You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
As the relay, I want a per-binary forwarding goroutine that reads frames from a binary WS, unwraps each routing envelope, looks up the phone matching the embedded conn_id, and writes the inner frame to that phone, so that binary-to-phone traffic flows after /v1/server upgrades complete.
Context
The binary-side data path. After /v1/server claims the server-id (#16), this ticket replaces the current c.CloseRead(r.Context()) + <-readCtx.Done() placeholder in internal/relay/server_endpoint.go with an actual frame loop. Frames are forwarded as opaque bytes — the relay MUST NOT parse the inner protocol envelope. See pyrycode/pyrycode/docs/protocol-mobile.md § Routing envelope.
This is the binary-side mirror of the phone-side forwarder shipped in #25. internal/relay/forward.go already exists; this ticket extends it with StartBinaryForwarder alongside the existing StartPhoneForwarder. The architect should mirror #25's established shape (synchronous despite the Start verb, returns error for observability, consumer-defined source interface so tests can substitute a fake) except where this ticket's AC explicitly diverges — see the Send-error AC below.
Acceptance Criteria
In internal/relay/forward.go, export StartBinaryForwarder (signature shape per architect; conceptually (ctx, registry, serverID, binaryConn, logger) -> error, mirroring StartPhoneForwarder).
Returns when the source conn returns a Read error OR ctx is cancelled.
Unknown conn_id: log a warning, drop the frame, continue the loop — phones come and go, this is expected. Do NOT close the binary.
Malformed envelope (Unmarshal returns a sentinel via errors.Is): log a warning, drop the frame, continue the loop.
Phone Send error: log + drop the frame, continue the loop. The owning phone handler will see its own read fail and clean up. This diverges from StartPhoneForwarder's return-on-Send-error behaviour: the binary is still serving other phones, so a single bad sink does not end the loop.
/v1/server handler in internal/relay/server_endpoint.go calls StartBinaryForwarder after ClaimServer returns nil and the heartbeat goroutine has been launched, replacing the current c.CloseRead(r.Context()) + <-readCtx.Done() placeholder. The CloseRead call MUST be removed — it drains-and-discards data frames, which would race the forwarder reader. Mirror the wiring shape used in client_endpoint.go for StartPhoneForwarder (_ = StartBinaryForwarder(r.Context(), reg, serverID, wsconn, logger)). The HTTP handler returns when the forwarder returns; the existing defer { ScheduleReleaseServer; Close; log } (relay: WS upgrade for /v1/server — accept binary connection, validate headers, claim server-id #16/relay: /v1/server — defer binary release by 30s grace window #21) and defer cancelHB (relay: WebSocket heartbeat — RFC 6455 ping/pong every 30s, close at 60s #7) run after under LIFO order. The forwarder MUST NOT call any of those itself.
Binary sends an envelope addressed to a known conn_id: that phone receives the inner frame; other registered phones do not. Inner frame is byte-stable round-trip — canonicalise via json.Compact per the whitespace lesson.
Multi-phone: 2 phones registered; binary sends one envelope per phone; each receives only its own.
Unknown conn_id: log warning, frame dropped, no panic, binary forwarder continues, other phones unaffected. Assert continuation by sending a valid envelope after the bad one and observing it land.
Binary disconnects (mock Read returns an error): forwarder returns; the handler-level defer runs ScheduleReleaseServer as expected.
Context cancellation: forwarder returns promptly.
make test clean with -race. Document go test -race -count=20 ./internal/relay/ in the test file's doc comment, per the race-count lesson.
Technical Notes
Inner frames are opaque. Test envelopes should carry inner frames containing nested JSON; assert byte-stable forwarding via json.Compact on both sides.
A single bad frame from the binary MUST NOT tear the binary connection down. Phones come and go; an envelope addressing a just-disconnected phone is a normal race, not a binary fault. Log and drop, never close — this is structural.
S — one new forwarder in an existing file + one handler call-site swap (remove CloseRead+<-Done, add StartBinaryForwarder call) + 5 test scenarios. ~60 LOC production + ~140 LOC tests.
User Story
As the relay, I want a per-binary forwarding goroutine that reads frames from a binary WS, unwraps each routing envelope, looks up the phone matching the embedded
conn_id, and writes the inner frame to that phone, so that binary-to-phone traffic flows after/v1/serverupgrades complete.Context
The binary-side data path. After
/v1/serverclaims the server-id (#16), this ticket replaces the currentc.CloseRead(r.Context())+<-readCtx.Done()placeholder ininternal/relay/server_endpoint.gowith an actual frame loop. Frames are forwarded as opaque bytes — the relay MUST NOT parse the inner protocol envelope. Seepyrycode/pyrycode/docs/protocol-mobile.md§ Routing envelope.This is the binary-side mirror of the phone-side forwarder shipped in #25.
internal/relay/forward.goalready exists; this ticket extends it withStartBinaryForwarderalongside the existingStartPhoneForwarder. The architect should mirror #25's established shape (synchronous despite theStartverb, returnserrorfor observability, consumer-defined source interface so tests can substitute a fake) except where this ticket's AC explicitly diverges — see the Send-error AC below.Acceptance Criteria
internal/relay/forward.go, exportStartBinaryForwarder(signature shape per architect; conceptually(ctx, registry, serverID, binaryConn, logger) -> error, mirroringStartPhoneForwarder).UnmarshalRoutingEnvelope(from relay: routing-envelope wrapper type — marshal, unmarshal, tests #1); resolve the addressed phone viaRegistry.PhonesFor(serverID)matchingenv.ConnID; callphone.Send(env.Frame)with the inner bytes.ctxis cancelled.conn_id: log a warning, drop the frame, continue the loop — phones come and go, this is expected. Do NOT close the binary.Unmarshalreturns a sentinel viaerrors.Is): log a warning, drop the frame, continue the loop.Senderror: log + drop the frame, continue the loop. The owning phone handler will see its own read fail and clean up. This diverges fromStartPhoneForwarder's return-on-Send-error behaviour: the binary is still serving other phones, so a single bad sink does not end the loop./v1/serverhandler ininternal/relay/server_endpoint.gocallsStartBinaryForwarderafterClaimServerreturns nil and the heartbeat goroutine has been launched, replacing the currentc.CloseRead(r.Context())+<-readCtx.Done()placeholder. TheCloseReadcall MUST be removed — it drains-and-discards data frames, which would race the forwarder reader. Mirror the wiring shape used inclient_endpoint.goforStartPhoneForwarder(_ = StartBinaryForwarder(r.Context(), reg, serverID, wsconn, logger)). The HTTP handler returns when the forwarder returns; the existingdefer { ScheduleReleaseServer; Close; log }(relay: WS upgrade for /v1/server — accept binary connection, validate headers, claim server-id #16/relay: /v1/server — defer binary release by 30s grace window #21) anddefer cancelHB(relay: WebSocket heartbeat — RFC 6455 ping/pong every 30s, close at 60s #7) run after under LIFO order. The forwarder MUST NOT call any of those itself.internal/relay/forward_test.go(extending the existing file from relay: phone-side frame forwarder — wrap inner frames in routing envelope and send to binary #25; mock binary+phone conns wired to a realRegistry):conn_id: that phone receives the inner frame; other registered phones do not. Inner frame is byte-stable round-trip — canonicalise viajson.Compactper the whitespace lesson.conn_id: log warning, frame dropped, no panic, binary forwarder continues, other phones unaffected. Assert continuation by sending a valid envelope after the bad one and observing it land.deferrunsScheduleReleaseServeras expected.make testclean with-race. Documentgo test -race -count=20 ./internal/relay/in the test file's doc comment, per the race-count lesson.Technical Notes
json.Compacton both sides.UnmarshalRoutingEnvelope(relay: routing-envelope wrapper type — marshal, unmarshal, tests #1) branch viaerrors.Is; the established pattern is in place.defer { ScheduleReleaseServer; Close; log }(relay: WS upgrade for /v1/server — accept binary connection, validate headers, claim server-id #16/relay: /v1/server — defer binary release by 30s grace window #21) and heartbeat (relay: WebSocket heartbeat — RFC 6455 ping/pong every 30s, close at 60s #7) handle cleanup and liveness; the forwarder is purely a read-pump.Size Estimate
S — one new forwarder in an existing file + one handler call-site swap (remove
CloseRead+<-Done, addStartBinaryForwardercall) + 5 test scenarios. ~60 LOC production + ~140 LOC tests.Split from #6.