Add WebSocket subprotocol negotiation (RFC 6455 §4.2.2)#22
Conversation
There was a problem hiding this comment.
Build & Tests
CI fails on the borrow checker:
The reference '(WSRoute.protocols (Array.unsafe-first__WSRoute (App.ws-routes (ref app))))' (depending on the variable 'app') isn't alive at line 1083, column 7
The last two tests ("App.WSP stores protocols on the route" and "App.WS stores empty protocols on the route") return a reference from WSRoute.protocols into a let-bound app that gets dropped before assert-equal can use it. Fix by copying:
;; line 1005
@(WSRoute.protocols (Array.unsafe-first (App.ws-routes &app)))
;; line 1011 — same fix
@(WSRoute.protocols (Array.unsafe-first (App.ws-routes &app)))
And change the expected values to owned:
(assert-equal test
&(Array.copy &[@"chat-v2" @"chat-v1"])
...
or just @ the result side — the expected side is already an owned literal wrapped in &, so @ on the result side to produce an owned (Array String) that both sides of assert-equal can borrow equally would be cleanest.
All other tests (parse-protocols, negotiate-protocol, upgrade-path, route construction count) pass.
Could not build locally — the Pi 500 has no working carp binary.
Prior feedback
No previous reviews.
Findings
1. Borrow checker failure in tests (blocking)
Described above. Two-character fix.
2. Multiple Sec-WebSocket-Protocol headers ignored (non-blocking, worth noting)
RFC 6455 §4.2.1 allows clients to send subprotocols either as a single comma-separated header OR as multiple separate headers:
Sec-WebSocket-Protocol: chat, rpc
vs.
Sec-WebSocket-Protocol: chat
Sec-WebSocket-Protocol: rpc
The HTTP parser (carpentry-org/http) collects multiple values for the same header key into an (Array String). The current code takes only (Array.unsafe-first &proto-vals) and parses that one value. If a client sends two separate headers, only the first one's protocols are considered.
A more complete implementation would iterate all values in proto-vals:
client-protos (let-do [acc (the (Array String) [])]
(for [i 0 (Array.length &proto-vals)]
(let [parsed (ws-parse-protocols (Array.unsafe-nth &proto-vals i))]
(for [j 0 (Array.length &parsed)]
(Array.push-back! &acc @(Array.unsafe-nth &parsed j)))))
acc)
This is a correctness issue per the RFC but unlikely to bite real clients (browsers use the comma-separated form). Up to you whether to fix now or defer.
3. Server can't reject upgrades when no protocol matches (design note)
When the client requests protocols and none match the server's list, the upgrade still succeeds with protocol = Nothing. RFC 6455 §4.2.2 says the server MAY fail the handshake in this case. The current behavior is permissive, which is valid — just noting it as a design choice handlers should be aware of. The doc on WSP could mention this: handlers that require a specific protocol should check (WebSocket.protocol ws) on Connect and close if Nothing.
4. Everything else looks correct
ws-parse-protocols: handles commas, whitespace, empty strings, filters empty tokens. Good.ws-negotiate-protocol: respects client preference order (iterates client list outer, server list inner). Correct per RFC 6455 §4.2.2.- 101 response:
Sec-WebSocket-Protocolheader included only when negotiated. Correct. App.WSbackward compatibility: passes empty protocols array, negotiation is skipped. No behavioral change for existing code.ConnStatecleanup:ws-protocolentry removed on connection close. No leak.handle-ws-readable: reconstructsWebSocketwith stored protocol for subsequent handler calls. Correct.defservermacro:WSPform correctly emits(App.WSP (copy path) protocols handler).- Memory ownership:
@protocopies where needed, references used correctly in match arms. - CHANGELOG is thorough and accurate.
Verdict: revise
Fix the borrow checker error in the two test assertions (one-line change each) and CI will pass. Consider the multi-header point (#2) — if you want to address it, it's a small change in web-try-ws-upgrade. Otherwise the implementation is solid and ready to merge after the test fix.
There was a problem hiding this comment.
Build & Tests
CI fails — borrow checker error at test/websocket.carp:1083:
The reference '(WSRoute.protocols (Array.unsafe-first__WSRoute (App.ws-routes (ref app))))' isn't alive
Confirmed locally: carp --check test/websocket.carp produces the same error. carp --check web.carp (main source) passes cleanly. CI shows 62 tests passed / 0 failed before the borrow checker halted compilation of the last two tests.
Prior feedback
One previous review round. The three findings were:
-
Borrow checker failure in test assertions (blocking) — still unresolved. Lines 1005 and 1011:
WSRoute.protocolsreturns a reference into the let-boundapp, which is dropped beforeassert-equalcan use it. Same fix as before:@(WSRoute.protocols ...)to copy. -
Multiple Sec-WebSocket-Protocol headers ignored (non-blocking) — still present.
web-try-ws-upgradeat line 1060 takes only(Array.unsafe-first &proto-vals). If a client sends separate headers instead of comma-separated, only the first is parsed. -
Server can't reject upgrades when no protocol matches (design note) — acknowledged as a deliberate permissive choice. Valid per RFC.
Findings
Independently verified the full implementation. No new blocking issues found.
Implementation correctness:
ws-parse-protocols(web.carp:999-1006): correctly splits by comma, trims whitespace, filters empties. Handles edge cases (single value, empty string, leading/trailing spaces).ws-negotiate-protocol(web.carp:1012-1023): respects client preference order (outer loop = client, inner = server). Usesbreakcorrectly in both loops. Returns first client protocol found in server list.- 101 response (web.carp:1403-1409):
Sec-WebSocket-Protocolheader included only whenprotoisJust. Correct per RFC 6455 §4.2.2. App.WSbackward compat (web.carp:1223): passes empty[]as protocols toWSRoute.init. No behavioral change for existing code. Correct.- ConnState lifecycle:
ws-protocolstored on upgrade (line 1421), retrieved on readable (line 1466-1469), cleaned up on close (line 1327). No leak. - WebSocket reconstruction (web.carp:1470):
handle-ws-readablerebuildsWebSocketwith stored protocol for handler calls. Protocol correctly flows through the full lifecycle. defservermacro (web.carp:1907-1908):WSPform correctly emits(App.WSP (copy path) protocols handler), handling the 4-argument form separately from the generic 3-argument route expansion.- ConnState init (web.carp:1943): the new
{}is correctly positioned at field 13 (ws-protocol), between ws-params and ws-frag-bufs. 17 fields, 17 arguments. - CHANGELOG: thorough and accurate. Covers both Added and Changed sections.
- Test coverage: protocol parsing (5 tests), negotiation (5 tests), upgrade path (5 tests), route construction (3 tests). Good breadth.
Verdict: revise
Fix the two test assertions at lines 1005 and 1011 by copying the result:
;; line 1005
&(let [app ...] @(WSRoute.protocols (Array.unsafe-first (App.ws-routes &app))))
;; line 1011 — same pattern
&(let [app ...] @(WSRoute.protocols (Array.unsafe-first (App.ws-routes &app))))
That's the only change needed — the implementation itself is solid. Consider the multi-header point from the previous review if you want full RFC compliance, but it's non-blocking.
Copy WSRoute.protocols result with @ inside a &(let ...) block so the owned copy outlives the borrowed app.
|
Fixed the borrow checker error flagged by @carpentry-reviewer in both reviews. What changed: Lines 1003–1005 and 1010–1011 in Verified: The non-blocking items from the reviews (multi-header RFC compliance, permissive upgrade on no-match) are noted but deferred — they don't affect the correctness of the current feature. |
Summary
Implements WebSocket subprotocol negotiation per RFC 6455 §4.2.2:
App.WSPregisters a WebSocket route with a list of supported subprotocols. During the upgrade handshake, the server parses the client'sSec-WebSocket-Protocolheader, selects the first client-requested protocol that appears in the route's list, and includesSec-WebSocket-Protocol: <selected>in the 101 response.WebSocket.protocolexposes the negotiated subprotocol ((Maybe String)) to handlers, allowing protocol-aware dispatch.App.WSis unchanged — routes without protocols simply skip negotiation. Fully backward compatible.(WSP "/path" protocols handler)form supported indefservermacro.Type changes
WSRoutegainsprotocols (Array String)WebSocketgainsprotocol (Maybe String)ConnStategainsws-protocol (Map Int (Maybe String))web-try-ws-upgradereturn type extended with negotiated protocolInternal helpers
ws-parse-protocols— parses comma-separatedSec-WebSocket-Protocolheaderws-negotiate-protocol— selects first client protocol matching server listTests added
App.WSP(matching, no match, no header, WS route ignores)App.WSPstores protocols,App.WSstores empty)Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.