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
- Test server drains before teardown, as
SilentClose already does. Narrow, matches existing
practice in the same file, and fixes the flake.
- 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.
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 orwindows-latest, and not reproduced locally on Linux.
Minimal reproduction
cargo test --workspace --no-fail-faston macOS. The failure is intermittent: one failure in thelast 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.
Observed and expected behavior
Expected: the
InvalidCloseCodefault maps toWebSocketTerminalCause::ExpectationFailed, becausethe server closes with code 1005, which the sealed plan does not accept.
Observed:
WebSocketTerminalCause::IoFailure. The executor never reports the verdict it derived fromthe received frame, because a socket error during teardown overwrites it.
Mechanism
inject_post_upgrade_faultwrites the invalid close frame and returns immediately(
crates/kahea-test-server/src/websocket.rs:603, called at:508), after which the connection isdropped. 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/EPIPEand theobservation is finalized as
IoFailure. macOS resets sooner than Linux under this pattern, which iswhy only that leg trips, and only sometimes.
The test server already anticipates this hazard elsewhere:
SilentClosedrains the client's closeframe with
consume_client_close(socket.get_mut())before waiting (:615). The post-upgrade faultpaths do not.
InvalidClosePayload,InvalidUtf8,ReservedOpcode,ReservedBit,FragmentedControlFrame, andTruncatedFrameshare the same shape and are latent instances of thesame race;
InvalidCloseCodeis simply the case where the client is most likely to reply before theserver tears down.
Two candidate fixes, which are not equivalent
SilentClosealready does. Narrow, matches existingpractice in the same file, and fixes the flake.
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 intests. Terminal cause is a public
kahea/k1field, so a change here is a contract decision andshould 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 onmain, so every pull request has asmall chance of a spurious block. #33 hit exactly this and was re-run rather than merged on a red
check.