Skip to content

feat(relay): WSConn adapter for nhooyr.io/websocket (#15)#17

Merged
ilmoniemi merged 3 commits into
mainfrom
feature/15
May 8, 2026
Merged

feat(relay): WSConn adapter for nhooyr.io/websocket (#15)#17
ilmoniemi merged 3 commits into
mainfrom
feature/15

Conversation

@ilmoniemi
Copy link
Copy Markdown
Contributor

What

Adds internal/relay/ws_conn.goWSConn, a thin adapter that wraps *websocket.Conn from nhooyr.io/websocket to satisfy the registry's Conn interface (ConnID, Send, Close).

  • Send takes a per-connection writeMu and writes a single binary frame under context.WithTimeout(closeCtx, 10s). The mutex serialises concurrent callers (the underlying library forbids concurrent Write); the timeout bounds the slow-peer write-side DoS vector.
  • Close is sync.Once-guarded. It cancels closeCtx (aborting any in-flight Write) and calls conn.Close(StatusNormalClosure, ""). It deliberately does not take writeMu — that would deadlock against a slow peer; instead, cancelling closeCtx causes the in-flight Write to return and release the mutex on its own.
  • ConnID is a pure non-blocking getter, as required by the registry calling it under its write lock in UnregisterPhone.

Adds nhooyr.io/websocket v1.8.17 as a direct dependency. No edits to registry.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.Conn via httptest.NewServer and a small accept-and-read echo handler. No library mocks.

  • TestWSConn_ConnID_ReturnsConstructorValue — locks the constructor → getter contract.
  • TestWSConn_ConcurrentSend_ProducesIntactFrames — 16 goroutines each Send a tagged payload; receiver gets 16 distinct intact frames. -race is the primary signal for write-mutex correctness.
  • TestWSConn_DoubleClose_DoesNotPanic — idempotency.
  • TestWSConn_SendAfterClose_ReturnsErrorSend after Close returns non-nil; no assertion on the specific error type, per the spec.

go test -race ./... ✅, go vet ./... ✅, go build ./cmd/pyrycode-relay ✅. gosec / govulncheck not installed locally; CI is the source of truth for those gates.

Architecture compliance

  • Interface match (registry.go:31-46): ConnID non-blocking; Send serialised per-connection; Close idempotent and safe concurrently with Send.
  • Context strategy per spec: adapter-owned closeCtx cancelled by Close, plus a per-call WithTimeout(closeCtx, writeTimeout). Avoids context.Background() (slow-loris) and avoids changing the registry's Conn.Send signature.
  • writeTimeout = 10s named constant, doc-commented as the slow-peer mitigation. Single source of truth if the value needs to move later.
  • Lock graph is a single node (writeMu) plus a one-shot guard (closeOnce); no goroutines spawned, no channels, no callbacks.
  • Doc comments on every exported symbol name the concurrency contract — what is locked, what is cancelled, what is safe-with-what.
  • Caller invariants the adapter cannot enforce (one *websocket.Conn → one WSConn; opaque connID) are documented on NewWSConn, not coded around.
  • Out-of-scope items left untouched: header validation, close-code semantics beyond 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

ilmoniemi and others added 2 commits May 8, 2026 21:26
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>
@ilmoniemi
Copy link
Copy Markdown
Contributor Author

Code Review: #15

Decision: PASS

Findings

  • [NIT] internal/relay/ws_conn.go:24 — consider a compile-time interface assertion (var _ Conn = (*WSConn)(nil)) so any future drift in the registry's Conn contract surfaces at build time rather than at the first caller site in relay: WS upgrade for /v1/server — accept binary connection, validate headers, claim server-id #4/relay: WS upgrade for /v1/server — accept binary connection, validate headers, claim server-id #16. The package already exposes Conn and the matching fakeConn in registry_test.go; one line here keeps the adapter honest.
  • [NIT] internal/relay/ws_conn_test.go:57 — TestWSConn_ConnID_ReturnsConstructorValue builds a WSConn with a nil *websocket.Conn and never calls Close, so the captured cancel is never invoked. Harmless (context.WithCancel(Background()) doesn't spawn a goroutine), but a defer wc.Close() — gated on the path that doesn't exercise w.conn — would be tidier and would track the lifecycle the rest of the file follows.
  • [NIT] internal/relay/ws_conn_test.go:30 — the echo handler is fine for these tests, but if a future contributor extends the suite, t.Errorf from a server-goroutine after srv.Close() would race with test teardown. Not an issue today; flagging only because the pattern is easy to copy.

Summary

Implementation tracks the spec line-for-line: single writeMu, sync.Once-guarded Close, closeCtx cancelled by Close, per-call WithTimeout, Send returns library errors verbatim, Close discards conn.Close's error, no defensive nil checks. Doc comments encode the concurrency contract (locked / cancelled / safe-with) on every exported symbol — exactly what the spec's "done means" called for. go.mod pins nhooyr.io/websocket v1.8.17 (within the v1.8.x line, vanity import path preserved). The four AC tests are real end-to-end against httptest.NewServer with no library mocks; payloads are distinguishable (g00..g15) and the assertion is N distinct frames, which is the correctness signal under -race. CI: test and security both green; no // #nosec annotations introduced. The spec's Security review section is present and reaches PASS, the writeTimeout = 10s slow-loris mitigation it called out is implemented, and no security finding is left unaddressed in the diff.

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.
@ilmoniemi ilmoniemi merged commit 733c78e into main May 8, 2026
2 checks passed
@ilmoniemi ilmoniemi deleted the feature/15 branch May 8, 2026 18:35
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: WSConn adapter — nhooyr.io/websocket.Conn implementing the registry Conn interface

1 participant