Files
alkcall/src/channels/wire.rs
T
glm-5.3-flash ae372c0c7e test+docs: prepublish hardening from review (failure paths, doc hygiene)
Coverage:
- CF-002: demux skipped-bytes budget teardown test (268 MiB skip in-memory;
  a budgetless demux wedges in the 17th skip, the real one tears down) and
  the skip-hits-EOF arm (truncated oversized payload ends the loop).
- CF-001: retryable CONNECTION_CLOSED pinned for subscribe write failures
  in both stream modes and single-stream publish request-frame failures;
  non-retryable INTERNAL pinned for mid-publish failures in both modes
  (deterministic FailOnFlushN write half).
- Gateway: invoke_sink with with_deadline(None) completes a slow sink.

Docs:
- Fix broken intra-doc link on lib.rs's feature-gated gateway mention
  (rustdoc warned on default-feature builds).
- Re-point 40 src/ references from the old alknet mono-repo ADR numbering
  (049/050/052/065/070/074/092) to this crate's numbering
  (021/011/034/007/008/009/005); drop into_sub_streams references
  removed by ADR-035.

Verification: 565 default / 582 all-features (8 new), clippy -D warnings
on default/gateway/all-features/wasm32, fmt clean, rustdoc warning-free
on default and all-features, publish dry-run clean. Consumer-facing API
continuity 0.1.1 -> 0.2.0 verified by compiling an API-surface probe
against both versions.
2026-08-31 09:56:07 +00:00

338 lines
13 KiB
Rust

