relay: binary-side frame forwarder (#26)#33
Merged
Merged
Conversation
Adds StartBinaryForwarder, the binary→phone read pump that unwraps each routing envelope, finds the phone matching env.ConnID under serverID, and writes env.Frame as opaque bytes. Replaces the CloseRead+Done placeholder in /v1/server. Diverges from StartPhoneForwarder in error policy: malformed envelopes, unknown conn_ids, and phone Send failures all log+drop and continue. The binary serves N phones, so a single bad sink or bad frame must not tear the binary connection down. Tests cover routing, multi-phone fanout, unknown conn_id continuation, malformed envelope continuation, phone-send-error continuation, binary disconnect, and ctx cancel. fakePhone gained capture-Send + Close so it directly satisfies Conn for the new tests; existing #25 tests are unaffected (still use ®istryConn{phone}). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ilmoniemi
commented
May 11, 2026
Contributor
Author
ilmoniemi
left a comment
There was a problem hiding this comment.
Code Review: #26
Decision: PASS
Findings
None. The implementation matches the spec line-for-line and the architect's Security review section in docs/specs/architecture/26-binary-forwarder.md is present and dated 2026-05-10 (required for the security-sensitive label).
Verified:
- Loop body (
internal/relay/forward.go:117-157) mirrorsStartPhoneForwardershape with the three documented divergences: malformed envelope, unknownconn_id, andphone.Senderror allcontinuerather thanreturn. Onlybinary.Readerror returns. The structural rationale ("N sinks, one bad sink does not end the loop") is captured in the doc comment. server_endpoint.go:103-111swapsCloseRead+<-readCtx.Done()for_ = StartBinaryForwarder(...), matching theclient_endpoint.goshape. TheCloseReadcall is gone in full perdocs/lessons.md§ "A long-lived WS handler that does not read frames…". The handler'sdefer { ScheduleReleaseServer; Close; log }anddefer cancelHB()are untouched; LIFO unwind remainscancelHB → release → Close. The forwarder calls none of them.- No log leakage: the four log call sites (
binary_forwarder_read_end,_unmarshal_err,_unknown_conn_id,_phone_send_failed) enumerate fields explicitly —server_id,binary_conn_id,conn_id,err. No envelope bytes, noenv.Frame, no headers, no tokens. - Cross-server addressing is structurally bounded:
reg.PhonesFor(serverID)scopes the lookup to the binary's own slot; a forgedenv.ConnIDcannot reach phones under a different server-id. - Tests cover all seven AC bullets.
fakePhoneextended withmu/sent/sendErr+Send/Closeper Approach (a) of the spec; existing #25 tests untouched (they wrap via®istryConn{phone}and never inspectsent).fakeBinarySourceis a clean mirror offakePhone's Read side. go test -race ./internal/relay/clean.go vet ./...clean.go build ./...clean. Manual stress-race -count=20documented in the test file's doc-comment header per the race-count lesson.- Goroutine lifecycle: no new goroutines spawned by the forwarder; runs synchronously on the HTTP handler goroutine. All four termination paths in the spec (binary close, ctx cancel, heartbeat-driven close, server shutdown) terminate the single forwarder goroutine cleanly.
- Send-error divergence test (
TestStartBinaryForwarder_PhoneSendError_DropsAndContinues) explicitly asserts the structural divergence fromStartPhoneForwarder— p1 errors, p2 still receives.
Summary
Clean, narrowly-scoped implementation. Spec-faithful, no over-reach into the existing defer/heartbeat machinery, no error-handling improvisation, no logging of attacker-controlled payload bytes. The single fakePhone extension keeps test fakes minimal. Ready to merge.
7 tasks
Per-ticket file + new feature doc + INDEX entry + server-endpoint updates for the StartBinaryForwarder swap. PROJECT-MEMORY patterns folded the sink-fan-out error policy and merged the two source interfaces into one bullet now that both forwarders exist. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds
StartBinaryForwarder, the per-binary read pump on/v1/server. Reads frames from the binary WS, callsUnmarshalon each, looks up the phone matchingenv.ConnIDunder the binary'sserverID, and writesenv.Frame(opaque bytes) to that phone. Replaces thec.CloseRead(r.Context())+<-readCtx.Done()placeholder inserver_endpoint.go.Mirror of
StartPhoneForwardershape with one structural divergence: malformed envelopes, unknownconn_ids, and phoneSenderrors all log + drop + continue. The binary serves N phones, so a single bad frame or wedged sink must not tear the binary connection down. The forwarder returns only onbinary.Readerror orctxcancel.Issue
Closes #26.
Testing
Seven new tests in
internal/relay/forward_test.go:TestStartBinaryForwarder_RoutesToAddressedPhone— addressed phone receives the inner frame; siblings don't. Inner bytes byte-stable modulo whitespace viajson.Compact.TestStartBinaryForwarder_MultiplePhones— two phones, one envelope each, exact 1:1 routing.TestStartBinaryForwarder_UnknownConnID_DropsAndContinues— bogusconn_iddropped; subsequent valid envelope still lands; forwarder still running (returnsio.EOFonly when frames chan closes).TestStartBinaryForwarder_MalformedEnvelope_DropsAndContinues—[]byte("not-json")dropped; subsequent valid envelope still lands.TestStartBinaryForwarder_PhoneSendError_DropsAndContinues—p1.sendErrset; envelope to p1 dropped; envelope to p2 lands. Asserts the divergence fromStartPhoneForwarder's return-on-Send-error.TestStartBinaryForwarder_BinaryDisconnect_Returns— closing the source frames chan returnsio.EOF; subsequentScheduleReleaseServer(s1, 0)clears the slot — verifies handler-level defer behaviour structurally.TestStartBinaryForwarder_ContextCancellation_Returns— ctx cancel causes prompt return.fakePhonegainedmu/sent/sendErr+Send/Closeso it directly satisfiesConn. Existing #25 tests are unaffected (they use®istryConn{phone}and never inspectsent).Local:
go test -race ./...clean.go vet ./...clean. Manual stress:go test -race -count=20 -run TestStartBinaryForwarder_ ./internal/relay/clean.Architecture compliance
Read → Unmarshal → PhonesFor lookup → Send, withcontinueon the three drop branches andreturnonReaderror.binarySourceinterface defined at the consumer (per the architect's preferred option), structurally identical tophoneSourcebut distinct named type for call-site clarity.server_endpoint.gocall shape mirrorsclient_endpoint.go:_ = StartBinaryForwarder(r.Context(), reg, serverID, wsconn, logger).CloseReadremoved in full per the lessons.md "WS handler that doesn't read" entry — the new loop is now the sole reader, and a parallelCloseReadgoroutine would race the sole-reader contract on the underlying*websocket.Conn.defer { ScheduleReleaseServer; Close; log }anddefer cancelHBunchanged; LIFO unwind on forwarder return remainscancelHB → release → Close. The forwarder calls none of them.server_id,binary_conn_id,conn_id,err); no envelope/frame bytes or headers in logs.🤖 Generated with Claude Code