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