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