feat(relay): WSConn adapter for nhooyr.io/websocket (#15)#17
Conversation
Wraps *websocket.Conn from nhooyr.io/websocket to satisfy the registry's Conn interface. Per-connection writeMu serialises Send so concurrent broadcast callers do not interleave frames; an adapter-owned context cancelled by Close aborts in-flight writes; a 10s per-call write timeout bounds the slow-peer DoS vector. Closes #15 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Code Review: #15Decision: PASS Findings
SummaryImplementation tracks the spec line-for-line: single Ship it. |
…15) - features/ws-conn-adapter.md — API, concurrency model, context strategy, what the adapter deliberately does not do, adversarial framing, tests. - decisions/0004-ws-library-and-adapter-context-strategy.md — library choice (nhooyr.io/websocket v1.8.x), three context-strategy options considered, rationale for the adopted one. - INDEX.md — add WSConn feature and ADR-0004. - PROJECT-MEMORY.md — record #15 in What's Built; add deps; new pattern for adapters bridging interface↔library API mismatches. - lessons.md — Close-via-context-cancellation rather than write-mutex; nhooyr.io/websocket vanity-path note.
What
Adds
internal/relay/ws_conn.go—WSConn, a thin adapter that wraps*websocket.Connfromnhooyr.io/websocketto satisfy the registry'sConninterface (ConnID,Send,Close).Sendtakes a per-connectionwriteMuand writes a single binary frame undercontext.WithTimeout(closeCtx, 10s). The mutex serialises concurrent callers (the underlying library forbids concurrentWrite); the timeout bounds the slow-peer write-side DoS vector.Closeissync.Once-guarded. It cancelscloseCtx(aborting any in-flightWrite) and callsconn.Close(StatusNormalClosure, ""). It deliberately does not takewriteMu— that would deadlock against a slow peer; instead, cancellingcloseCtxcauses the in-flightWriteto return and release the mutex on its own.ConnIDis a pure non-blocking getter, as required by the registry calling it under its write lock inUnregisterPhone.Adds
nhooyr.io/websocketv1.8.17 as a direct dependency. No edits toregistry.go,envelope.go, or any handler — wiring is the upgrade-handler tickets' job (#4/#16, #5).Issue
Closes #15.
Testing
internal/relay/ws_conn_test.go— end-to-end against a real*websocket.Connviahttptest.NewServerand a small accept-and-read echo handler. No library mocks.TestWSConn_ConnID_ReturnsConstructorValue— locks the constructor → getter contract.TestWSConn_ConcurrentSend_ProducesIntactFrames— 16 goroutines eachSenda tagged payload; receiver gets 16 distinct intact frames.-raceis the primary signal for write-mutex correctness.TestWSConn_DoubleClose_DoesNotPanic— idempotency.TestWSConn_SendAfterClose_ReturnsError—SendafterClosereturns non-nil; no assertion on the specific error type, per the spec.go test -race ./...✅,go vet ./...✅,go build ./cmd/pyrycode-relay✅.gosec/govulnchecknot installed locally; CI is the source of truth for those gates.Architecture compliance
registry.go:31-46):ConnIDnon-blocking;Sendserialised per-connection;Closeidempotent and safe concurrently withSend.closeCtxcancelled byClose, plus a per-callWithTimeout(closeCtx, writeTimeout). Avoidscontext.Background()(slow-loris) and avoids changing the registry'sConn.Sendsignature.writeTimeout = 10snamed constant, doc-commented as the slow-peer mitigation. Single source of truth if the value needs to move later.writeMu) plus a one-shot guard (closeOnce); no goroutines spawned, no channels, no callbacks.*websocket.Conn→ oneWSConn; opaqueconnID) are documented onNewWSConn, not coded around.StatusNormalClosure, heartbeat (relay: WebSocket heartbeat — RFC 6455 ping/pong every 30s, close at 60s #7), read loop / envelope (relay: frame forwarding loop — wrap/unwrap routing envelope, route by server-id and conn_id #6), conn-id generation, throttling.🤖 Generated with Claude Code