--- id: ws-byte-adapter name: WS ↔ byte-stream adapter (research POC → production shape) status: completed depends_on: [] scope: moderate risk: high impact: phase level: research tags: [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 - [x] POC: adapter + ChannelsAdapter + channel-0 Dispatcher over an in-process axum WS server ↔ tungstenite client — call round-trip works - [x] POC test: 3 MiB payload splits across WS messages and reassembles (byte-integrity asserted) - [x] POC test: 20 back-to-back interleaved calls reassemble without corruption or cross-correlation - [x] OQ-01 sub-items (a)-(d) resolved with concrete values, written back into open-questions.md - [x] 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.