Files
alkhttp/tasks/websocket/byte-adapter.md
T
glm-5.3-flash a85500d3d9 docs: ws-byte-adapter POC complete (GO) — resolve OQ-01, update framing facts
POC at /workspace/ws-byte-adapter-poc/ (not on main), all tests green:
- call round-trip over axum WS ↔ adapter ↔ ChannelsAdapter ↔ channel-0
  Dispatcher (published alkcall API only, no forks)
- 3 MiB payload splits across 1 MiB WS messages, byte-intact reassembly
- 20 interleaved calls reassemble without corruption/cross-correlation

Findings merged:
- OQ-01 resolved: byte-stream both directions, bounded mpsc (64 slots)
  inbound, outbound chunk-header parsing (frame-as-two-chunks
  live-confirmed), 1 MiB message cap with split, flush no-op, close →
  EOF → REQ-CH-02
- websocket.md: chunk ≠ frame on channel 0 (frame reassembly required);
  operationId is the request payload key
- task ws-byte-adapter → completed with full Summary; ws-upgrade-session
  unblocks
2026-08-28 07:14:32 +00:00

5.0 KiB

id, name, status, depends_on, scope, risk, impact, level, tags
id name status depends_on scope risk impact level tags
ws-byte-adapter WS ↔ byte-stream adapter (research POC → production shape) completed
moderate high phase research
websocket
phase-2
poc

Description

Targeted POC validating the WS↔byte-stream adapter contract (OQ-01) before the production implementation: wrap axum's WebSocket as AsyncRead + AsyncWrite such that alkcall's ChannelsAdapter demux and mux run over it correctly.

Inbound: axum WS messages → shared bounded buffer → AsyncRead drains (backpressure via the alknet-tty precedent: try_send → Full → Pending). Outbound: AsyncWrite accumulates into a pending buffer; a task parses outgoing 8-byte chunk headers (verified: header+payload are separate writes; channel 0's write_frame is prefix+body separately) and emits each complete chunk as one WS binary message; chunks over the WS message cap (~1 MiB default) split across messages. Close: WS close → transport EOF (REQ-CH-02); AsyncWrite::shutdown → zero-length sentinel then Close frame (REQ-CH-01).

Acceptance Criteria

  • POC: adapter + ChannelsAdapter + channel-0 Dispatcher over an in-process axum WS server ↔ tungstenite client — call round-trip works
  • POC test: 3 MiB payload splits across WS messages and reassembles (byte-integrity asserted)
  • POC test: 20 back-to-back interleaved calls reassemble without corruption or cross-correlation
  • OQ-01 sub-items (a)-(d) resolved with concrete values, written back into open-questions.md
  • Findings + go/pivot recommendation recorded in this task's Summary

References

  • docs/architecture/websocket.md (§The WS ↔ byte-stream adapter)
  • docs/plans/implementation.md (§What the spike established, point 6)
  • alknet-tty precedent: src/adapter.rs pump_session drainer pattern + TestStdinSink backpressure
  • alkcall: channels::wire::write_chunk, channels::mux::MuxRunner, REQ-CH-01/02 in channels-wire.md

Notes

POC process: the code lives in /workspace/ws-byte-adapter-poc/ (needs no repo code — alkcall and axum come from crates.io); findings merge into this task file and open-questions.md. POC code does not land on main. The production adapter task (ws-upgrade-session) grows from the validated shape.

Summary

Recommendation: GO. The adapter contract is validated end-to-end; the production implementation can proceed with the POC's shape.

What was built (/workspace/ws-byte-adapter-poc/, not on main):

  • split_ws_to_bytes(socket) -> (WsByteStream, WsPumps): inbound = WS read task → bounded mpsc (64 slots) → AsyncRead drains (EOF = channel close); outbound = AsyncWrite queues bytes → writer task parses 8-byte chunk headers from the pending buffer → one WS message per chunk, split at WS_MESSAGE_CAP = 1 MiB.
  • Full server path: axum upgrade → adapter → Connection::from_bidi(_, b"alk/channels")ChannelsAdapter (NoCap policy) → install_channel_zero hook → Dispatcher::run_loop_single_stream — all alkcall-published API, zero forks.
  • Tests: call round-trip; 3 MiB payload split/reassembly with byte integrity; 20 interleaved calls, no corruption/cross-correlation.

Resolved OQ-01 sub-items:

  • (a) Inbound buffer: bounded mpsc, 64 slots ≈ 64 MiB worst case; backpressure is the channel send awaiting capacity (simpler and strictly bounded vs the TTY try_send-poll pattern — the WS read task blocks, applying TCP-level backpressure to the socket).
  • (b) Outbound boundary parsing: confirmed live — a response frame arrived as TWO chunks (length-prefix chunk + body chunk) because write_frame issues two writes through the mux. The writer task parsing chunk headers out of the pending byte buffer is required and sufficient. write_tx.poll_ready doesn't exist on tokio mpsc — used try_send + wake-on-Full (fine at 64-deep; production may prefer futures::channel::mpsc as alkcall's MpscSendStream does).
  • (c) Flush: poll_flush = Ok (writes queue; the writer task emits independently). No correctness issue observed.
  • (d) Close mapping: WS close → read task ends → mpsc closes → demux EOF → REQ-CH-02 teardown verified by session ending cleanly. shutdown maps to closing the write channel (mux pumps emit the zero-length sentinels themselves on sender drop).
  • Dispatch detail discovered: dispatch() reads operationId (not operation) from the request payload — client framing must match. Also: chunk != frame on channel 0 — clients MUST reassemble length-prefixed frames from the channel-0 byte stream, never parse per-chunk. Both belong in websocket.md's framing section.

Flags for the production task (ws-upgrade-session):

  • The POC's AuthContext::anonymous must become the bearer-resolved identity, attached to the CallConnection.
  • WsPumps guard (abort on session teardown) needs integrating with the session lifecycle.
  • Consider futures::channel::mpsc for the write path if wake-on-Full shows up in profiles; not a correctness issue.