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