Skip to main content

liminal_sdk/remote/tcp/
mod.rs

1//! Real TCP transport for the remote SDK.
2//!
3//! Unlike [`ProtocolRemoteTransport`](super::protocol::ProtocolRemoteTransport),
4//! which only exercises the SDK's framing in-process, this transport opens a real
5//! `TcpStream` to a running `liminal-server`, performs the protocol handshake, and
6//! exchanges canonical wire frames over the socket.
7//!
8//! # Blocking model
9//!
10//! The SDK API surface is synchronous: [`RemoteTransport`] methods return plain
11//! `Result` values, and the rest of the SDK (connection pool, lifecycle) is
12//! driven by ordinary blocking calls. This transport therefore uses
13//! `std::net::TcpStream` in blocking mode with explicit read/write timeouts; it
14//! does not introduce an async runtime. Each transport call holds a short-lived
15//! connection lock for the duration of one request/response exchange.
16
17mod connection;
18mod push_client;
19mod subscription;
20
21pub use push_client::{OBSERVABILITY_CHANNEL, PushClient, PushWriter, PushedFrame};
22pub use subscription::{DeliveredMessage, SubscriptionStream};
23
24use alloc::format;
25use alloc::string::ToString;
26use alloc::sync::Arc;
27use alloc::vec::Vec;
28use core::fmt;
29
30use liminal::protocol::{
31    CausalContext, Frame, MessageEnvelope, PUBLISH_DELIVERED_FLAG, PUBLISH_IDEMPOTENCY_KEY_FLAG,
32    SchemaId,
33};
34use spin::Mutex;
35
36use crate::{DeliveryAck, PressureResponse, SdkError};
37
38use self::connection::{Connection, unexpected_frame};
39use super::ServerAddress;
40use super::protocol::{
41    RemoteTransport, WireConversationRequest, WirePublishRequest, WireResumeRequest,
42    WireSubscribeRequest,
43};
44
45/// Application stream id used for non-subscription application frames.
46const APPLICATION_STREAM_ID: u32 = 1;
47/// In-flight credit advertised on subscribe; one keeps strict pacing.
48const DEFAULT_MAX_IN_FLIGHT: u32 = 1;
49/// Schema id used for payloads whose schema is not carried on the wire.
50const SCHEMALESS_SCHEMA: &[u8] = &[];
51
52/// Real TCP transport that exchanges canonical wire frames with a liminal server.
53pub struct TcpRemoteTransport {
54    connection: Arc<Mutex<Connection>>,
55}
56
57impl fmt::Debug for TcpRemoteTransport {
58    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
59        formatter
60            .debug_struct("TcpRemoteTransport")
61            .finish_non_exhaustive()
62    }
63}
64
65impl TcpRemoteTransport {
66    /// Connects to `server_address`, completes the handshake, and returns a ready transport.
67    ///
68    /// # Errors
69    ///
70    /// Returns [`SdkError::Connection`] when the TCP connection cannot be
71    /// established, and [`SdkError::Protocol`] when the handshake frames cannot be
72    /// encoded, sent, or are rejected by the server.
73    pub fn connect(server_address: &ServerAddress) -> Result<Self, SdkError> {
74        let connection = Connection::connect(server_address.as_str())?;
75        Ok(Self {
76            connection: Arc::new(Mutex::new(connection)),
77        })
78    }
79
80    /// Connects and handshakes carrying `auth_token`, for a server gated by an
81    /// `[auth]` section. Additive to [`connect`]; an empty token is equivalent to it.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`SdkError::Connection`] when the TCP connection cannot be
86    /// established or the server rejects the token (a `ConnectError` closes the
87    /// socket), and [`SdkError::Protocol`] when the handshake frames cannot be
88    /// encoded or sent.
89    ///
90    /// [`connect`]: Self::connect
91    pub fn connect_with_auth(
92        server_address: &ServerAddress,
93        auth_token: &[u8],
94    ) -> Result<Self, SdkError> {
95        let connection = Connection::connect_with_auth(server_address.as_str(), auth_token)?;
96        Ok(Self {
97            connection: Arc::new(Mutex::new(connection)),
98        })
99    }
100
101    fn round_trip(&self, request: &Frame) -> Result<Frame, SdkError> {
102        let mut connection = self.connection.lock();
103        connection.round_trip(request)
104    }
105}
106
107impl RemoteTransport for TcpRemoteTransport {
108    fn publish(
109        &self,
110        _server_address: &ServerAddress,
111        request: &WirePublishRequest,
112    ) -> Result<PressureResponse, SdkError> {
113        let frame = build_publish_frame(request);
114        let response = self.round_trip(&frame)?;
115        publish_response(response)
116    }
117
118    fn publish_with_delivery(
119        &self,
120        _server_address: &ServerAddress,
121        request: &WirePublishRequest,
122    ) -> Result<DeliveryAck, SdkError> {
123        let frame = build_publish_frame(request);
124        let response = self.round_trip(&frame)?;
125        publish_delivery_response(response)
126    }
127
128    /// Subscribes over the shared request/response connection.
129    ///
130    /// # v1 caveat — pooled subscribe registers a delivering subscriber
131    ///
132    /// This registers a *real* server-side subscriber on the shared pool
133    /// connection, which is what lets a subsequent keyed publish observe a genuine
134    /// delivery ack ([`PUBLISH_DELIVERED_FLAG`](liminal::protocol::PUBLISH_DELIVERED_FLAG)).
135    /// The server then pumps a `Deliver` frame for every message on the channel onto
136    /// this connection. Because the connection only reads (and discards) those
137    /// frames during a round trip, an application that subscribes for the ack signal
138    /// and then goes idle on a busy channel lets the server's bounded outbound buffer
139    /// (default 4 MiB) fill; on overflow the server tears the connection down, and
140    /// every later request on this transport then fails through no fault of the
141    /// caller. An actively-used transport is self-limiting (each round trip drains
142    /// the backlog), but a subscribe-then-idle client on a hot channel is at risk.
143    ///
144    /// v1 guidance: consume channel deliveries through a dedicated
145    /// [`SubscriptionStream`] (its own connection with a background reader), and use
146    /// the pooled subscribe only as the delivery-ack signal alongside regular
147    /// traffic. The v2 credit mode removes this by gating and multiplexing delivery.
148    fn subscribe(
149        &self,
150        _server_address: &ServerAddress,
151        request: &WireSubscribeRequest,
152    ) -> Result<(), SdkError> {
153        let frame = Frame::Subscribe {
154            flags: 0,
155            stream_id: request.stream_id(),
156            channel: request.channel().to_string(),
157            // An empty accepted-schema list lets the server select the channel's
158            // configured schema, mirroring the server's negotiation contract.
159            accepted_schemas: Vec::new(),
160            max_in_flight: DEFAULT_MAX_IN_FLIGHT,
161        };
162        let response = self.round_trip(&frame)?;
163        subscribe_response(response)
164    }
165
166    fn send_conversation(
167        &self,
168        _server_address: &ServerAddress,
169        request: &WireConversationRequest,
170    ) -> Result<(), SdkError> {
171        let conversation_label = request.conversation_id().as_str();
172        let conversation_id = conversation_wire_id(conversation_label);
173        let envelope = build_envelope(SCHEMALESS_SCHEMA, request.payload());
174        let mut connection = self.connection.lock();
175        connection.send_conversation_message(conversation_id, conversation_label, envelope)
176    }
177
178    fn request_reply_conversation(
179        &self,
180        _server_address: &ServerAddress,
181        request: &WireConversationRequest,
182    ) -> Result<Vec<u8>, SdkError> {
183        let conversation_label = request.conversation_id().as_str();
184        let conversation_id = conversation_wire_id(conversation_label);
185        let envelope = build_envelope(SCHEMALESS_SCHEMA, request.payload());
186        let mut connection = self.connection.lock();
187        connection.conversation_request_reply(conversation_id, conversation_label, envelope)
188    }
189
190    fn resume(
191        &self,
192        _server_address: &ServerAddress,
193        request: &WireResumeRequest,
194    ) -> Result<(), SdkError> {
195        // The wire protocol has no resume frame: the server replays a subscription
196        // from its durable log only when the SDK re-issues the Subscribe for that
197        // stream on reconnect. This transport does not retain the channel/stream
198        // mapping needed to re-drive that Subscribe here, so it cannot honour the
199        // resume over the socket. Returning a clear error keeps the contract honest
200        // rather than reporting success while dropping the user's resume intent.
201        let _ = (request.subscription_id(), request.resume_from_sequence());
202        Err(SdkError::Protocol {
203            description:
204                "resume is not yet supported over the TCP transport; re-subscribe to trigger \
205                 server replay"
206                    .to_string(),
207        })
208    }
209}
210
211fn build_envelope(schema_bytes: &[u8], payload: &[u8]) -> MessageEnvelope {
212    MessageEnvelope::new(
213        schema_id_from_bytes(schema_bytes),
214        CausalContext::independent(),
215        payload.to_vec(),
216    )
217}
218
219/// Derives a stable 32-byte schema id from arbitrary schema bytes via FNV-1a.
220///
221/// The server selects the channel's configured schema on subscribe and stores the
222/// published envelope verbatim, so this id only needs to be deterministic, not a
223/// negotiated value.
224fn schema_id_from_bytes(schema_bytes: &[u8]) -> SchemaId {
225    let mut id = [0_u8; SchemaId::WIRE_LEN];
226    let mut hash = fnv1a(schema_bytes).to_be_bytes();
227    // Spread the 8-byte digest across the 32-byte id deterministically.
228    for (index, slot) in id.iter_mut().enumerate() {
229        *slot = hash[index % hash.len()];
230        if index % hash.len() == hash.len() - 1 {
231            hash = fnv1a(&hash).to_be_bytes();
232        }
233    }
234    SchemaId::new(id)
235}
236
237fn conversation_wire_id(conversation_id: &str) -> u64 {
238    fnv1a(conversation_id.as_bytes())
239}
240
241/// FNV-1a 64-bit hash, used only for deterministic wire-id derivation.
242fn fnv1a(bytes: &[u8]) -> u64 {
243    const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
244    const PRIME: u64 = 0x0000_0100_0000_01b3;
245    let mut hash = OFFSET_BASIS;
246    for byte in bytes {
247        hash ^= u64::from(*byte);
248        hash = hash.wrapping_mul(PRIME);
249    }
250    hash
251}
252
253/// Builds the wire `Publish` frame, attaching the idempotency key (and its flag)
254/// only when the request carries one so a no-key publish stays byte-identical to
255/// the pre-13-L1 layout.
256fn build_publish_frame(request: &WirePublishRequest) -> Frame {
257    let envelope = build_envelope(request.schema().schema.as_ref(), request.payload());
258    let flags = match request.idempotency_key() {
259        Some(_) => PUBLISH_IDEMPOTENCY_KEY_FLAG,
260        None => 0,
261    };
262    Frame::Publish {
263        flags,
264        stream_id: APPLICATION_STREAM_ID,
265        channel: request.channel().to_string(),
266        envelope,
267        idempotency_key: request.idempotency_key().map(ToString::to_string),
268    }
269}
270
271fn publish_response(frame: Frame) -> Result<PressureResponse, SdkError> {
272    match frame {
273        Frame::PublishAck { .. } => Ok(PressureResponse::Accept),
274        Frame::PublishError {
275            reason_code,
276            message,
277            ..
278        } => Err(SdkError::Backpressure {
279            reason: format!(
280                "server rejected publish (reason {reason_code}): {}",
281                message.unwrap_or_else(|| "no detail".to_string())
282            ),
283        }),
284        other => Err(unexpected_frame("PublishAck", &other)),
285    }
286}
287
288/// Maps a publish ack into a genuine delivery ack: the `PUBLISH_DELIVERED_FLAG`
289/// bit on the ack reports whether a subscriber actually received the message.
290fn publish_delivery_response(frame: Frame) -> Result<DeliveryAck, SdkError> {
291    match frame {
292        Frame::PublishAck { flags, .. } => {
293            let accepted = flags & PUBLISH_DELIVERED_FLAG != 0;
294            Ok(DeliveryAck::new(PressureResponse::Accept, accepted))
295        }
296        Frame::PublishError {
297            reason_code,
298            message,
299            ..
300        } => Err(SdkError::Backpressure {
301            reason: format!(
302                "server rejected publish (reason {reason_code}): {}",
303                message.unwrap_or_else(|| "no detail".to_string())
304            ),
305        }),
306        other => Err(unexpected_frame("PublishAck", &other)),
307    }
308}
309
310fn subscribe_response(frame: Frame) -> Result<(), SdkError> {
311    match frame {
312        Frame::SubscribeAck { .. } => Ok(()),
313        Frame::SubscribeError {
314            reason_code,
315            message,
316            ..
317        } => Err(SdkError::Protocol {
318            description: format!(
319                "server rejected subscribe (reason {reason_code}): {}",
320                message.unwrap_or_else(|| "no detail".to_string())
321            ),
322        }),
323        other => Err(unexpected_frame("SubscribeAck", &other)),
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn schema_ids_are_deterministic_and_distinct() {
333        assert_eq!(schema_id_from_bytes(b"a"), schema_id_from_bytes(b"a"));
334        assert_ne!(schema_id_from_bytes(b"a"), schema_id_from_bytes(b"b"));
335    }
336
337    #[test]
338    fn conversation_ids_are_stable() {
339        assert_eq!(conversation_wire_id("chat"), conversation_wire_id("chat"));
340        assert_ne!(conversation_wire_id("chat"), conversation_wire_id("other"));
341    }
342
343    #[test]
344    fn publish_ack_maps_to_accept() -> Result<(), SdkError> {
345        let frame = Frame::PublishAck {
346            flags: 0,
347            stream_id: 1,
348            message_id: 7,
349        };
350        assert_eq!(publish_response(frame)?, PressureResponse::Accept);
351        Ok(())
352    }
353
354    #[test]
355    fn publish_error_maps_to_backpressure() {
356        let frame = Frame::PublishError {
357            flags: 0,
358            stream_id: 1,
359            reason_code: 9,
360            message: Some("nope".to_string()),
361        };
362        assert!(matches!(
363            publish_response(frame),
364            Err(SdkError::Backpressure { .. })
365        ));
366    }
367}