//! Channels wire format: the 8-byte chunk header (ADR-034, amended by
//! ADR-035 — no `stream_type` concept).
//!
//! `[channel_id: u32 BE][length: u32 BE][payload bytes]`
//!
//! 8 bytes of header, followed by `length` bytes of opaque payload. The
//! payload is opaque to the channels layer — the handler parses its own
//! framing from the payload. `length = 0` is the EOF sentinel (clean
//! shutdown for a `channel_id`).
//!
//! This is the sync core (ADR-034 §"Sync core / async shell split"):
//! pure byte manipulation, no async, no platform deps, WASM-clean. The
//! async shell (demux/mux — see [`super::adapter`]) wraps this core
//! with `read_exact` / `write_all` on the transport and `mpsc` routing.
//!
//! See `docs/architecture/channels-wire.md` for the full specification.
use std::io;
use thiserror::Error;
/// The chunk header length in bytes.
pub const CHUNK_HEADER_LEN: usize = 8;
/// The maximum chunk payload length (16 MiB, matching TTY's cap —
/// ADR-034). A chunk with `length > MAX_CHUNK_LEN` returns
/// [`ChunkError::TooLarge`] and does not corrupt the stream — the demux
/// drops the chunk and continues. The header is always exactly 8 bytes,
/// so the demux can always resync by reading the next 8-byte header.
pub const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024;
/// A chunk channel ID of 0 is pre-negotiated as `alk/call` (ADR-036).
/// Both sides know `channel_id = 0` is routed to the `CallAdapter`
/// without an explicit open op exchange.
pub const CHANNEL_ID_ZERO: u32 = 0;
/// The chunk header's BAST (Binary Abstract Syntax Tree) document —
/// the machine-readable wire-format spec. The canonical copy is
/// `docs/architecture/chunk-header.bast.json`; this const embeds it so
/// downstream Rust crates can consume it without a file lookup. BAST is
/// plain JSON consumable by any language; the `alktype` crate compiles
/// it into readers/writers/validators, and future codegen derives
/// language-specific implementations from it. The hand-rolled
/// [`parse_header`]/[`write_header`] functions are the hot path; the
/// BAST document is the contract.
pub const CHUNK_HEADER_BAST: &str = include_str!("../../docs/architecture/chunk-header.bast.json");
/// The parsed 8-byte chunk header.
///
/// `length = 0` is the EOF sentinel — the reassembled stream interprets
/// an empty payload as EOF (clean shutdown for a `channel_id`,
/// REQ-CH-01). The sentinel is emitted by the write side's
/// `AsyncWrite::shutdown` and consumed by the read side's
/// `AsyncRead::poll_read` as EOF.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChunkHeader {
pub channel_id: u32,
pub length: u32,
}
impl ChunkHeader {
pub fn new(channel_id: u32, length: u32) -> Self {
Self { channel_id, length }
}
/// `true` if this chunk is the EOF sentinel (`length = 0`).
pub fn is_eof(&self) -> bool {
self.length == 0
}
}
/// Errors raised by the sync wire-format core.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ChunkError {
/// The input buffer was shorter than the 8-byte header. The demux
/// reads exactly 8 bytes before parsing, so this is a programming
/// error in the caller, not a wire condition.
#[error("header buffer too short: need {need} bytes, have {have}")]
HeaderTooShort { need: usize, have: usize },
/// The chunk payload length exceeds `MAX_CHUNK_LEN`. The demux
/// drops the chunk and continues — the header is always exactly 8
/// bytes, so the demux resyncs by reading the next 8-byte header.
#[error("chunk too large: {length} bytes (max {max})")]
TooLarge { length: u32, max: u32 },
}
/// Parse an 8-byte chunk header from `buf`. Pure function — no
/// allocation, no async, WASM-clean.
///
/// Returns [`ChunkError::HeaderTooShort`] if `buf` is shorter than 8
/// bytes. Returns [`ChunkError::TooLarge`] if the parsed `length`
/// exceeds `MAX_CHUNK_LEN` — the demux drops the chunk and continues
/// (the header is always exactly 8 bytes, so the demux resyncs by
/// reading the next 8-byte header).
pub fn parse_header(buf: &[u8]) -> Result<ChunkHeader, ChunkError> {
if buf.len() < CHUNK_HEADER_LEN {
return Err(ChunkError::HeaderTooShort {
need: CHUNK_HEADER_LEN,
have: buf.len(),
});
}
let channel_id = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
let length = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]);
if length > MAX_CHUNK_LEN {
return Err(ChunkError::TooLarge {
length,
max: MAX_CHUNK_LEN,
});
}
Ok(ChunkHeader { channel_id, length })
}
/// Write an 8-byte chunk header into `out`. Pure function — no
/// allocation, no async, WASM-clean.
///
/// Returns [`ChunkError::HeaderTooShort`] if `out` is shorter than 8
/// bytes. The `[channel_id: u32 BE][length: u32 BE]` layout is the wire
/// format (ADR-034, amended by ADR-035 — no `stream_type` byte). All
/// current callers pass an 8-byte buffer, but the contract is typed
/// rather than panic-documented (AGENTS.md §2: no panics in library
/// code).
pub fn write_header(channel_id: u32, length: u32, out: &mut [u8]) -> Result<(), ChunkError> {
if out.len() < CHUNK_HEADER_LEN {
return Err(ChunkError::HeaderTooShort {
need: CHUNK_HEADER_LEN,
have: out.len(),
});
}
let header = &mut out[..CHUNK_HEADER_LEN];
header[0..4].copy_from_slice(&channel_id.to_be_bytes());
header[4..8].copy_from_slice(&length.to_be_bytes());
Ok(())
}
/// Read an 8-byte chunk header from `reader`. Async convenience wrapper
/// around [`parse_header`] — the demux loop's primary read. Returns the
/// parsed header, or an `io::Error` on short read / EOF.
pub async fn read_header<R>(reader: &mut R) -> io::Result<ChunkHeader>
where
R: tokio::io::AsyncRead + Unpin,
{
use tokio::io::AsyncReadExt;
let mut buf = [0u8; CHUNK_HEADER_LEN];
reader.read_exact(&mut buf).await?;
parse_header(&buf).map_err(|e| match e {
ChunkError::HeaderTooShort { .. } => io::Error::other("header too short after read_exact"),
ChunkError::TooLarge { length, max } => io::Error::new(
io::ErrorKind::InvalidData,
format!("chunk too large: {length} bytes (max {max})"),
),
})
}
/// Write an 8-byte chunk header + payload to `writer`. Async convenience
/// wrapper around [`write_header`] — the mux write path's primary write.
/// `payload` may be empty (the EOF sentinel — `length = 0`).
pub async fn write_chunk<W>(writer: &mut W, channel_id: u32, payload: &[u8]) -> io::Result<()>
where
W: tokio::io::AsyncWrite + Unpin,
{
use tokio::io::AsyncWriteExt;
let mut header = [0u8; CHUNK_HEADER_LEN];
write_header(channel_id, payload.len() as u32, &mut header)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?;
writer.write_all(&header).await?;
if !payload.is_empty() {
writer.write_all(payload).await?;
}
Ok(())
}
/// Write the EOF sentinel (a zero-length chunk) for `channel_id`. The
/// reassembled stream's read side interprets this as EOF (REQ-CH-01).
pub async fn write_eof<W>(writer: &mut W, channel_id: u32) -> io::Result<()>
where
W: tokio::io::AsyncWrite + Unpin,
{
write_chunk(writer, channel_id, &[]).await
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::AsyncReadExt;
#[test]
fn parse_header_round_trips_channel_id_and_length() {
let mut buf = [0u8; 8];
write_header(7, 1024, &mut buf).expect("write");
let header = parse_header(&buf).expect("parse");
assert_eq!(header, ChunkHeader::new(7, 1024));
assert!(!header.is_eof());
}
#[test]
fn parse_header_eof_sentinel() {
let mut buf = [0u8; 8];
write_header(3, 0, &mut buf).expect("write");
let header = parse_header(&buf).expect("parse");
assert_eq!(header.length, 0);
assert!(header.is_eof());
}
#[test]
fn parse_header_channel_zero() {
let mut buf = [0u8; 8];
write_header(CHANNEL_ID_ZERO, 512, &mut buf).expect("write");
let header = parse_header(&buf).expect("parse");
assert_eq!(header.channel_id, CHANNEL_ID_ZERO);
}
#[test]
fn parse_header_max_length_accepted() {
let mut buf = [0u8; 8];
write_header(1, MAX_CHUNK_LEN, &mut buf).expect("write");
let header = parse_header(&buf).expect("parse");
assert_eq!(header.length, MAX_CHUNK_LEN);
}
#[test]
fn parse_header_too_large_returns_error() {
let mut buf = [0u8; 8];
write_header(1, MAX_CHUNK_LEN + 1, &mut buf).expect("write");
match parse_header(&buf) {
Err(ChunkError::TooLarge { length, max }) => {
assert_eq!(length, MAX_CHUNK_LEN + 1);
assert_eq!(max, MAX_CHUNK_LEN);
}
other => panic!("expected TooLarge, got {other:?}"),
}
}
#[test]
fn parse_header_short_buffer_returns_error() {
let buf = [0u8; 4];
match parse_header(&buf) {
Err(ChunkError::HeaderTooShort { need, have }) => {
assert_eq!(need, CHUNK_HEADER_LEN);
assert_eq!(have, 4);
}
other => panic!("expected HeaderTooShort, got {other:?}"),
}
}
#[test]
fn write_header_writes_be_bytes() {
let mut buf = [0u8; 8];
write_header(0x01020304, 0x05060708, &mut buf).expect("write");
assert_eq!(buf, [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
}
#[test]
fn write_header_short_buffer_returns_error() {
let mut buf = [0u8; 4];
match write_header(1, 0, &mut buf) {
Err(ChunkError::HeaderTooShort { need, have }) => {
assert_eq!(need, CHUNK_HEADER_LEN);
assert_eq!(have, 4);
}
other => panic!("expected HeaderTooShort, got {other:?}"),
}
}
#[test]
fn chunk_header_bast_is_valid_json_and_describes_the_wire_format() {
let doc: serde_json::Value =
serde_json::from_str(CHUNK_HEADER_BAST).expect("BAST doc is valid JSON");
let def = doc
.get("$defs")
.and_then(|d| d.get("ChunkHeader"))
.expect("ChunkHeader def present");
assert_eq!(def.get("kind").and_then(|v| v.as_str()), Some("struct"));
assert_eq!(def.get("endian").and_then(|v| v.as_str()), Some("big"));
let fields = def
.get("fields")
.and_then(|v| v.as_array())
.expect("fields");
assert_eq!(fields.len(), 2);
assert_eq!(
fields[0].get("name").and_then(|v| v.as_str()),
Some("channel_id")
);
assert_eq!(
fields[0].get("kind").and_then(|v| v.as_str()),
Some("uint32")
);
assert_eq!(
fields[1].get("name").and_then(|v| v.as_str()),
Some("length")
);
assert_eq!(
fields[1].get("kind").and_then(|v| v.as_str()),
Some("uint32")
);
}
#[tokio::test]
async fn read_header_round_trips_through_duplex() {
let (mut reader, mut writer) = tokio::io::duplex(64);
write_chunk(&mut writer, 42, b"hello").await.expect("write");
let header = read_header(&mut reader).await.expect("read header");
assert_eq!(header.channel_id, 42);
assert_eq!(header.length, 5);
let mut payload = [0u8; 5];
reader.read_exact(&mut payload).await.expect("read payload");
assert_eq!(&payload, b"hello");
}
#[tokio::test]
async fn write_eof_writes_zero_length_chunk() {
let (mut reader, mut writer) = tokio::io::duplex(64);
write_eof(&mut writer, 7).await.expect("write eof");
let header = read_header(&mut reader).await.expect("read header");
assert_eq!(header.channel_id, 7);
assert_eq!(header.length, 0);
assert!(header.is_eof());
}
#[tokio::test]
async fn write_chunk_empty_payload_writes_eof_sentinel() {
let (mut reader, mut writer) = tokio::io::duplex(64);
write_chunk(&mut writer, 9, &[]).await.expect("write");
let header = read_header(&mut reader).await.expect("read header");
assert_eq!(header.length, 0);
assert!(header.is_eof());
}
#[tokio::test]
async fn read_header_on_closed_stream_returns_unexpected_eof() {
let (mut reader, writer) = tokio::io::duplex(64);
drop(writer);
let mut buf = [0u8; CHUNK_HEADER_LEN];
let result = reader.read_exact(&mut buf).await;
assert!(result.is_err(), "read on closed stream should error");
}
}