Build: The MessageStream Codec
Maps to: Phase 6 (cluster). Kind: Build.
Objective
Add the wire to control-core. You already have the Message enum from Part II — defined, tested, but never yet sent anywhere. Here you build MessageStream: a thin wrapper over Framed<TcpStream, LengthDelimitedCodec> that offers send(&Message) and recv() -> Option<Message>, and prove it with a single test that ships a Message across a real loopback socket and gets it back byte-identical. This is the first code in the entire course that touches the network — and it is small on purpose, because LengthDelimitedCodec does the hard part.
Scaffold
Create:
crates/control-core/src/codec.rs— theMessageStreamstruct, itsnew/send/recv, and its one test.
Edit:
crates/control-core/src/lib.rs— addpub mod codec;and re-exportMessageStreamalongside the other public types.crates/control-core/Cargo.toml— four new dependencies.
New deps and why:
tokio-util(features["codec"]) — suppliesFramedandLengthDelimitedCodec, the framing layer. This is the new crate the concept chapter demonstrated. ([dependencies].)futures— supplies theSinkExt/StreamExtextension traits whose.send()and.next()you call on aFramed. ([dependencies].)bytes— suppliesBytes, the type the codec'sSinkaccepts for an outgoing frame. ([dependencies].)tokio— add thenetfeature (forTcpStream/TcpListener) to the existing entry;macros/rtare already there from the seam build. ([dependencies], plus#[tokio::test]from dev.)
serde_json (encode/decode each frame) and pretty_assertions (the test's assert_eq!) are already in the manifest from earlier builds.
Expected result: cargo test -p control-core → 12 tests pass. The eleven from the Core arc are untouched; the new one is a_message_roundtrips_over_a_real_socket in codec.rs. The Core-arc build told you a twelfth test would arrive "when you build the framed codec that actually ships these frames" — this is that test, and the count reaches 12 now.
The spec (givens)
MessageStream
/// A bidirectional channel of `Message`s over one TCP connection.
pub struct MessageStream {
framed: Framed<TcpStream, LengthDelimitedCodec>,
}
impl MessageStream {
pub fn new(stream: TcpStream) -> Self { /* wrap in Framed + LengthDelimitedCodec */ }
/// Encode a message as one length-prefixed JSON frame and send it.
pub async fn send(&mut self, msg: &Message) -> Result<(), ControlError>;
/// Read the next frame and decode it. `Ok(None)` means the peer closed cleanly.
pub async fn recv(&mut self) -> Result<Option<Message>, ControlError>;
}
newwraps theTcpStreamwithFramed::new(stream, LengthDelimitedCodec::new()). Nothing else — the codec is the whole configuration.sendserializesmsgwithserde_json::to_vec, wraps theVec<u8>inBytes::from(...), and pushes it withself.framed.send(...).await. Both the serialize step and the sink step map any error toControlError::Protocol(e.to_string()).recvpullsself.framed.next().awaitand matches three arms:Some(Ok(frame))deserializes the frame bytes withserde_json::from_slice, mapping a serde error toControlError::Protocoland returningOk(Some(msg));Some(Err(e))(a framing/IO error) maps toErr(ControlError::Protocol(...));None(clean end of stream) returnsOk(None).
The error mapping is the whole policy: a garbled frame or a broken link is retryable (Protocol), and a clean close is not an error at all (Ok(None)). Hold the two apart — the connection actor in the next chapter branches on exactly this difference.
Concepts exercised
Framed+LengthDelimitedCodecas the framing layer over a rawTcpStream.- The
futures::SinkExt/StreamExtextension traits behind.send()/.next(). - One JSON document per length-delimited frame via
serde_json::to_vec/from_slice. - Mapping both serde and framing failures onto the retryable
ControlError::Protocol, and a clean close ontoOk(None). - Driving two
MessageStreams over a loopbackTcpListenerbound to127.0.0.1:0.
The build loop (you drive)
Test — a_message_roundtrips_over_a_real_socket (in codec.rs, #[tokio::test])
- Write the failing test. Bind a
TcpListenerto127.0.0.1:0(the OS picks a free port; read it back withlocal_addr()).tokio::spawna server task thataccept()s one connection, wraps it in aMessageStream, and echoes:recv().awaitone message andsendit straight back. In the main task,TcpStream::connect(addr), wrap it in aMessageStream,sendaMessage::Register { worker_id: "w1".into(), capacity: 4 }, thenrecvand assert the echoed message equals the one you sent. - Predict: before you implement
recv, what does the server task'srecv().awaitreturn the instant the client task ends and its socket drops —Ok(Some(..)),Ok(None), or anErr? Tie your answer to the three match arms in the spec. - Run — it fails to compile (no
MessageStreamyet). - Implement
MessageStream—new,send,recv— exactly as specified. Do not forgetuse futures::{SinkExt, StreamExt};, or the compiler will claimFramedhas nosend/nextmethod. - Run green. Then run the whole suite —
cargo test -p control-core— and confirm you are at 12 with the eleven Core-arc tests still green. - Commit.
Framed entirely and just wrote the JSON bytes to the raw TcpStream with write_all, then sent a second message the same way, what would a single read on the far side most likely return — and which concept from the framing chapter names the bug? Say it in one sentence, then let LengthDelimitedCodec make the question moot.
Sink for Framed<_, LengthDelimitedCodec> accepts Bytes, not Vec<u8>. Pass the raw serde_json::to_vec result and you get a trait-bound error on send that points at Sink<Vec<u8>> not being satisfied — the fix is one Bytes::from(bytes). And recv's None arm is not an oversight to fill in with an error: a clean end-of-stream is Ok(None) on purpose, and turning it into an Err here would make every normal worker disconnect look like a protocol failure.
Done when
cargo test -p control-core shows 12 green (the eleven Core-arc tests plus a_message_roundtrips_over_a_real_socket); a Message::Register survives the trip across a loopback socket and compares equal; you can say why send wraps its bytes in Bytes and why recv's clean-close arm returns Ok(None) rather than an error. Commit. control-core now has a wire — the next chapter puts a worker on the other end of it.