liminal_sdk/remote/tcp/push_client.rs
1//! Client-side background reader for server-initiated pushes.
2//!
3//! Every other SDK transport call is request/response: the client writes a frame
4//! and reads exactly one reply to its own request ([`Connection::round_trip`]). A
5//! server PUSH inverts that — the server writes a [`Frame::Push`] on the client's
6//! existing connection at a time of the server's choosing, with no outstanding
7//! client request to read it. [`PushClient`] is the piece that consumes those
8//! inbound frames: it owns a connection whose socket is drained by a dedicated
9//! background reader thread, surfaces each pushed frame on a channel, and lets the
10//! caller send back a correlated [`Frame::PushReply`] on the same socket.
11//!
12//! # Read/write split
13//!
14//! A push connection is read concurrently (the background thread blocks on the
15//! socket) and written concurrently (the caller replies). `TcpStream` is cloned so
16//! the reader thread owns one handle and the writer holds the other behind a
17//! `Mutex`; the two handles share the same underlying socket, so a reply written
18//! by the caller travels the connection the server is pushing on. This keeps the
19//! request/reply [`Connection`] (which couples a single read to a single write)
20//! completely untouched — the push path is additive, not a rewrite.
21
22use alloc::format;
23use alloc::string::ToString;
24use alloc::sync::Arc;
25use alloc::vec;
26use alloc::vec::Vec;
27use core::time::Duration;
28
29use std::io::{Read, Write};
30use std::net::TcpStream;
31use std::sync::Mutex;
32use std::sync::atomic::{AtomicBool, Ordering};
33use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
34use std::thread::JoinHandle;
35
36use liminal::protocol::{
37 CausalContext, Frame, MessageEnvelope, ProtocolError, ProtocolVersion, SchemaId,
38 WorkerRegisterOutcome, WorkerRegistration, decode, encode, encoded_len,
39};
40
41use crate::SdkError;
42
43/// Minimum protocol version this client advertises during the handshake.
44const CLIENT_MIN_VERSION: ProtocolVersion = ProtocolVersion::new(1, 0);
45/// Maximum protocol version this client advertises during the handshake.
46const CLIENT_MAX_VERSION: ProtocolVersion = ProtocolVersion::new(1, 0);
47/// Bound on a single socket write.
48const WRITE_TIMEOUT: Duration = Duration::from_secs(5);
49/// Poll cadence the reader thread uses so it can observe the stop flag promptly
50/// between reads while still blocking efficiently on the socket the rest of the
51/// time.
52const READER_POLL_TIMEOUT: Duration = Duration::from_millis(100);
53/// Read chunk size used when draining the socket into the frame buffer.
54const READ_CHUNK_BYTES: usize = 4096;
55/// Upper bound on a single buffered frame, guarding against runaway buffering.
56const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
57/// Application stream id used for the client's push reply frames.
58const APPLICATION_STREAM_ID: u32 = 1;
59
60/// The reserved channel a worker publishes agent-observability events to over its
61/// existing push connection.
62///
63/// It is NOT a general pub/sub channel: the server routes a publish on this exact
64/// channel name straight to its `ConnectionNotifier` observability hook (bypassing
65/// the channel-fan-out cluster), so a worker never needs a second connection to
66/// stream a transcript. The name is a wire contract shared by the worker publisher
67/// and the server's demux, so it is pinned here as the single source of truth.
68pub const OBSERVABILITY_CHANNEL: &str = "aion.observability.v1";
69
70/// A frame the server pushed to this client.
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub struct PushedFrame {
73 /// Correlation id the server assigned; echo it on the reply.
74 correlation_id: u64,
75 /// Opaque payload bytes the server pushed.
76 payload: Vec<u8>,
77}
78
79impl PushedFrame {
80 /// Correlation id to echo back on the reply so the server matches it.
81 #[must_use]
82 pub const fn correlation_id(&self) -> u64 {
83 self.correlation_id
84 }
85
86 /// Opaque payload bytes the server pushed.
87 #[must_use]
88 pub fn payload(&self) -> &[u8] {
89 &self.payload
90 }
91
92 /// Consumes the frame, returning the owned payload bytes.
93 #[must_use]
94 pub fn into_payload(self) -> Vec<u8> {
95 self.payload
96 }
97}
98
99/// A connected client that consumes server pushes and sends correlated replies.
100///
101/// Construct with [`PushClient::connect`]; the background reader starts
102/// immediately and runs until the client is dropped. Pull pushed frames with
103/// [`PushClient::recv_timeout`] and answer them with [`PushClient::reply`].
104#[derive(Debug)]
105pub struct PushClient {
106 /// Write half of the shared socket, guarded so the caller's reply does not
107 /// interleave bytes with any other writer.
108 writer: Arc<Mutex<TcpStream>>,
109 /// Inbound pushed frames surfaced by the background reader.
110 inbound: Receiver<PushedFrame>,
111 /// Signals the reader thread to stop; set on drop.
112 stop: Arc<AtomicBool>,
113 /// Background reader handle, joined on drop.
114 reader: Option<JoinHandle<()>>,
115}
116
117impl PushClient {
118 /// Connects to `address`, performs the protocol handshake, and starts the
119 /// background reader that drains inbound server pushes.
120 ///
121 /// # Errors
122 ///
123 /// Returns [`SdkError::Connection`] when the TCP connection or socket
124 /// configuration fails, and [`SdkError::Protocol`] when the handshake is
125 /// rejected or the socket cannot be cloned for the reader thread.
126 pub fn connect(address: &str) -> Result<Self, SdkError> {
127 // Open access: an empty token is byte-identical to the pre-auth handshake.
128 Self::connect_with_auth(address, &[])
129 }
130
131 /// Connects and handshakes carrying `auth_token`, then starts the background
132 /// reader, for a server gated by an `[auth]` section. Additive to [`connect`];
133 /// an empty token is equivalent to it.
134 ///
135 /// # Errors
136 ///
137 /// Returns [`SdkError::Connection`] when the TCP connection or socket
138 /// configuration fails or the server rejects the token, and
139 /// [`SdkError::Protocol`] when the handshake is otherwise rejected or the socket
140 /// cannot be cloned for the reader thread.
141 ///
142 /// [`connect`]: Self::connect
143 pub fn connect_with_auth(address: &str, auth_token: &[u8]) -> Result<Self, SdkError> {
144 let mut stream = connect_socket(address)?;
145 handshake(&mut stream, auth_token)?;
146 Self::start_reader(stream)
147 }
148
149 /// Connects, performs the handshake, then synchronously registers this client
150 /// as a worker before starting the background reader.
151 ///
152 /// This mirrors the synchronous `Connect`/`ConnectAck` pattern: the
153 /// `WorkerRegister` frame is written and its [`Frame::WorkerRegisterAck`] read
154 /// on the calling thread, BEFORE the Push-only background reader is spawned, so
155 /// the ack is never swallowed by the reader. A connect-variant (rather than a
156 /// `register()` method on a connected client) is the cleanest fit: `connect`
157 /// spawns the reader as its last step, so registration must be threaded into
158 /// the connect sequence to land before that spawn; a post-connect method would
159 /// race the already-running reader for the ack frame.
160 ///
161 /// # Errors
162 ///
163 /// Returns [`SdkError::Connection`] when the TCP connection or socket
164 /// configuration fails, and [`SdkError::Protocol`] when the handshake is
165 /// rejected, the server rejects the registration (the rejection reason is
166 /// carried in the error), or the socket cannot be cloned for the reader thread.
167 pub fn connect_with_registration(
168 address: &str,
169 registration: WorkerRegistration,
170 ) -> Result<Self, SdkError> {
171 Self::connect_with_registration_and_auth(address, registration, &[])
172 }
173
174 /// Connects, handshakes carrying `auth_token`, registers the worker, then starts
175 /// the reader — the auth-gated variant of [`connect_with_registration`]. Additive;
176 /// an empty token is equivalent to it.
177 ///
178 /// # Errors
179 ///
180 /// Returns [`SdkError::Connection`] when the TCP connection or socket
181 /// configuration fails or the server rejects the token, and
182 /// [`SdkError::Protocol`] when the handshake is otherwise rejected, the server
183 /// rejects the registration (the reason is carried in the error), or the socket
184 /// cannot be cloned for the reader thread.
185 ///
186 /// [`connect_with_registration`]: Self::connect_with_registration
187 pub fn connect_with_registration_and_auth(
188 address: &str,
189 registration: WorkerRegistration,
190 auth_token: &[u8],
191 ) -> Result<Self, SdkError> {
192 let mut stream = connect_socket(address)?;
193 handshake(&mut stream, auth_token)?;
194 register(&mut stream, registration)?;
195 Self::start_reader(stream)
196 }
197
198 /// Spawns the Push-only background reader over a handshaken (and, for a worker,
199 /// already-registered) stream and returns the running client.
200 fn start_reader(stream: TcpStream) -> Result<Self, SdkError> {
201 // Clone the socket so the reader thread owns one handle and the writer
202 // holds the other; both refer to the same underlying connection.
203 let read_stream = stream.try_clone().map_err(|source| SdkError::Protocol {
204 description: format!("failed to clone push socket for reader thread: {source}"),
205 })?;
206
207 let stop = Arc::new(AtomicBool::new(false));
208 let (sender, inbound) = channel();
209 let reader_stop = Arc::clone(&stop);
210 let reader = std::thread::Builder::new()
211 .name("liminal-push-reader".to_string())
212 .spawn(move || run_reader(read_stream, &sender, &reader_stop))
213 .map_err(|source| SdkError::Protocol {
214 description: format!("failed to start push reader thread: {source}"),
215 })?;
216
217 Ok(Self {
218 writer: Arc::new(Mutex::new(stream)),
219 inbound,
220 stop,
221 reader: Some(reader),
222 })
223 }
224
225 /// Blocks up to `timeout` for the next pushed frame from the server.
226 ///
227 /// # Errors
228 ///
229 /// Returns [`SdkError::Connection`] when no push arrives within `timeout` or
230 /// the background reader has stopped (e.g. the server closed the connection).
231 pub fn recv_timeout(&self, timeout: Duration) -> Result<PushedFrame, SdkError> {
232 self.inbound.recv_timeout(timeout).map_err(|error| {
233 let detail = match error {
234 RecvTimeoutError::Timeout => "no server push arrived within the timeout",
235 RecvTimeoutError::Disconnected => {
236 "the push reader stopped before a server push arrived"
237 }
238 };
239 SdkError::Connection {
240 description: format!("push receive failed: {detail}"),
241 }
242 })
243 }
244
245 /// Sends a correlated reply to a pushed frame, echoing its correlation id so
246 /// the server matches the reply back to the originating push.
247 ///
248 /// # Errors
249 ///
250 /// Returns [`SdkError::Protocol`] when the reply frame cannot be encoded and
251 /// [`SdkError::Connection`] when it cannot be written to the socket or the
252 /// writer lock is poisoned.
253 pub fn reply(&self, correlation_id: u64, payload: Vec<u8>) -> Result<(), SdkError> {
254 let frame = Frame::new_push_reply(APPLICATION_STREAM_ID, correlation_id, payload)
255 .map_err(|error| protocol_error(&error))?;
256 let mut writer = self.writer.lock().map_err(|error| SdkError::Connection {
257 description: format!("push writer lock poisoned: {error}"),
258 })?;
259 write_frame(&mut writer, &frame)
260 }
261
262 /// A cheap, cloneable handle to this push connection's write half, for
263 /// background tasks that publish out-of-band frames on the same socket without
264 /// owning the full client (which cannot be cloned — it holds the reader thread
265 /// join handle).
266 ///
267 /// The returned [`PushWriter`] shares the client's `Arc<Mutex<TcpStream>>`, so a
268 /// frame it writes travels the SAME connection the server pushes on. It is the
269 /// worker's observability-drain leg: a drain task holds one and publishes each
270 /// [`OBSERVABILITY_CHANNEL`] event live while the client keeps serving pushes.
271 #[must_use]
272 pub fn writer_handle(&self) -> PushWriter {
273 PushWriter {
274 writer: Arc::clone(&self.writer),
275 }
276 }
277
278 /// Publish `payload` to `channel` over this connection (out-of-band from the
279 /// push/reply round trip).
280 ///
281 /// Convenience shorthand for `self.writer_handle().publish(channel, payload)`.
282 ///
283 /// # Errors
284 ///
285 /// Returns [`SdkError::Protocol`] when the publish frame cannot be encoded and
286 /// [`SdkError::Connection`] when it cannot be written to the socket or the
287 /// writer lock is poisoned.
288 pub fn publish(&self, channel: &str, payload: Vec<u8>) -> Result<(), SdkError> {
289 self.writer_handle().publish(channel, payload)
290 }
291}
292
293/// A cheap clone of a [`PushClient`]'s write half.
294///
295/// It writes `Frame::Publish` frames on the SAME socket the client receives pushes
296/// on, so a background drain task can stream observability events upstream without a
297/// second connection. Cloning is an `Arc` bump; the underlying socket and its write
298/// lock are shared with the originating [`PushClient`].
299#[derive(Clone, Debug)]
300pub struct PushWriter {
301 writer: Arc<Mutex<TcpStream>>,
302}
303
304impl PushWriter {
305 /// Publish `payload` to `channel` on the shared connection.
306 ///
307 /// Writes a single `Frame::Publish` carrying the opaque bytes verbatim (schema
308 /// id zero, an independent causal context — the server routes the reserved
309 /// observability channel straight to its notifier hook, so no schema negotiation
310 /// or ordering context is required). The write takes the shared writer lock, so
311 /// it never interleaves bytes with a concurrent push reply.
312 ///
313 /// # Errors
314 ///
315 /// Returns [`SdkError::Protocol`] when the publish frame cannot be encoded and
316 /// [`SdkError::Connection`] when it cannot be written to the socket or the writer
317 /// lock is poisoned.
318 pub fn publish(&self, channel: &str, payload: Vec<u8>) -> Result<(), SdkError> {
319 let envelope = MessageEnvelope::new(
320 SchemaId::new([0_u8; SchemaId::WIRE_LEN]),
321 CausalContext::independent(),
322 payload,
323 );
324 let frame = Frame::new_publish(APPLICATION_STREAM_ID, channel, envelope)
325 .map_err(|error| protocol_error(&error))?;
326 let mut writer = self.writer.lock().map_err(|error| SdkError::Connection {
327 description: format!("push writer lock poisoned: {error}"),
328 })?;
329 write_frame(&mut writer, &frame)
330 }
331
332 /// Send a correlated reply to a server push on the shared connection, echoing the
333 /// push's `correlation_id` so the server matches the reply to its push.
334 ///
335 /// Identical wire effect to [`PushClient::reply`], but issued from a cheap
336 /// [`PushWriter`] clone so a BACKGROUND task (e.g. a long-running agent dispatch)
337 /// can answer its own push after it completes, without holding the full client or
338 /// blocking the serve loop. Shares the writer lock, so it never interleaves bytes
339 /// with a concurrent publish or reply.
340 ///
341 /// # Errors
342 ///
343 /// Returns [`SdkError::Protocol`] when the reply frame cannot be encoded and
344 /// [`SdkError::Connection`] when it cannot be written to the socket or the writer
345 /// lock is poisoned.
346 pub fn reply(&self, correlation_id: u64, payload: Vec<u8>) -> Result<(), SdkError> {
347 let frame = Frame::new_push_reply(APPLICATION_STREAM_ID, correlation_id, payload)
348 .map_err(|error| protocol_error(&error))?;
349 let mut writer = self.writer.lock().map_err(|error| SdkError::Connection {
350 description: format!("push writer lock poisoned: {error}"),
351 })?;
352 write_frame(&mut writer, &frame)
353 }
354}
355
356impl Drop for PushClient {
357 fn drop(&mut self) {
358 self.stop.store(true, Ordering::SeqCst);
359 if let Some(reader) = self.reader.take() {
360 // The reader wakes within READER_POLL_TIMEOUT to observe the stop flag,
361 // so this join does not hang on a quiet connection.
362 reader.join().ok();
363 }
364 }
365}
366
367/// Opens and configures the push-client socket (Nagle off, bounded read/write
368/// timeouts) before any framing.
369fn connect_socket(address: &str) -> Result<TcpStream, SdkError> {
370 let stream = TcpStream::connect(address).map_err(|source| SdkError::Connection {
371 description: format!("failed to connect push client to {address}: {source}"),
372 })?;
373 stream
374 .set_nodelay(true)
375 .map_err(|source| SdkError::Connection {
376 description: format!("failed to disable Nagle for {address}: {source}"),
377 })?;
378 // A bounded read timeout lets the reader thread wake to check the stop flag
379 // even when the server is silent; without it the thread would block forever
380 // on a quiet connection and never observe drop.
381 stream
382 .set_read_timeout(Some(READER_POLL_TIMEOUT))
383 .map_err(|source| SdkError::Connection {
384 description: format!("failed to set push read timeout for {address}: {source}"),
385 })?;
386 stream
387 .set_write_timeout(Some(WRITE_TIMEOUT))
388 .map_err(|source| SdkError::Connection {
389 description: format!("failed to set push write timeout for {address}: {source}"),
390 })?;
391 Ok(stream)
392}
393
394/// Drives the synchronous worker-registration round trip
395/// (`WorkerRegister` -> `WorkerRegisterAck`) on a handshaken socket, before the
396/// background reader is spawned.
397///
398/// A `Rejected` ack maps to a typed [`SdkError::Protocol`] carrying the server's
399/// reason; any non-ack reply is a protocol error.
400fn register(stream: &mut TcpStream, registration: WorkerRegistration) -> Result<(), SdkError> {
401 let frame = Frame::WorkerRegister {
402 flags: 0,
403 registration,
404 };
405 write_frame(stream, &frame)?;
406 let mut buffer = Vec::new();
407 match read_one_frame(stream, &mut buffer)? {
408 Frame::WorkerRegisterAck {
409 outcome: WorkerRegisterOutcome::Accepted,
410 ..
411 } => Ok(()),
412 Frame::WorkerRegisterAck {
413 outcome: WorkerRegisterOutcome::Rejected { reason },
414 ..
415 } => Err(SdkError::Protocol {
416 description: format!("server rejected worker registration: {reason}"),
417 }),
418 other => Err(SdkError::Protocol {
419 description: format!(
420 "expected WorkerRegisterAck during registration, received {:?}",
421 other.frame_type()
422 ),
423 }),
424 }
425}
426
427/// Drives the client handshake (`Connect` -> `ConnectAck`) on a fresh socket,
428/// carrying `auth_token` (empty for an open, non-auth server).
429fn handshake(stream: &mut TcpStream, auth_token: &[u8]) -> Result<(), SdkError> {
430 let connect = Frame::Connect {
431 flags: 0,
432 min_version: CLIENT_MIN_VERSION,
433 max_version: CLIENT_MAX_VERSION,
434 auth_token: auth_token.to_vec(),
435 };
436 write_frame(stream, &connect)?;
437 let mut buffer = Vec::new();
438 match read_one_frame(stream, &mut buffer)? {
439 Frame::ConnectAck { .. } => Ok(()),
440 Frame::ConnectError {
441 reason_code,
442 message,
443 ..
444 } => Err(SdkError::Connection {
445 description: format!(
446 "server rejected push connection (reason {reason_code}): {}",
447 message.unwrap_or_else(|| "no detail".to_string())
448 ),
449 }),
450 other => Err(SdkError::Protocol {
451 description: format!(
452 "expected ConnectAck during push handshake, received {:?}",
453 other.frame_type()
454 ),
455 }),
456 }
457}
458
459/// Background loop: drains the socket, surfacing each `Push` frame on `sender`.
460///
461/// Returns (ending the thread) when the stop flag is set, the connection closes,
462/// or a fatal decode/IO error occurs. A read timeout is non-fatal: it just lets
463/// the loop re-check the stop flag.
464fn run_reader(mut stream: TcpStream, sender: &Sender<PushedFrame>, stop: &AtomicBool) {
465 let mut buffer = Vec::new();
466 while !stop.load(Ordering::SeqCst) {
467 match next_frame(&mut stream, &mut buffer) {
468 Ok(Some(Frame::Push {
469 correlation_id,
470 payload,
471 ..
472 })) => {
473 if sender
474 .send(PushedFrame {
475 correlation_id,
476 payload,
477 })
478 .is_err()
479 {
480 // The receiver was dropped; nothing will consume further
481 // pushes, so stop reading.
482 return;
483 }
484 }
485 // `Some(_)`: any non-Push frame on a push connection is unexpected for
486 // this spike — ignore it rather than tearing the reader down so a stray
487 // frame cannot silently drop subsequent pushes. `None`: a read timeout
488 // with no complete frame. Both just loop to re-check the stop flag.
489 Ok(Some(_) | None) => {}
490 // Connection closed or a fatal read/decode error: end the thread. The
491 // dropped `sender` surfaces as a `Disconnected` on the receiver side.
492 Err(_) => return,
493 }
494 }
495}
496
497/// Reads until one complete frame decodes, treating a read timeout as
498/// `Ok(None)` so the caller can re-check the stop flag without ending the loop.
499fn next_frame(stream: &mut TcpStream, buffer: &mut Vec<u8>) -> Result<Option<Frame>, SdkError> {
500 loop {
501 match decode(buffer) {
502 Ok((frame, consumed)) => {
503 buffer.drain(..consumed);
504 return Ok(Some(frame));
505 }
506 Err(
507 ProtocolError::IncompleteHeader { .. } | ProtocolError::TruncatedPayload { .. },
508 ) => match fill_buffer(stream, buffer)? {
509 FillOutcome::Read => {}
510 FillOutcome::TimedOut => return Ok(None),
511 },
512 Err(error) => return Err(protocol_error(&error)),
513 }
514 }
515}
516
517/// Reads one complete frame, blocking (no timeout tolerance) — used for the
518/// synchronous handshake and worker-registration replies, before the background
519/// reader starts.
520fn read_one_frame(stream: &mut TcpStream, buffer: &mut Vec<u8>) -> Result<Frame, SdkError> {
521 loop {
522 match decode(buffer) {
523 Ok((frame, consumed)) => {
524 buffer.drain(..consumed);
525 return Ok(frame);
526 }
527 Err(
528 ProtocolError::IncompleteHeader { .. } | ProtocolError::TruncatedPayload { .. },
529 ) => match fill_buffer(stream, buffer)? {
530 FillOutcome::Read => {}
531 FillOutcome::TimedOut => {
532 return Err(SdkError::Connection {
533 description: "push connection timed out waiting for a control-frame reply"
534 .to_string(),
535 });
536 }
537 },
538 Err(error) => return Err(protocol_error(&error)),
539 }
540 }
541}
542
543/// Appends one socket read into `buffer`, mapping a read timeout to a non-fatal
544/// [`FillOutcome::TimedOut`] so the reader can poll the stop flag.
545fn fill_buffer(stream: &mut TcpStream, buffer: &mut Vec<u8>) -> Result<FillOutcome, SdkError> {
546 if buffer.len() > MAX_FRAME_BYTES {
547 return Err(SdkError::Protocol {
548 description: format!(
549 "push frame exceeded {MAX_FRAME_BYTES} bytes without a complete frame"
550 ),
551 });
552 }
553 let mut chunk = [0_u8; READ_CHUNK_BYTES];
554 match stream.read(&mut chunk) {
555 Ok(0) => Err(SdkError::Connection {
556 description: "server closed the push connection".to_string(),
557 }),
558 Ok(read) => {
559 let Some(received) = chunk.get(..read) else {
560 return Err(SdkError::Protocol {
561 description: "push socket read reported more bytes than the buffer holds"
562 .to_string(),
563 });
564 };
565 buffer.extend_from_slice(received);
566 Ok(FillOutcome::Read)
567 }
568 Err(error)
569 if matches!(
570 error.kind(),
571 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
572 ) =>
573 {
574 Ok(FillOutcome::TimedOut)
575 }
576 Err(error) => Err(SdkError::Connection {
577 description: format!("failed to read from push connection: {error}"),
578 }),
579 }
580}
581
582/// Outcome of one non-fatal socket read attempt.
583#[derive(Debug, Clone, Copy, PartialEq, Eq)]
584enum FillOutcome {
585 Read,
586 TimedOut,
587}
588
589/// Encodes and writes one frame to the socket, flushing it.
590fn write_frame(stream: &mut TcpStream, frame: &Frame) -> Result<(), SdkError> {
591 let len = encoded_len(frame).map_err(|error| protocol_error(&error))?;
592 let mut bytes = vec![0_u8; len];
593 let written = encode(frame, &mut bytes).map_err(|error| protocol_error(&error))?;
594 let encoded = bytes.get(..written).ok_or_else(|| SdkError::Protocol {
595 description: "push wire encoder reported an invalid byte count".to_string(),
596 })?;
597 stream
598 .write_all(encoded)
599 .map_err(|source| SdkError::Connection {
600 description: format!("failed to write push frame: {source}"),
601 })?;
602 stream.flush().map_err(|source| SdkError::Connection {
603 description: format!("failed to flush push frame: {source}"),
604 })
605}
606
607/// Maps a wire codec error into the SDK error taxonomy.
608fn protocol_error(error: &ProtocolError) -> SdkError {
609 SdkError::Protocol {
610 description: format!("push wire codec error: {error}"),
611 }
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use liminal::protocol::FrameType;
618
619 #[test]
620 fn pushed_frame_exposes_correlation_and_payload() {
621 let frame = PushedFrame {
622 correlation_id: 7,
623 payload: vec![1, 2, 3],
624 };
625 assert_eq!(frame.correlation_id(), 7);
626 assert_eq!(frame.payload(), &[1, 2, 3]);
627 assert_eq!(frame.into_payload(), vec![1, 2, 3]);
628 }
629
630 #[test]
631 fn publish_frame_round_trips_through_codec() -> Result<(), SdkError> {
632 // The observability publish frame the drain leg writes: a Publish on the
633 // reserved channel carrying opaque payload bytes verbatim.
634 let envelope = MessageEnvelope::new(
635 SchemaId::new([0_u8; SchemaId::WIRE_LEN]),
636 CausalContext::independent(),
637 vec![9, 9, 9],
638 );
639 let frame = Frame::new_publish(APPLICATION_STREAM_ID, OBSERVABILITY_CHANNEL, envelope)
640 .map_err(|error| protocol_error(&error))?;
641 let len = encoded_len(&frame).map_err(|error| protocol_error(&error))?;
642 let mut bytes = vec![0_u8; len];
643 let written = encode(&frame, &mut bytes).map_err(|error| protocol_error(&error))?;
644 let (decoded, consumed) =
645 decode(&bytes[..written]).map_err(|error| protocol_error(&error))?;
646 assert_eq!(consumed, written);
647 assert_eq!(decoded.frame_type(), FrameType::Publish);
648 let Frame::Publish {
649 channel, envelope, ..
650 } = decoded
651 else {
652 return Err(SdkError::Protocol {
653 description: "expected a Publish frame".to_string(),
654 });
655 };
656 assert_eq!(channel, OBSERVABILITY_CHANNEL);
657 assert_eq!(envelope.payload, vec![9, 9, 9]);
658 Ok(())
659 }
660
661 #[test]
662 fn reply_frame_round_trips_through_codec() -> Result<(), SdkError> {
663 let frame = Frame::new_push_reply(APPLICATION_STREAM_ID, 9, vec![4, 5])
664 .map_err(|error| protocol_error(&error))?;
665 let len = encoded_len(&frame).map_err(|error| protocol_error(&error))?;
666 let mut bytes = vec![0_u8; len];
667 let written = encode(&frame, &mut bytes).map_err(|error| protocol_error(&error))?;
668 let (decoded, consumed) =
669 decode(&bytes[..written]).map_err(|error| protocol_error(&error))?;
670 assert_eq!(consumed, written);
671 assert_eq!(decoded.frame_type(), FrameType::PushReply);
672 Ok(())
673 }
674}