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