--- id: tty/wire-codec name: Implement raw chunk codec (ChunkReader/ChunkWriter, RawError, stream types) status: completed depends_on: [tty/crate-init] scope: narrow risk: low impact: component level: implementation --- ## Description Implement the raw chunk codec in `src/wire.rs`. This is the Phase 2 "raw carriage" format (ADR-052): after the single JSON negotiation frame, the bidi stream switches to a chunk format for the life of the session. The codec is a direct port of the POC's `/workspace/alknet-tty-poc/src/raw.rs`, generalized into the crate's `wire` module. The POC code is the reference; this task ports it verbatim with crate-appropriate doc comments and the `RawError` type exposed publicly. ### Wire format ```text [stream_type: u8][length: u32 be][payload bytes] ``` - `stream_type` (1 byte) — the channel: | stream_type | channel | direction | payload | |---|---|---|---| | 0 | data-in (stdin) | client→server | raw bytes | | 1 | data-out (stdout) | server→client | raw bytes | | 2 | data-err (stderr) | server→client | raw bytes | | 3 | control | bidirectional | JSON control message | `stream_type > 3` is a protocol error (`InvalidStreamType`). There is no extension escape hatch in the byte — a 5th channel is a wire-format change requiring a new ALPN (`alknet/tty/v2` per ADR-006). - `length` (4 bytes, big-endian) — payload length in bytes. Max 16 MiB (`MAX_CHUNK_LEN = 16 * 1024 * 1024`). A chunk larger than 16 MiB is a protocol error (`ChunkTooLarge`). - `payload` (`length` bytes) — raw bytes (data channels) or UTF-8 JSON (control). ### Types to implement ```rust pub const STREAM_STDIN: u8 = 0; pub const STREAM_STDOUT: u8 = 1; pub const STREAM_STDERR: u8 = 2; pub const STREAM_CONTROL: u8 = 3; pub const CHUNK_HEADER_LEN: usize = 5; // 1 byte type + 4 bytes length pub const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024; #[derive(Debug, thiserror::Error)] pub enum RawError { #[error("io: {0}")] Io(#[from] std::io::Error), #[error("connection closed")] ConnectionClosed, #[error("invalid chunk header: stream type {0}")] InvalidStreamType(u8), #[error("chunk too large: {0}")] ChunkTooLarge(u32), } #[derive(Debug, Clone)] pub struct Chunk { pub stream_type: u8, pub bytes: bytes::Bytes, } impl Chunk { pub fn stdin(bytes: bytes::Bytes) -> Self; pub fn stdout(bytes: bytes::Bytes) -> Self; pub fn stderr(bytes: bytes::Bytes) -> Self; pub fn control(bytes: bytes::Bytes) -> Self; } pub struct ChunkReader { /* ... */ } impl ChunkReader { pub fn new(reader: R) -> Self; pub fn into_inner(self) -> R; pub async fn read_chunk(&mut self) -> Result; } pub struct ChunkWriter { /* ... */ } impl ChunkWriter { pub fn new(writer: W) -> Self; pub fn into_inner(self) -> W; pub async fn write_chunk(&mut self, chunk: &Chunk) -> Result<(), RawError>; pub async fn write_stdin(&mut self, bytes: &[u8]) -> Result<(), RawError>; pub async fn write_control_json(&mut self, json: &[u8]) -> Result<(), RawError>; } ``` ### Implementation notes - `read_chunk` reads the 5-byte header, validates `stream_type <= 3` (else `InvalidStreamType`), validates `length <= MAX_CHUNK_LEN` (else `ChunkTooLarge`), reads `length` bytes. On `UnexpectedEof` reading the header or payload, return `ConnectionClosed` (not `Io`) — the stream ended cleanly. - `write_chunk` writes the 5-byte header then the payload (if non-empty), then flushes. `write_stdin` and `write_control_json` are convenience helpers. - Zero-length chunks are sentinels (see tty-wire.md §"Sentinels"): a zero-length stdin chunk is EOF from the client; a zero-length stdout chunk is "drained" from the server. The codec does not special-case these — they are just chunks with `length == 0`; the adapter interprets them. ### Tests Port the POC's round-trip behavior: write a chunk, read it back, assert equality. Test all four stream types. Test `InvalidStreamType` (stream_type 4) and `ChunkTooLarge` (length > MAX_CHUNK_LEN). Test `ConnectionClosed` on truncated header/payload. Use `tokio::io::duplex` as the transport stand-in. ## Acceptance Criteria - [ ] `STREAM_STDIN`/`STREAM_STDOUT`/`STREAM_STDERR`/`STREAM_CONTROL` constants defined (0-3) - [ ] `CHUNK_HEADER_LEN` (5) and `MAX_CHUNK_LEN` (16 MiB) constants defined - [ ] `RawError` enum with `Io`, `ConnectionClosed`, `InvalidStreamType`, `ChunkTooLarge` - [ ] `Chunk` struct with `stream_type`/`bytes` fields and `stdin`/`stdout`/`stderr`/`control` constructors - [ ] `ChunkReader::read_chunk` validates stream_type and length, returns `ConnectionClosed` on clean EOF - [ ] `ChunkWriter::write_chunk`/`write_stdin`/`write_control_json` write header + payload + flush - [ ] Round-trip unit tests for all four stream types - [ ] Unit test: `InvalidStreamType` on stream_type > 3 - [ ] Unit test: `ChunkTooLarge` on length > MAX_CHUNK_LEN - [ ] Unit test: `ConnectionClosed` on truncated header and truncated payload - [ ] `cargo test -p alknet-tty` succeeds - [ ] `cargo clippy -p alknet-tty` succeeds with no warnings ## References - docs/architecture/crates/tty/tty-wire.md — wire format spec (§"Phase 2: Raw Chunk Format", §"Sentinels") - docs/architecture/decisions/052-alknet-tty-wire-format-and-two-carriage.md — ADR-052 - /workspace/alknet-tty-poc/src/raw.rs — the reference implementation to port ## Notes > This is a near-verbatim port of the POC's `raw.rs`. The codec is > transport-agnostic (works over any `AsyncRead`/`AsyncWrite`); the adapter > task wires it to QUIC bidi streams. The `MAX_CHUNK_LEN` constant is shared > with the negotiation module (the framing-disambiguation trick depends on > error frames being under 16 MiB so the high byte of the length prefix is > `0x00` — see tty-adapter.md §"Negotiation errors"). ## Summary > To be filled on completion