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
101 lines
5.0 KiB
Markdown
101 lines
5.0 KiB
Markdown
---
|
|
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. |