153 lines
5.9 KiB
Markdown
153 lines
5.9 KiB
Markdown
---
|
|
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<R: AsyncRead + Unpin> { /* ... */ }
|
|
impl<R: AsyncRead + Unpin> ChunkReader<R> {
|
|
pub fn new(reader: R) -> Self;
|
|
pub fn into_inner(self) -> R;
|
|
pub async fn read_chunk(&mut self) -> Result<Chunk, RawError>;
|
|
}
|
|
|
|
pub struct ChunkWriter<W: AsyncWrite + Unpin> { /* ... */ }
|
|
impl<W: AsyncWrite + Unpin> ChunkWriter<W> {
|
|
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 |