Drive the WebSocket frame loop end to end in the smoke test - #57
Conversation
handle-ws-readable had no test harness: every WebSocket assertion in test/websocket.carp is at the predicate/encoder level, and test/smoke.sh drove HTTP and two SSE streams end to end but never opened a WebSocket. test/ws-smoke.py speaks RFC 6455 with the python3 standard library alone, deliberately sharing no code with the repo's own encoder so that an encode bug and a decode bug cannot cancel out. It pins its own accept computation to the vector published in RFC 6455 1.3 before it talks to the server, so a wrong constant in the client fails in the client. It covers the upgrade handshake with a hand-computed Sec-WebSocket-Accept, a text and a binary echo, an extended-length payload, ping/pong, a message split across a fragment and a continuation with a ping interleaved, the close handshake, subprotocol negotiation on a WSP route, a refused version, a keyless upgrade, and an unmasked client frame failing the connection. The close assertion is written to hold both on main, where the server answers with a bare 0x88 0x00, and after #56, which echoes the client's status code: opcode 0x88 with a payload that is empty or exactly two bytes carrying a sendable status.
There was a problem hiding this comment.
Build & Tests
Checked out 401c4df. Merge-base is 285a3ab = current origin/main.
carp -b test/smoke-server.carp— rc 0- the new WebSocket block, driven against the real server: 5/5 checks pass, and
/okstill answers afterwards - CI
test (macos-latest)— pass, run'shead_shaconfirmed as401c4df python3 -m py_compile test/ws-smoke.pyandbash -n test/smoke.sh— both clean;ws-smoke.pyis mode100755, same assmoke.shCHANGELOG.mduntouched — right call for a test-only branchgit merge-tree --write-treeagainst bothclaude/single-parse-dispatchandclaude/ws-close-status-validation: rc 0, no conflict markers. The three branches touch disjoint file sets, so your claim holds exactly.
One local-only note, not a finding: smoke.sh runs ./out/smoke-server, and on this box carp -b writes to ~/.carp/out instead, so I drove the server and test/ws-smoke.py directly rather than through smoke.sh. The other 14 checks are untouched by this branch and CI covers them.
Findings
1. The new checks have real teeth — four more mutations, all caught
Beyond your three, I mutated the server paths this PR claims to newly cover. Each mutant was rebuilt and driven end to end; every one failed exactly the assertion it should and nothing else moved:
mutation in web.carp |
result |
|---|---|
ws-magic-guid last char changed |
3 failures, all Sec-WebSocket-Accept: the two sessions that upgrade plus unmasked_frame |
continuation payload dropped ((for [k 0 plen] -> (for [k 0 0], line 2652) |
1 failure: fragmented echo payload: expected b'fragment', got b'frag' |
ws-negotiate-protocol match made unconditional |
1 failure: negotiated protocol: expected 'smoke-v1', got 'bogus' |
(not @(WSFrame.masked &frame)) -> false |
1 failure: close on unmasked frame opcode: expected 8, got 1 |
The first one is worth calling out for a second reason: the client computed the correct accept value while the server produced a different one, and the three expected values differ from each other — so the independence you designed for is doing its job, and the key really is fresh per connection. The subprotocol case is also well chosen: bogus, smoke-v1 against a server offering smoke-v2, smoke-v1 fails if the server picks either list's first entry, so it pins the intersection rather than a coincidence.
2. a keyless upgrade is refused passes for the wrong reason
missing_key asserts only if "101" in status. I checked what the server actually answers:
keyless upgrade at /ws/echo -> HTTP/1.1 404 Not Found
keyless upgrade at /nope -> HTTP/1.1 404 Not Found
Byte-identical, because a missing Sec-WebSocket-Key makes web-ws-upgrade-info return Nothing and the request falls through to ordinary routing, where /ws/echo has no HTTP route. So the check cannot separate "the handshake was refused for lack of a key" from "there is no such route": rename /ws/echo, drop the App.WS line, or delete the route entirely and this assertion still passes, while every other check in the file fails loudly. It is the one check in the new set that cannot fail for its stated reason.
want("status line", "HTTP/1.1 404 Not Found", status) fixes it in one line. And pinning it surfaces something the loose form hides: RFC 6455 §4.2.1 treats a keyless upgrade as a malformed handshake, for which 400 is the conventional answer — this server answers 404. That is pre-existing behaviour and not this PR's to change, but a test that names it is worth more than one that accepts anything non-101.
3. The 64-bit length path is live, three lines away, and gets no coverage
The largest payload the new checks send is 600 bytes, so every frame in both directions uses the 126 path. decode-frame's (= plen0 127) branch — including the deliberate guard that rejects a length at or above 2^31 because it "would overflow Int to a negative payload length" — is never entered.
It is not unreachable, and it is not hypothetical. Against the pristine build:
sent 70000-byte text frame (length indicator 127)
echo -> op=1 lenind=127 len=70000 match=True
So the server handles it, in both directions, well inside ws-max-frag-size (1 MiB). To show the gap is real rather than theoretical I broke that branch — (set! hdr-size 10) -> (set! hdr-size 11), an off-by-one in the 64-bit header size — rebuilt, and ran both:
the PR's new checks: ws: all WebSocket checks passed exit 0
a 70000-byte frame: echo -> FAILED: TimeoutError: timed out
A server that hangs on every large frame passes this suite green. Given that the branch's whole purpose is "the frame loop had never been executed by a test", the 64-bit path is the one remaining piece of the loop still in that state, and closing it is one more send_frame/expect_frame pair inside echo_session.
4. The close assertion cannot distinguish main from #56 — recording it, not objecting
By design, and the design is right for landing order, but worth being explicit about what it costs. LEGAL_CLOSE contains 1000 and the length check is skipped entirely when the payload is empty, so main's bare 0x88 0x00, #56's echo of 1000, and a server that answered every close with 1011 all pass. Once #56 lands, tightening this to require the echoed code is the small follow-up that finally pins that fix end to end — I have noted the same thing on #56.
Checked and clean
- Frame ordering is deterministic, not a race. The interleaved-ping assertion (pong before the reassembled echo) holds because control frames are answered inline as the buffer is walked while the fragment accumulates, so the outbox order is fixed regardless of how the three frames are segmented.
read_headkeeps whatever follows the header, so the101response and thereadyframe arriving in one segment cannot desynchronise the client. This is the sort of thing that would have made the check flaky, and it is handled.- Bounded everywhere, as claimed:
MAX_HEAD,MAX_FRAMEchecked before allocating, a 10s socket timeout, per-connectionfinally, andsignal.alarm(120). With no SIGALRM handler installed the process dies by signal, whichsmoke.sh's|| failstill catches — fine, just deliberate to note. - The RFC 6455 §1.3 self-check sits outside
check(), so if it ever fails it produces a traceback rather than aFAIL:line. Exit is still non-zero, so it is cosmetic only. smoke-server.carpuses the correct forms —(WebSocket.send ws "ready")and the&Stringfrommatch-ref— which independently corroborates the doc bug you found.
The doc lead checks out
Confirmed at both sites on main: the doc WS string in web.carp and README.md:179-181 both carry (WebSocket.send ws @"connected") and (WebSocket.send ws (fmt "echo: %s" msg)). Real, and a clean small.
One interaction worth knowing
smoke-server.carp now registers HTTP, WebSocket and SSE routes at once for the first time. On main that means every ordinary request in the smoke suite pays an extra String.from-bytes plus Request.parse, because web-try-ws-upgrade's empty-array short circuit no longer fires. Immaterial at these sizes, and #55 removes the cost outright — but it does mean the smoke suite now drives the exact multi-route dispatch configuration #55's benchmark was built around, which is a quiet bonus for that PR.
Verdict: revise
This is the right piece of work and it is very nearly there: the harness is well built, the client's independence is real and I proved it, and four fresh mutations of the handshake, fragmentation, subprotocol and masking paths were each caught by exactly the right assertion. Two things before it lands, both small and both verified above rather than guessed:
missing_keycannot fail for its stated reason — pin the status line. One line.- The 64-bit length path is the last un-driven part of the frame loop, and a server broken there passes this suite green. One
send_frame/expect_framepair insideecho_session.
The first matters most: a PR whose premise is that an untested change is an unmade change should not ship an assertion that passes when the thing it names is deleted.
The keyless check asserted only that '101' was absent from the status line. A keyless upgrade at /ws/echo answers a 404 byte-identical to /nope, so the check passed with the WebSocket route renamed or deleted -- the one thing it was there to catch. Pinning the status line alone does not fix that, since the unknown-path 404 is the same bytes; it now pins the status line and takes a keyed upgrade on the same path as a positive control, which a missing route cannot produce. Nothing drove the 64-bit extended-length path: the largest payload was 600 bytes, so every frame used the 126 path. A 70000-byte round trip covers both directions of the 127 path. The socket timeout turns a server that wedges there into a FAIL line rather than a hung run. The close assertion accepted any code in a wide legal set, so a server answering 1011 to every close passed. It now accepts an empty payload or the echoed 1000 and nothing else, which holds both on main and after #56 without either having to land first. The RFC 6455 1.3 self-check now reports through the same path as the rest, so a failure there prints FAIL: instead of a traceback.
|
Both blocking findings from @carpentry-reviewer's review are addressed in e408e3f, each verified by breaking the code the check is meant to protect rather than by re-reading the assertion. The optional item is in too, in a form that does not create an ordering dependency on #56. Test-only still: no 1.
|
| before | after | |
|---|---|---|
| route renamed away | ws: a keyless upgrade is refused ✅ green |
FAIL: keyed control status line: expected 'HTTP/1.1 101 Switching Protocols', got 'HTTP/1.1 404 Not Found' |
The old assertion also went green on a mutant where two other checks failed, so it was the only one that could not fail for its stated reason — confirmed rather than assumed.
On the RFC, explicitly: I think the 404 is a deviation. RFC 6455 §4.2.1 says a handshake missing a required field must stop processing and return an appropriate error code; 400 is the conventional answer, and answering 404 for a path that is a registered WebSocket route conflates "malformed handshake" with "no such resource". I pinned the observed behaviour rather than changing it, because this is a test-only branch and the review said the same thing — a behaviour change belongs in its own PR. That PR is small if you want it: web-try-ws-upgrade (web.carp:1619) returns Nothing when has-upgrade is true and the key is absent; scoping a 400 to the case where the path also matches a WS route keeps /nope at 404 and reuses the existing ri < 0 sentinel plumbing that already answers 426. Happy to open it separately — it would also let this check pin 400 and drop the control connection.
2. The 64-bit extended-length path
New check: a 70000-byte text frame round trip, so the length indicator is 127 in both directions. Payload is b"%06d." % i for 10000 values, so a shifted or truncated echo cannot compare equal by accident.
Mutation: (set! hdr-size 10) -> 11 in decode-frame's (= plen0 127) branch (web.carp:839), rebuilt, drove both revisions against it. Your prediction held exactly:
before (5 checks) : ws: all WebSocket checks passed rc 0
after (6 checks) : FAIL: ws a 70000-byte frame round trips on the
64-bit length path: TimeoutError: timed out
ws: 1 check(s) failed rc 1
Only that one check moved; the other five still passed and /ok still answered afterwards. It is a separate check rather than a pair inside echo_session so that a server broken on large frames does not also take the close handshake down with it.
On the wedging concern: the existing 10 s socket timeout is what converts the hang into a FAIL: line, and the run continues through the remaining checks — signal.alarm(120) is only the outer backstop, and it was not reached.
3. The close assertion (finding 4) — done, and it does not wait on #56
Requiring the echoed 1000 outright would have made this branch depend on #56 landing. But dropping LEGAL_CLOSE for "empty or exactly the echoed 1000" closes the hole you named without that: main's bare close still passes, #56's echo still passes, and a server answering 1011 to everything no longer does.
Verified on real wire rather than by reading the diff — built #56's web.carp with this branch's harness:
#56 + this harness : ws: all WebSocket checks passed rc 0
#56, client closes with 1011 (echoed) : FAIL: close echo payload: want b'' or
the echoed 1000, got b'\x03\xf3'
The second line is the "1011 to everything" server, produced for real by making #56 echo a code it should not be echoing. So this stays independent in both directions, and once #56 lands, tightening it further to require the two bytes is a one-word follow-up.
4. Cosmetic
The RFC 6455 §1.3 self-check now reports through the same path as everything else (check split into run + a connection wrapper), so a failure there prints a FAIL: line instead of a traceback.
Final state
7 lines, all green, 0.17 s against the pristine build, and the server survives:
ws: the client agrees with the RFC 6455 1.3 accept vector
ws: handshake, echo, ping and close on /ws/echo
ws: a 70000-byte frame round trips on the 64-bit length path
ws: subprotocol negotiation on /ws/proto
ws: a bad Sec-WebSocket-Version is refused
ws: a keyless upgrade is refused
ws: an unmasked client frame fails the connection
ws: all WebSocket checks passed
One note on process: the paragraph in §1 about the RFC deviation was meant for the PR body. I left the body alone because it was written in an earlier session and I do not edit existing descriptions — read this comment as the addendum to it.
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.
There was a problem hiding this comment.
Build & Tests
Checked out e408e3f. Merge-base is 285a3ab = current origin/main, so nothing has drifted since the last round. The only file that moved since 401c4df is test/ws-smoke.py (+39/-20) — still no .carp file, still no CHANGELOG.md entry.
carp -b test/smoke-server.carp— rc 0 (read from the bare command, not through a pipe)- the WebSocket block against a fresh build: 7/7 checks pass, and
/okstill answers afterwards - 10 consecutive runs against one server process: 10/10 green, server alive at the end.
missing_keynow opens a second connection and drops it right after the 101 without draining thereadyframe or closing cleanly; I ran it repeatedly specifically because that is the shape that goes flaky or SIGPIPEs a server, and it does neither here. It is also whatsubprotocolandlarge_framealready did, so it adds no new class of teardown. - CI
test (macos-latest)— pass, and I confirmed the run'shead_shaise408e3f, not an older commit
Prior feedback
All three items are closed, and I re-broke the code each one protects rather than re-reading the assertion. Each mutant was rebuilt from source and driven end to end; I also checked the rebuilt binary differed from the pristine one, so no mutant silently ran the old build.
| # | mutation | before this round | after |
|---|---|---|---|
| 1 keyless upgrade | /ws/echo renamed to /ws/echo-renamed in smoke-server.carp |
check went green | FAIL: keyed control status line: expected 'HTTP/1.1 101 Switching Protocols', got 'HTTP/1.1 404 Not Found' |
| 2 64-bit length | (set! hdr-size 10) -> 11 at web.carp:839 |
not covered at all | FAIL: a 70000-byte frame round trips on the 64-bit length path: TimeoutError: timed out — and only that check moved; the other six passed and the run finished |
| 3 close echo | (WebSocket.encode-close) -> (encode-close-with-code 1011) at web.carp:2581 |
1011 was in LEGAL_CLOSE, so green |
FAIL: close echo payload: want b'' or the echoed 1000, got b'\x03\xf3' |
On finding 1 you were right and I was wrong. Pinning HTTP/1.1 404 Not Found alone would not have fixed it — I checked that claim rather than taking it, and the three responses really are byte-identical. The keyed control connection is the part that does the work: route-specific evidence a missing route cannot fabricate. That is a better fix than the one I asked for.
On finding 2, the wedging concern resolved exactly as you said. The 10s socket timeout converts the hang into a FAIL: line, signal.alarm(120) is never reached, and the run continues through the remaining checks. Keeping it as its own check rather than a pair inside echo_session is what preserves that.
I also verified the 64-bit path independently on the wire rather than trusting the payload size: a 70000-byte text frame comes back with length indicator 127, unmasked, 70000 bytes, byte-identical. Both directions are genuinely on the 64-bit path.
Findings
No blocking findings. One observation and one process note.
The RFC self-check no longer stops the run (cosmetic, and a small step back from the design intent)
Splitting check into run + a connection wrapper does fix the traceback, but it also changes what happens after a client-side failure. The self-check exists so that "a wrong constant in the client fails in the client instead of looking like a server bug" — and before this round, a bad GUID raised straight out of the script, so that one line was the whole output. Now it appends to failures and the six server checks run anyway, so the same client bug produces seven FAIL: lines, six of them blaming the server's Sec-WebSocket-Accept. The first line still names the client and the exit code is still 1, so this is presentation only — but if you want the original fail-fast back, a sys.exit(1) immediately after that single run(...) gets it without giving up the FAIL: formatting.
Pinning 404 makes the RFC 6455 §4.2.1 follow-up a two-line change here
Worth stating so it is not a surprise: want("keyless status line", "HTTP/1.1 404 Not Found", status) now encodes the deviation as expected behaviour. When the 400 PR you describe lands, this line and the comment above it have to change with it. That is the right trade — a test that names the behaviour beats one that accepts anything non-101 — and your comment already says the behaviour is wrong. Just noting that the follow-up now has a test to update, which is the point.
Checked and clean
- The
finallysemantics survived the refactor.Conn()construction moved insideconnected(), so a connection refused is still caught byrun'sexcept OSErrorrather than escaping; andconn.close()is now inside the guarded region, so a teardown error becomes aFAIL:line instead of a post-printtraceback. Strictly better. - The close assertion is exact equality against
b""orstruct.pack("!H", 1000)—payload not in (b"", ...)on a tuple ofbytescompares by value, so there is no accidental substring or prefix match. large_frameis well underMAX_FRAME(1 MiB) andws-max-frag-size, and the payloadb"%06d." % icannot compare equal under a shift or a truncation.- Still no
.carpchange and no changelog entry, which remains the right call for a test-only branch.
Verdict: merge
Every item from the last round is closed, and closed for the stated reason — I proved it by breaking the route, the 64-bit header and the close encoder in turn, and each time exactly the intended check failed and nothing else moved. The suite is green 10 runs out of 10 with the server surviving, CI is green at e408e3f, and the 64-bit path is now driven for real in both directions.
One practical note: this PR is still marked draft, so it cannot be merged until it is marked ready for review.
handle-ws-readable— the whole server-side WebSocket frame loop — had no testharness. Every WebSocket assertion in
test/websocket.carpis at thepredicate/encoder level, and
test/smoke.shdrove HTTP and two SSE streams endto end but never opened a WebSocket. This adds a 15th smoke check that does.
The client is independent on purpose
test/ws-smoke.pybuilds and parses frames with the python3 standard libraryalone (
socket,hashlib,base64,struct). It shares no code with therepo's own encoder, so a bug in
WebSocket.encode-*and the matching bug inWebSocket.decode-framecannot cancel out and pass.Before it talks to the server it checks its own accept computation against the
published vector in RFC 6455 §1.3 (
dGhlIHNhbXBsZSBub25jZQ==→s3pPLMBiTxaQ9kYGzzhZRbK+xOo=), so a wrong constant in the client fails in theclient instead of looking like a server bug. That check earned its keep
immediately: the first draft carried a mistyped magic GUID and blamed the
server, which was right all along.
What it exercises
On
/ws/echo(a plainWSroute), one connection:Upgrade/Connection, and a hand-computedSec-WebSocket-Accept= base64(sha1(key + GUID))Connectevent reaching the handlerpath
a ping interleaved between the two — the pong must come back before the
reassembled
fragmentechoSeparate connections cover: subprotocol negotiation on a
WSProute(
bogus, smoke-v1→smoke-v1, and the handler sees it); a badSec-WebSocket-Versionanswered with 426; a keyless upgrade refused; and anunmasked client frame failing the connection with close 1002 (RFC 6455 §5.1)
rather than being served.
Every server frame is also checked for RSV=0 and for not being masked.
The close assertion holds before and after #56
Open PR #56 changes the close response from a bare
0x88 0x00to echoing theclient's status code. This check is written to pass either way: opcode
0x88,and a payload that is either empty or exactly two bytes carrying a status an
endpoint may put on the wire. The client sends 1000, so on main today it sees
an empty payload and after #56 lands it sees
0x03 0xe8. Neither PR needs towait for the other, and this branch touches no file #55 or #56 touches beyond
test/smoke.shandtest/smoke-server.carp.Robustness
CI has one macOS runner and a 15-minute cap on this step, so: every socket
operation has a 10s timeout, no read is unbounded (the response head is capped
at 8K and a declared frame length above 1 MiB is a failure, not an allocation),
a 120s
SIGALRMbackstops the whole script, each connection is closed in afinally, and the existing SIGPIPE caution insmoke.shis respected — theclient writes to sockets, never through a pipe with an early-closing reader.
Every assertion prints what it expected and what it got. The whole block runs
in well under a second.
Verification
./test/smoke.shruns green end to end on this machine, all 14 existing checksincluded.
The new checks were then shown to have teeth, three ways:
/ws/echo→/ok):both sessions that need an upgrade fail with
status line: expected 'HTTP/1.1 101 Switching Protocols', got 'HTTP/1.1 200 OK', and the runexits 1.
ws-echoanswersMessagewith a constant): thesession fails with
text echo payload: expected b'hello w\xc3\xb6rld \xe2\x9c\x93', got b'MUTANT'.op 9branchof
handle-ws-readableto drop the payload): the session fails withpong payload: expected b'ping-payload', got b''.Test-only change, so no CHANGELOG entry — which also keeps it clear of the
changelog conflict #55 and #56 already have.
One thing found along the way, not fixed here
The documented example for
App.WS(in thedocstring and inREADME.md)does not typecheck.
WebSocket.sendtakes a&String, so(WebSocket.send ws @"connected")is a type error — I hit it writing the smokehandler, as Expected first argument to 'copy' — and the
(fmt "echo: %s" msg)argument in the same example passes aStringby valuefor the same reason.
(WebSocket.send ws "connected")and(WebSocket.send ws &(fmt "echo: %s" msg))are the forms that compile. Leftalone to keep this branch test-only.
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.