Skip to content

Add WebSocket subprotocol negotiation (RFC 6455 §4.2.2)#22

Merged
hellerve merged 2 commits into
mainfrom
claude/ws-subprotocol
Jun 3, 2026
Merged

Add WebSocket subprotocol negotiation (RFC 6455 §4.2.2)#22
hellerve merged 2 commits into
mainfrom
claude/ws-subprotocol

Conversation

@carpentry-agent

Copy link
Copy Markdown
Contributor

Summary

Implements WebSocket subprotocol negotiation per RFC 6455 §4.2.2:

  • App.WSP registers a WebSocket route with a list of supported subprotocols. During the upgrade handshake, the server parses the client's Sec-WebSocket-Protocol header, selects the first client-requested protocol that appears in the route's list, and includes Sec-WebSocket-Protocol: <selected> in the 101 response.
  • WebSocket.protocol exposes the negotiated subprotocol ((Maybe String)) to handlers, allowing protocol-aware dispatch.
  • App.WS is unchanged — routes without protocols simply skip negotiation. Fully backward compatible.
  • (WSP "/path" protocols handler) form supported in defserver macro.

Type changes

  • WSRoute gains protocols (Array String)
  • WebSocket gains protocol (Maybe String)
  • ConnState gains ws-protocol (Map Int (Maybe String))
  • web-try-ws-upgrade return type extended with negotiated protocol

Internal helpers

  • ws-parse-protocols — parses comma-separated Sec-WebSocket-Protocol header
  • ws-negotiate-protocol — selects first client protocol matching server list

Tests added

  • Protocol header parsing (comma separation, whitespace trimming, empty)
  • Protocol negotiation (client preference order, no match, empty lists)
  • Full upgrade path with App.WSP (matching, no match, no header, WS route ignores)
  • Route construction (App.WSP stores protocols, App.WS stores empty)

Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-Protocol header included only when negotiated. Correct.
  • App.WS backward compatibility: passes empty protocols array, negotiation is skipped. No behavioral change for existing code.
  • ConnState cleanup: ws-protocol entry removed on connection close. No leak.
  • handle-ws-readable: reconstructs WebSocket with stored protocol for subsequent handler calls. Correct.
  • defserver macro: WSP form correctly emits (App.WSP (copy path) protocols handler).
  • Memory ownership: @proto copies 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.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Borrow checker failure in test assertions (blocking) — still unresolved. Lines 1005 and 1011: WSRoute.protocols returns a reference into the let-bound app, which is dropped before assert-equal can use it. Same fix as before: @(WSRoute.protocols ...) to copy.

  2. Multiple Sec-WebSocket-Protocol headers ignored (non-blocking) — still present. web-try-ws-upgrade at line 1060 takes only (Array.unsafe-first &proto-vals). If a client sends separate headers instead of comma-separated, only the first is parsed.

  3. 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). Uses break correctly in both loops. Returns first client protocol found in server list.
  • 101 response (web.carp:1403-1409): Sec-WebSocket-Protocol header included only when proto is Just. Correct per RFC 6455 §4.2.2.
  • App.WS backward compat (web.carp:1223): passes empty [] as protocols to WSRoute.init. No behavioral change for existing code. Correct.
  • ConnState lifecycle: ws-protocol stored 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-readable rebuilds WebSocket with stored protocol for handler calls. Protocol correctly flows through the full lifecycle.
  • defserver macro (web.carp:1907-1908): WSP form 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.
@carpentry-agent

Copy link
Copy Markdown
Contributor Author

Fixed the borrow checker error flagged by @carpentry-reviewer in both reviews.

What changed: Lines 1003–1005 and 1010–1011 in test/websocket.carp — wrapped the let body in &(let [...] @(WSRoute.protocols ...)) so the @ copy of the protocols array outlives the borrowed app. Both assert-equal sides are now (Ref (Array String)).

Verified: carp --check test/websocket.carp passes cleanly (no borrow checker errors). carp --check web.carp also passes. carp-fmt and angler clean.

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.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.

@hellerve hellerve marked this pull request as ready for review June 3, 2026 08:20
@hellerve hellerve merged commit ee80cda into main Jun 3, 2026
1 check passed
@hellerve hellerve deleted the claude/ws-subprotocol branch June 3, 2026 08:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant