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
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 |
|
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.rspump_sessiondrainer pattern +TestStdinSinkbackpressure - alkcall:
channels::wire::write_chunk,channels::mux::MuxRunner, REQ-CH-01/02 inchannels-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 → boundedmpsc(64 slots) →AsyncReaddrains (EOF = channel close); outbound =AsyncWritequeues bytes → writer task parses 8-byte chunk headers from the pending buffer → one WS message per chunk, split atWS_MESSAGE_CAP= 1 MiB.- Full server path: axum upgrade → adapter →
Connection::from_bidi(_, b"alk/channels")→ChannelsAdapter(NoCap policy) →install_channel_zerohook →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_frameissues two writes through the mux. The writer task parsing chunk headers out of the pending byte buffer is required and sufficient.write_tx.poll_readydoesn't exist on tokio mpsc — usedtry_send+ wake-on-Full (fine at 64-deep; production may prefer futures::channel::mpsc as alkcall'sMpscSendStreamdoes). - (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.
shutdownmaps to closing the write channel (mux pumps emit the zero-length sentinels themselves on sender drop). - Dispatch detail discovered:
dispatch()readsoperationId(notoperation) 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::anonymousmust become the bearer-resolved identity, attached to theCallConnection. WsPumpsguard (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.