Test what the terminal-waiter fix only reasoned about - #174
Conversation
Four claims held up #173's fix that no test touched. The composition that actually broke. A Detached loop parked in Receive, its Share() handle in an async_delivery registry, and a peer closing while a fan-out delivery is parked -- the hub shape the downstream hang was reported against. No suite had it: the transport suites park waiters with no registry above them, the registry suites park chains with no coroutine loop below, and the bug needed both halves at once. Added to session_registry_test, where it wedges on the old ordering. The branch the fix created. Firing the send first means a coroutine's OWN parked send can now resume a loop that ends and runs the whole revocation drain inline, underneath Fire, before Fire reaches its receive branch. That is safe only because awaits are sequential, so the branch Fire returns to is empty -- reasoning the contract suite did not exercise, since it covered lone-receive and both-parked but not lone-send. That a third party can use any of this. TerminalWaiters and the contract suite were made public for out-of-tree implementors, and nothing showed one could reach them. The consumer module now implements a WebSocket in consumer code, runs its terminal transition through TerminalWaiters, and is held to the same suite across the module boundary -- which also proves the testonly target is reachable, previously assumed. That JsonRpcStreamSocket parks nothing. This is why the fix needed no change there, checked by reading. Instantiating the suite for it keeps that true if the decorator ever grows slots of its own. Verified the way the fix was: flipping the order in TerminalWaiters::Fire fails the registry test and all four instantiations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012hdJLWdx7u7mpACwZXs2bt
macOS failed ATerminalTransitionCompletesALoneParkedCoroutineSend, and the cause was in the detector, not the platform. WaitUntilStable required the send counter to be non-zero before it would believe the counter had settled. But with a message large enough, the FIRST send parks and the counter never leaves zero -- so the guard made the helper blind to the most decisively parked state there is. Zero is a perfectly good stable value here: a Detached coroutine starts eagerly and issues its first send before the launch expression returns, so a settled counter cannot mean "not started yet". It means parked, or finished -- and the test now rules out finished explicitly, which the old predicate was accidentally doing and getting wrong. The Beast driver's messages were also too small to wedge a macOS wire: it absorbed 1 MiB writes for a full 30s without ever parking. Worse, the 2s-per-attempt timeout in WedgeThenPark read those slow-but-progressing writes as parked ones, so the sibling test passed on a wire that had not wedged at all. Eight mebibytes exceeds any loopback's buffers, so the first write parks and both detectors agree for the right reason. Together these cut the Beast contract group from 39s to 8s, since the tests now park immediately instead of grinding through their budgets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012hdJLWdx7u7mpACwZXs2bt
|
Both macOS jobs failed The detector could not detect a parked send. stable = (now == last && now > 0) ? stable + 1 : 0;With a large enough message the first send parks, so the counter never leaves zero and stability is never reported. The guard made the helper blind to the most decisively parked state there is. Zero is a fine stable value here: a The Beast driver's messages were too small to wedge a macOS wire. It absorbed 1 MiB writes for the full 30s without ever parking. The more interesting part: Two things I'd call out from this:
Re-verified locally: Generated by Claude Code |
|
Approve with nits. Tests-only follow-up to #173 that closes real coverage gaps; CI is green on What worksThe four claims map cleanly onto tests:
NitsLone-send test overclaims what it catches. The comment says send-first can run pin drain under
Registry test still hand-rolls the unwind wait instead of VerdictMergeable. The composition test and the consumer/JsonRpc suite wiring are the load-bearing bits; the detector/size fix makes the new Beast coverage real rather than accidentally green. Only ask I’d make before merge: tone down the lone-send comment so it matches the failure mode it actually has. |
Review caught the comment describing a scenario the test did not build. It never called Share(), so view_ held no state, so ~AsyncEventStream's End() returned immediately -- there was no revocation drain to run under Fire, and the comment's talk of one was wrong. Share() now, which is what makes the case interesting: firing the lone parked send resumes the loop inline, and the loop's exit -- revoke, close the session, drain -- runs underneath Fire on the completing thread, with the close reentering the transition that is still running. The comment also implied this was the ordering tripwire. It is not, and cannot be: with no receive parked, Fire's order is unobservable here. It now says so and points at the test that does pin the ordering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012hdJLWdx7u7mpACwZXs2bt
|
Nit 1 was right on both counts, and I checked rather than took it on faith — The test never called I took the stronger fix rather than only softening the prose: the loop now Worth noting this is the second time on this PR that a green test turned out to be asserting less than it claimed — the first was On the other two:
Registry test hand-rolling the unwind wait — leaving it. Re-verified after the change: Generated by Claude Code |
What
Follow-up to #173. That PR's fix rested on four claims that no test touched — each one checked by reading, which is exactly how the original bug survived in two transports at once. Tests only, plus one CHANGELOG line.
1. The composition that actually broke. A
Detachedloop parked inReceive, itsShare()handle in anasync_deliveryregistry, and a peer closing while a fan-out delivery is parked on the wire — the hub shape the downstream hang was reported against. No suite had it, because the halves live in different files: the transport suites park waiters with no registry above them (beast_websocket_test, the contract suite), and the registry suites park chains with no coroutine loop below (TeardownWithAParkedChainNeverHangsdestroys the registry, not the session, and has noAsyncEventStreamat all). The bug needed both at once. Now insession_registry_test.2. The branch the fix created. Firing the send first means a coroutine's own parked
co_await Sendcan resume a loop that ends and runs the entire revocation drain inline — underneathFire, beforeFirereaches its receive branch. That's safe only because a coroutine's awaits are sequential, so the branchFirereturns to is empty. The contract suite covered lone-receive and both-parked, but not lone-send, so the hazard the fix relocated was the one case it didn't exercise. If a handle ever grows aReceive, this fails as a use-after-free rather than a hang.3. That a third party can use any of this.
TerminalWaitersand the contract suite were made public specifically for out-of-tree implementors, and nothing demonstrated one could reach them. The consumer module now implements aWebSocketin consumer code, runs its terminal transition throughTerminalWaiters, and is held to the same contract suite across the module boundary. That also closes an assumption I'd flagged but not tested: that thetestonlywebsocket_contract_test_supporttarget is reachable from a consuming module at all.4. That
JsonRpcStreamSocketparks nothing. This is why the fix needed no change there — its async twins forward to the socket underneath — and it was checked by reading. Instantiating the suite for it keeps that true if the decorator ever grows slots of its own, and incidentally shows the suite works for a decorator rather than only for real transports.Four implementations now run the shared suite: both in-repo transports, the JSON-RPC decorator, and the consumer's.
Testing
Verified the same way the fix was: flipping the order in
TerminalWaiters::Fire— one edit — and confirming the new tests fail. The registry composition test wedges ("the close wedged: a parked fan-out delivery was never completed"), and so does the ordering test in all four instantiations, including the out-of-tree one — which is the point of #3: the third-party socket inherits the rule without its author knowing the rule exists.async_event_stream_testbeast_websocket_testsession_registry_testjsonrpc_stream_socket_testwebsocket_contract_consumer_testwebsocket_pair_testevent_stream_testSame caveat as #173:
bazel testcan't fetch thebats-corearchive through this environment's proxy, so suites were compiled and run directly against vendored headers. Two things here are therefore unexercised by a real bazel run and want CI:cc_testand its dep on@smithy_cpp//runtime:websocket_contract_test_support— i.e. the very cross-module visibility claim item 3 is meant to prove. CI'sbazel consumerjobs are the real check.:websocket_contract_test_supportdep onjsonrpc_stream_socket_test.One judgement call worth flagging: the lone-parked-send test detects "the loop has parked" by watching its send counter go stable, because how many writes a wire accepts before wedging is the transport's business, not the suite's. That's a timing heuristic — two consecutive equal readings 500ms apart, 30s budget. It's the one thing here that could flake on a very slow runner, and it would flake as a clean
"the loop never parked in Send"failure rather than a hang.Checklist
bazel test //...and(cd codegen && gradle build spotlessCheck)pass locally — see the caveat; suites run directly, bazel could not fetch a toolchain dep hereGenerated by Claude Code