Skip to content

relay: SetReadLimit on WSConn — 256 KiB per-frame cap (#29)#44

Merged
ilmoniemi merged 3 commits into
mainfrom
feature/29
May 11, 2026
Merged

relay: SetReadLimit on WSConn — 256 KiB per-frame cap (#29)#44
ilmoniemi merged 3 commits into
mainfrom
feature/29

Conversation

@ilmoniemi
Copy link
Copy Markdown
Contributor

What

Bound every inbound WebSocket frame the relay reads from a peer by a deliberate 256 KiB per-frame cap, applied at the single chokepoint (NewWSConn) so the policy covers both the phone-side (StartPhoneForwarder, #25) and binary-side (StartBinaryForwarder, #26) forwarders without per-handler code.

  • NewWSConn gains a maxFrameBytes int64 parameter and calls *websocket.Conn.SetReadLimit before returning — there is no window in which Read can fire against an uncapped conn.
  • ServerHandler and ClientHandler gain a matching parameter and thread it through.
  • cmd/pyrycode-relay/main.go wires the literal at the composition root (const maxFrameBytes int64 = 256 * 1024), mirroring the existing 30*time.Second grace pattern. No package-level constant in internal/relay.
  • docs/knowledge/features/ws-conn-adapter.md: retired the "no per-message size cap on Read" follow-up; added the 256 KiB policy under § Adversarial framing.

Issue

Closes #29. Spec: docs/specs/architecture/29-wsconn-read-limit.md (security review verdict: PASS).

Testing

  • TestWSConn_Read_FrameExceedingCap_ReturnsError — round-trips an oversize frame through startEcho (cap=64); asserts the first AND a subsequent Read both return non-nil. Discharges "non-nil error" + "subsequent reads fail" ACs without asserting on the library-specific error type.
  • TestWSConn_Read_FrameAtCap_DeliveredIntact — at-cap frame (256 bytes against cap=256); asserts both the server-side received channel and the client-side echoed Read see the bytes intact.
  • startEcho now parametric on maxFrameBytes and echoes received frames back so the client-side cap can be exercised. Existing tests updated to pass 256*1024.
  • TestWSConn_ConnID_ReturnsConstructorValue rewritten to use startEcho (passing nil would now NPE inside SetReadLimit).
  • Test call sites: heartbeat_test.go, server_endpoint_test.go, client_endpoint_test.go — mechanical signature updates.

make vet, make test (race-enabled), make build clean.

Architecture compliance

  • Cap value is justified inline against pyrycode/pyrycode/docs/protocol-mobile.md § Backfill semantics (≤50 messages per message_chunk envelope; text-only payloads) in cmd/pyrycode-relay/main.go and the spec.
  • One literal at one wiring site (per AC + project-memory § "Policy values live at the wiring site").
  • No new defer paths on /v1/client or /v1/server — the existing defer { Unregister/ScheduleRelease; Close; log } handles the over-cap exit unchanged.
  • The library's close-with-1009 is observed only; no relay-side close code on this path.

🤖 Generated with Claude Code

ilmoniemi and others added 2 commits May 11, 2026 09:41
Thread a per-frame read-size cap through NewWSConn so the policy applies
at the single chokepoint both forwarders reach. Production wires 256 KiB
as a const at the composition root (cmd/pyrycode-relay/main.go), mirroring
the existing 30*time.Second grace literal pattern. Derivation lives in the
spec; the upper bound is the protocol's message_chunk envelope (≤50 text-
only messages plus routing wrapper).

ServerHandler and ClientHandler gain a maxFrameBytes int64 parameter and
forward it into NewWSConn. nhooyr's *websocket.Conn.SetReadLimit is
called inside the constructor before the struct is returned — there is
no window in which Read can fire against an uncapped conn.

ws_conn_test.go: startEcho gains a cap parameter and now echoes received
frames back so client-side Read can exercise the cap.
TestWSConn_Read_FrameExceedingCap_ReturnsError asserts first + subsequent
Read are non-nil after an over-cap frame. TestWSConn_Read_FrameAtCap_
DeliveredIntact asserts an at-cap frame round-trips intact.

docs/knowledge/features/ws-conn-adapter.md: retire the "no read cap"
follow-up bullet; document the 256 KiB policy under Adversarial framing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor Author

@ilmoniemi ilmoniemi left a comment

Choose a reason for hiding this comment

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

Code Review: #29

Decision: PASS

Findings

None.

Summary

Faithful implementation of docs/specs/architecture/29-wsconn-read-limit.md. SetReadLimit is applied inside NewWSConn before the struct is published (ws_conn.go:54) — discharges the AC's "before any Read" requirement at the single chokepoint both forwarders reach. The 256 KiB literal lives as a const in cmd/pyrycode-relay/main.go with the derivation pointer; no package-level constant in internal/relay. All eight call sites of NewWSConn, ServerHandler, and ClientHandler are updated (verified by grep); no defer additions in client_endpoint.go.

Tests are clean: startEcho is parametrised on maxFrameBytes, the over-cap test round-trips through the uncapped server back into the capped client and asserts only the non-nil contract on the first AND a subsequent Read (per AC), and the at-cap test pins the boundary on both server-side delivery and client-side echo. TestWSConn_ConnID_ReturnsConstructorValue migrated off the nil-conn pattern as the spec anticipated.

Security review (label security-sensitive): the architect's ## Security review section is present in the spec with verdict PASS and a 10-point adversarial walk. The diff is consistent with that review — no new log lines that could leak payload bytes, no new error paths, no crypto, no new deps, no // #nosec. SetReadLimit mutates pre-publication state on the constructor goroutine so there is no race. make vet, make build, and go test -race ./internal/relay/... are clean locally; CI test and security are green.

Docs: docs/knowledge/features/ws-conn-adapter.md has the "Out of scope" bullet removed and a new "Per-frame read cap" entry under § Adversarial framing, matching the spec's "make the spec true" instructions.

- knowledge/codebase/29.md: per-ticket implementation summary (cap
  derivation from protocol-mobile.md, constructor-site application,
  test parametrisation, failure semantics).
- features/ws-conn-adapter.md: NewWSConn signature reflects the new
  maxFrameBytes parameter (the Adversarial-framing entry was added
  inline with the implementation commit).
- PROJECT-MEMORY.md: new pattern — input-bounding policy applied at
  the adapter constructor, not per-handler. Distinct from the existing
  "policy values live at the wiring site" pattern (literal placement
  vs. enforcement placement).
- lessons.md: nhooyr's 32 MiB default read limit + the "cap at the
  constructor before any Read can fire" technique.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@ilmoniemi ilmoniemi merged commit 203387d into main May 11, 2026
2 checks passed
@ilmoniemi ilmoniemi deleted the feature/29 branch May 11, 2026 06:58
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.

relay: SetReadLimit on WSConn — bound per-frame size on both forwarders

1 participant