swactor/crates/streams/src/wire.rs
Zachery Aaron Shores-Chmielewski 315f0ff8ee feat: data streams primitive (#48)
Allows streaming blobs without interference from the actor runtime.
Co-authored-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
Co-committed-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
2026-02-23 04:47:54 +00:00

306 lines
9.2 KiB
Rust

use crate::types::{StreamConfig, StreamError, StreamId, StreamMode};
/// Magic bytes identifying the swactor stream protocol.
pub const MAGIC: [u8; 2] = [0x53, 0x57];
/// Wire protocol version.
pub const VERSION: u8 = 0x01;
/// ALPN protocol identifier for QUIC negotiation.
pub const ALPN: &[u8] = b"swactor/stream/1";
/// Header sent at the beginning of a stream connection.
///
/// Wire layout:
/// ```text
/// [2B magic] [1B version] [16B stream_id] [1B mode] [1B stripe_count]
/// [4B frame_size] [4B metadata_len] [metadata_len B metadata]
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StreamHeader {
pub stream_id: StreamId,
pub mode: StreamMode,
pub config: StreamConfig,
}
/// Fixed portion of the header (before variable-length metadata).
const HEADER_FIXED_SIZE: usize = 2 + 1 + 16 + 1 + 1 + 4 + 4; // 29 bytes
/// Encode a stream header into bytes.
pub fn encode_header(header: &StreamHeader) -> Vec<u8> {
let meta_len = header.config.metadata.len() as u32;
let total = HEADER_FIXED_SIZE + header.config.metadata.len();
let mut buf = Vec::with_capacity(total);
// Magic + version
buf.extend_from_slice(&MAGIC);
buf.push(VERSION);
// Stream ID
buf.extend_from_slice(&header.stream_id.0);
// Mode
let mode_byte = match header.mode {
StreamMode::BlobTransfer => 0x01,
};
buf.push(mode_byte);
// Config: stripe_count, frame_size, metadata
buf.push(header.config.stripe_count);
buf.extend_from_slice(&header.config.frame_size.to_be_bytes());
buf.extend_from_slice(&meta_len.to_be_bytes());
buf.extend_from_slice(&header.config.metadata);
buf
}
/// Decode a stream header from bytes.
pub fn decode_header(data: &[u8]) -> Result<StreamHeader, StreamError> {
if data.len() < HEADER_FIXED_SIZE {
return Err(StreamError::InvalidHeader(format!(
"too short: {} bytes, need at least {HEADER_FIXED_SIZE}",
data.len()
)));
}
// Magic
if data[0..2] != MAGIC {
return Err(StreamError::InvalidHeader(format!(
"bad magic: [{:#04x}, {:#04x}]",
data[0], data[1]
)));
}
// Version
if data[2] != VERSION {
return Err(StreamError::InvalidHeader(format!(
"unsupported version: {}",
data[2]
)));
}
// Stream ID
let mut id_bytes = [0u8; 16];
id_bytes.copy_from_slice(&data[3..19]);
let stream_id = StreamId(id_bytes);
// Mode
let mode = match data[19] {
0x01 => StreamMode::BlobTransfer,
other => {
return Err(StreamError::InvalidHeader(format!(
"unknown mode: {other:#04x}"
)));
}
};
// Config
let stripe_count = data[20];
let frame_size = u32::from_be_bytes([data[21], data[22], data[23], data[24]]);
let meta_len = u32::from_be_bytes([data[25], data[26], data[27], data[28]]) as usize;
if data.len() < HEADER_FIXED_SIZE + meta_len {
return Err(StreamError::InvalidHeader(format!(
"metadata truncated: have {} bytes after fixed header, need {meta_len}",
data.len() - HEADER_FIXED_SIZE
)));
}
let metadata = data[HEADER_FIXED_SIZE..HEADER_FIXED_SIZE + meta_len].to_vec();
Ok(StreamHeader {
stream_id,
mode,
config: StreamConfig {
stripe_count,
frame_size,
metadata,
},
})
}
/// Encode a data frame: `[4B payload_len (big-endian)] [payload]`.
/// A payload length of 0 signals end-of-stripe.
pub fn encode_data_frame(payload: &[u8]) -> Vec<u8> {
let len = payload.len() as u32;
let mut buf = Vec::with_capacity(4 + payload.len());
buf.extend_from_slice(&len.to_be_bytes());
buf.extend_from_slice(payload);
buf
}
/// End-of-stripe sentinel: a frame with zero-length payload.
pub fn encode_end_of_stripe() -> [u8; 4] {
[0, 0, 0, 0]
}
/// Result of decoding a data frame from a byte slice.
#[derive(Debug, PartialEq, Eq)]
pub enum DataFrameDecoded<'a> {
/// A data frame with payload.
Data(&'a [u8]),
/// End-of-stripe sentinel.
EndOfStripe,
}
/// Decode a data frame from a byte slice.
/// Returns the decoded frame and the number of bytes consumed.
pub fn decode_data_frame(data: &[u8]) -> Result<(DataFrameDecoded<'_>, usize), StreamError> {
if data.len() < 4 {
return Err(StreamError::InvalidHeader(
"data frame too short for length prefix".into(),
));
}
let len = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
if len == 0 {
return Ok((DataFrameDecoded::EndOfStripe, 4));
}
if data.len() < 4 + len {
return Err(StreamError::InvalidHeader(format!(
"data frame truncated: need {len} bytes, have {}",
data.len() - 4
)));
}
Ok((DataFrameDecoded::Data(&data[4..4 + len]), 4 + len))
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn header_round_trip_basic() {
let header = StreamHeader {
stream_id: StreamId([1; 16]),
mode: StreamMode::BlobTransfer,
config: StreamConfig {
stripe_count: 4,
frame_size: 262144,
metadata: vec![10, 20, 30],
},
};
let encoded = encode_header(&header);
let decoded = decode_header(&encoded).unwrap();
assert_eq!(header, decoded);
}
#[test]
fn header_rejects_bad_magic() {
let mut encoded = encode_header(&StreamHeader {
stream_id: StreamId([0; 16]),
mode: StreamMode::BlobTransfer,
config: StreamConfig::default(),
});
encoded[0] = 0xFF;
assert!(matches!(
decode_header(&encoded),
Err(StreamError::InvalidHeader(_))
));
}
#[test]
fn header_rejects_bad_version() {
let mut encoded = encode_header(&StreamHeader {
stream_id: StreamId([0; 16]),
mode: StreamMode::BlobTransfer,
config: StreamConfig::default(),
});
encoded[2] = 0xFF;
assert!(matches!(
decode_header(&encoded),
Err(StreamError::InvalidHeader(_))
));
}
#[test]
fn header_rejects_truncated() {
let encoded = encode_header(&StreamHeader {
stream_id: StreamId([0; 16]),
mode: StreamMode::BlobTransfer,
config: StreamConfig {
metadata: vec![1, 2, 3],
..StreamConfig::default()
},
});
// Chop off the metadata
let truncated = &encoded[..HEADER_FIXED_SIZE];
assert!(matches!(
decode_header(truncated),
Err(StreamError::InvalidHeader(_))
));
}
#[test]
fn data_frame_round_trip() {
let payload = b"hello world";
let encoded = encode_data_frame(payload);
let (decoded, consumed) = decode_data_frame(&encoded).unwrap();
assert_eq!(decoded, DataFrameDecoded::Data(b"hello world"));
assert_eq!(consumed, encoded.len());
}
#[test]
fn end_of_stripe_sentinel() {
let sentinel = encode_end_of_stripe();
assert_eq!(sentinel, [0, 0, 0, 0]);
let (decoded, consumed) = decode_data_frame(&sentinel).unwrap();
assert_eq!(decoded, DataFrameDecoded::EndOfStripe);
assert_eq!(consumed, 4);
}
#[test]
fn data_frame_rejects_truncated() {
let encoded = encode_data_frame(b"hello");
// Only give the length prefix + partial payload
let truncated = &encoded[..6];
assert!(matches!(
decode_data_frame(truncated),
Err(StreamError::InvalidHeader(_))
));
}
proptest! {
#[test]
fn header_round_trip_arbitrary(
id_bytes in prop::array::uniform16(any::<u8>()),
stripe_count in 1u8..=16,
frame_size in 1024u32..=1_048_576,
metadata in prop::collection::vec(any::<u8>(), 0..256),
) {
let header = StreamHeader {
stream_id: StreamId(id_bytes),
mode: StreamMode::BlobTransfer,
config: StreamConfig {
stripe_count,
frame_size,
metadata,
},
};
let encoded = encode_header(&header);
let decoded = decode_header(&encoded).unwrap();
prop_assert_eq!(header, decoded);
}
#[test]
fn data_frame_round_trip_arbitrary(
payload in prop::collection::vec(any::<u8>(), 0..262144),
) {
if payload.is_empty() {
// Empty payload encodes as end-of-stripe
let encoded = encode_data_frame(&payload);
let (decoded, _) = decode_data_frame(&encoded).unwrap();
prop_assert_eq!(decoded, DataFrameDecoded::EndOfStripe);
} else {
let encoded = encode_data_frame(&payload);
let (decoded, consumed) = decode_data_frame(&encoded).unwrap();
prop_assert_eq!(decoded, DataFrameDecoded::Data(&payload));
prop_assert_eq!(consumed, 4 + payload.len());
}
}
}
}