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.
15//!
16//! A connection has two guards, both parking (`std::sync::Mutex`, never a spin
17//! lock): the read side (the frame buffer and every read) and the write side (a
18//! clone of the socket every frame is written through). A participant send
19//! takes the write side alone, so it reaches the server while another thread
20//! is parked in a participant read on the same connection (SDK-023 R1). Every
21//! other call takes the read side and then the write side for its whole
22//! exchange, so two writers never interleave a frame and two readers never
23//! share a frame stream.
24
25mod flush;
26/// The canonical participant framing, reached by every byte-level transport
27/// through [`super::framing`] — hence `pub(in crate::remote)` rather than
28/// private to `tcp`.
29pub(in crate::remote) mod participant;
30mod pump_diagnostics;
31mod push_client;
32mod subscription;
33
34pub use flush::{FlushMode, FlushOutcome, PublishRejection};
35#[cfg(test)]
36pub(in crate::remote) use push_client::connect_socket_for_tests as connect_push_socket_for_tests;
37pub use push_client::{
38    OBSERVABILITY_CHANNEL, PendingPushConnect, PushClient, PushCloseCause, PushReceiveOutcome,
39    PushReceiver, PushWriter, PushedFrame,
40};
41pub use subscription::{DeliveredMessage, SubscriptionStream};
42
43use alloc::format;
44use alloc::string::{String, ToString};
45use alloc::sync::Arc;
46use alloc::vec::Vec;
47use core::fmt;
48use core::time::Duration;
49use std::net::TcpStream;
50
51use liminal::protocol::{
52    CausalContext, Frame, MessageEnvelope, PUBLISH_IDEMPOTENCY_KEY_FLAG, ProtocolVersion, SchemaId,
53};
54use std::sync::{Mutex, MutexGuard, PoisonError};
55
56use crate::{DeliveryAck, PressureResponse, SdkError};
57
58use super::ServerAddress;
59use super::framing::{Connection, unexpected_frame, write_frame};
60use super::participant::ParticipantResponseProvenance;
61use super::protocol::{
62    ParticipantRemoteTransport, ParticipantTransportFrame, RemoteTransport,
63    WireConversationRequest, WirePublishRequest, WireResumeRequest, WireSubscribeRequest,
64};
65use super::publish_reply;
66
67/// Application stream id used for non-subscription application frames.
68const APPLICATION_STREAM_ID: u32 = 1;
69/// In-flight credit advertised on subscribe; one keeps strict pacing.
70const DEFAULT_MAX_IN_FLIGHT: u32 = 1;
71/// Schema id used for payloads whose schema is not carried on the wire.
72const SCHEMALESS_SCHEMA: &[u8] = &[];
73
74/// The write side: every frame on the connection is written through this
75/// socket clone with this guard held.
76struct WriterSlot {
77    stream: TcpStream,
78    provenance: ParticipantResponseProvenance,
79}
80
81/// A guard whose holder panicked is still the connection's state: the spin
82/// lock this replaced had no poisoning, and every write and read below leaves
83/// the slot consistent between its own statements.
84fn held<T>(guard: &Mutex<T>) -> MutexGuard<'_, T> {
85    guard.lock().unwrap_or_else(PoisonError::into_inner)
86}
87
88struct ConnectionSlot {
89    connection: Connection<TcpStream>,
90    provenance: ParticipantResponseProvenance,
91    next_attempt_id: u64,
92    next_connection_id: u64,
93}
94
95/// Real TCP transport that exchanges canonical wire frames with a liminal server.
96pub struct TcpRemoteTransport {
97    connection: Arc<Mutex<ConnectionSlot>>,
98    writer: Arc<Mutex<WriterSlot>>,
99    #[cfg(unix)]
100    read_wake: super::ReadWake,
101    address: String,
102    auth_token: Vec<u8>,
103}
104
105impl fmt::Debug for TcpRemoteTransport {
106    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
107        formatter
108            .debug_struct("TcpRemoteTransport")
109            .finish_non_exhaustive()
110    }
111}
112
113impl TcpRemoteTransport {
114    /// Connects to `server_address`, completes the handshake, and returns a ready transport.
115    ///
116    /// # Errors
117    ///
118    /// Returns [`SdkError::Connection`] when the TCP connection cannot be
119    /// established, and [`SdkError::Protocol`] when the handshake frames cannot be
120    /// encoded, sent, or are rejected by the server.
121    pub fn connect(server_address: &ServerAddress) -> Result<Self, SdkError> {
122        Self::connect_with_auth(server_address, &[])
123    }
124
125    /// Connects and handshakes carrying `auth_token`, for a server gated by an
126    /// `[auth]` section. Additive to [`connect`]; an empty token is equivalent to it.
127    ///
128    /// # Errors
129    ///
130    /// Returns [`SdkError::Connection`] when the TCP connection cannot be
131    /// established or the server rejects the token (a `ConnectError` closes the
132    /// socket), and [`SdkError::Protocol`] when the handshake frames cannot be
133    /// encoded or sent.
134    ///
135    /// [`connect`]: Self::connect
136    pub fn connect_with_auth(
137        server_address: &ServerAddress,
138        auth_token: &[u8],
139    ) -> Result<Self, SdkError> {
140        let address = server_address.as_str().to_string();
141        let connection = Connection::connect_with_auth(&address, auth_token)?;
142        #[cfg(unix)]
143        let read_wake = super::ReadWake::new()?;
144        #[cfg(unix)]
145        let connection = {
146            let mut connection = connection;
147            connection.wake_idle_reads_through(read_wake.clone());
148            connection
149        };
150        let provenance = ParticipantResponseProvenance::new(1, 1);
151        let writer = WriterSlot {
152            stream: connection.write_half()?,
153            provenance,
154        };
155        Ok(Self {
156            #[cfg(unix)]
157            read_wake,
158            writer: Arc::new(Mutex::new(writer)),
159            connection: Arc::new(Mutex::new(ConnectionSlot {
160                connection,
161                provenance,
162                next_attempt_id: 2,
163                next_connection_id: 2,
164            })),
165            address,
166            auth_token: auth_token.to_vec(),
167        })
168    }
169
170    fn round_trip(&self, request: &Frame) -> Result<Frame, SdkError> {
171        let (mut slot, _writer) = self.exclusive();
172        slot.connection.round_trip(request)
173    }
174
175    /// The read side and then the write side, for a call that writes and reads
176    /// in one exchange. Always in this order, so no two calls deadlock.
177    fn exclusive(&self) -> (MutexGuard<'_, ConnectionSlot>, MutexGuard<'_, WriterSlot>) {
178        let slot = held(&self.connection);
179        let writer = held(&self.writer);
180        (slot, writer)
181    }
182}
183
184impl ParticipantRemoteTransport for TcpRemoteTransport {
185    fn send_participant(
186        &self,
187        _server_address: &ServerAddress,
188        request: &liminal_protocol::wire::ClientRequest,
189    ) -> Result<ParticipantResponseProvenance, SdkError> {
190        let mut writer = held(&self.writer);
191        write_frame(&mut writer.stream, &participant::request_frame(request)?)?;
192        Ok(writer.provenance)
193    }
194
195    fn receive_participant(
196        &self,
197        _server_address: &ServerAddress,
198    ) -> Result<ParticipantTransportFrame, SdkError> {
199        let mut slot = held(&self.connection);
200        let frame = slot.connection.receive_participant()?;
201        Ok(ParticipantTransportFrame {
202            frame,
203            provenance: slot.provenance,
204        })
205    }
206
207    fn receive_participant_within(
208        &self,
209        _server_address: &ServerAddress,
210        budget: Duration,
211    ) -> Result<Option<ParticipantTransportFrame>, SdkError> {
212        let mut slot = held(&self.connection);
213        let Some(frame) = slot.connection.receive_participant_within(budget)? else {
214            return Ok(None);
215        };
216        Ok(Some(ParticipantTransportFrame {
217            frame,
218            provenance: slot.provenance,
219        }))
220    }
221
222    fn try_receive_participant(&self) -> Result<Option<ParticipantTransportFrame>, SdkError> {
223        let (frame, provenance) = {
224            let mut slot = held(&self.connection);
225            (slot.connection.try_receive_participant()?, slot.provenance)
226        };
227        Ok(frame.map(|frame| ParticipantTransportFrame { frame, provenance }))
228    }
229
230    fn reconnect_participant(
231        &self,
232        _server_address: &ServerAddress,
233    ) -> Result<ParticipantResponseProvenance, SdkError> {
234        let (mut slot, mut writer) = self.exclusive();
235        let attempt_id = slot.next_attempt_id;
236        slot.next_attempt_id =
237            slot.next_attempt_id
238                .checked_add(1)
239                .ok_or_else(|| SdkError::Connection {
240                    description: "participant transport attempt identity exhausted".to_string(),
241                })?;
242        let connection = Connection::connect_with_auth(&self.address, &self.auth_token)?;
243        #[cfg(unix)]
244        let connection = {
245            let mut connection = connection;
246            connection.wake_idle_reads_through(self.read_wake.clone());
247            connection
248        };
249        let connection_id = slot.next_connection_id;
250        slot.next_connection_id =
251            slot.next_connection_id
252                .checked_add(1)
253                .ok_or_else(|| SdkError::Connection {
254                    description: "participant transport connection identity exhausted".to_string(),
255                })?;
256        let provenance = ParticipantResponseProvenance::new(connection_id, attempt_id);
257        writer.stream = connection.write_half()?;
258        writer.provenance = provenance;
259        slot.connection = connection;
260        slot.provenance = provenance;
261        Ok(provenance)
262    }
263}
264
265impl RemoteTransport for TcpRemoteTransport {
266    #[cfg(unix)]
267    fn read_wake(&self) -> Option<super::ReadWake> {
268        Some(self.read_wake.clone())
269    }
270
271    fn publish(
272        &self,
273        _server_address: &ServerAddress,
274        request: &WirePublishRequest,
275    ) -> Result<PressureResponse, SdkError> {
276        let frame = build_publish_frame(request);
277        let response = self.round_trip(&frame)?;
278        publish_response(response)
279    }
280
281    fn publish_with_delivery(
282        &self,
283        _server_address: &ServerAddress,
284        request: &WirePublishRequest,
285    ) -> Result<DeliveryAck, SdkError> {
286        let frame = build_publish_frame(request);
287        let response = self.round_trip(&frame)?;
288        publish_delivery_response(response)
289    }
290
291    /// Subscribes over the shared request/response connection.
292    ///
293    /// # v1 caveat — pooled subscribe registers a delivering subscriber
294    ///
295    /// This registers a *real* server-side subscriber on the shared pool
296    /// connection, which is what lets a subsequent keyed publish observe a genuine
297    /// delivery ack ([`PUBLISH_DELIVERED_FLAG`](liminal::protocol::PUBLISH_DELIVERED_FLAG)).
298    /// The server then pumps a `Deliver` frame for every message on the channel onto
299    /// this connection. Because the connection only reads (and discards) those
300    /// frames during a round trip, an application that subscribes for the ack signal
301    /// and then goes idle on a busy channel lets the server's bounded outbound buffer
302    /// (default 4 MiB) fill; on overflow the server tears the connection down, and
303    /// every later request on this transport then fails through no fault of the
304    /// caller. An actively-used transport is self-limiting (each round trip drains
305    /// the backlog), but a subscribe-then-idle client on a hot channel is at risk.
306    ///
307    /// v1 guidance: consume channel deliveries through a dedicated
308    /// [`SubscriptionStream`] (its own connection with a background reader), and use
309    /// the pooled subscribe only as the delivery-ack signal alongside regular
310    /// traffic. The v2 credit mode removes this by gating and multiplexing delivery.
311    fn subscribe(
312        &self,
313        _server_address: &ServerAddress,
314        request: &WireSubscribeRequest,
315    ) -> Result<(), SdkError> {
316        let frame = Frame::Subscribe {
317            flags: 0,
318            stream_id: request.stream_id(),
319            channel: request.channel().to_string(),
320            // An empty accepted-schema list lets the server select the channel's
321            // configured schema, mirroring the server's negotiation contract.
322            accepted_schemas: Vec::new(),
323            max_in_flight: DEFAULT_MAX_IN_FLIGHT,
324        };
325        let response = self.round_trip(&frame)?;
326        subscribe_response(response)
327    }
328
329    fn send_conversation(
330        &self,
331        _server_address: &ServerAddress,
332        request: &WireConversationRequest,
333    ) -> Result<(), SdkError> {
334        let conversation_label = request.conversation_id().as_str();
335        let conversation_id = conversation_wire_id(conversation_label);
336        let envelope = build_envelope(SCHEMALESS_SCHEMA, request.payload());
337        let (mut slot, _writer) = self.exclusive();
338        slot.connection
339            .send_conversation_message(conversation_id, conversation_label, envelope)
340    }
341
342    fn request_reply_conversation(
343        &self,
344        _server_address: &ServerAddress,
345        request: &WireConversationRequest,
346    ) -> Result<Vec<u8>, SdkError> {
347        let conversation_label = request.conversation_id().as_str();
348        let conversation_id = conversation_wire_id(conversation_label);
349        let envelope = build_envelope(SCHEMALESS_SCHEMA, request.payload());
350        let (mut slot, _writer) = self.exclusive();
351        slot.connection
352            .conversation_request_reply(conversation_id, conversation_label, envelope)
353    }
354
355    fn resume(
356        &self,
357        _server_address: &ServerAddress,
358        request: &WireResumeRequest,
359    ) -> Result<(), SdkError> {
360        // The wire protocol has no resume frame — and no replay path behind one:
361        // the server's Subscribe is a pure live registration (no durable read on
362        // any path today; verified at the bytes in
363        // docs/design/wire-durability-gaps-20260831.md, G3). Re-subscribing on
364        // reconnect resumes LIVE delivery only; messages published while
365        // disconnected are not replayed to the wire. Actual replay-on-subscribe
366        // is the A1 resume lane's feature. Returning a clear error keeps the
367        // contract honest rather than reporting success while dropping the
368        // user's resume intent.
369        let _ = (request.subscription_id(), request.resume_from_sequence());
370        Err(SdkError::Protocol {
371            description:
372                "resume is not yet supported over the TCP transport; re-subscribe for live \
373                 delivery only; messages published while disconnected are not replayed"
374                    .to_string(),
375        })
376    }
377
378    fn negotiated_version(&self) -> Option<ProtocolVersion> {
379        held(&self.connection).connection.negotiated_version()
380    }
381}
382
383fn build_envelope(schema_bytes: &[u8], payload: &[u8]) -> MessageEnvelope {
384    MessageEnvelope::new(
385        schema_id_from_bytes(schema_bytes),
386        CausalContext::independent(),
387        payload.to_vec(),
388    )
389}
390
391/// Derives a stable 32-byte schema id from arbitrary schema bytes via FNV-1a.
392///
393/// The server selects the channel's configured schema on subscribe and stores the
394/// published envelope verbatim, so this id only needs to be deterministic, not a
395/// negotiated value.
396fn schema_id_from_bytes(schema_bytes: &[u8]) -> SchemaId {
397    SchemaId::from_schema_bytes(schema_bytes)
398}
399
400fn conversation_wire_id(conversation_id: &str) -> u64 {
401    fnv1a(conversation_id.as_bytes())
402}
403
404/// FNV-1a 64-bit hash, used only for deterministic wire-id derivation.
405fn fnv1a(bytes: &[u8]) -> u64 {
406    const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
407    const PRIME: u64 = 0x0000_0100_0000_01b3;
408    let mut hash = OFFSET_BASIS;
409    for byte in bytes {
410        hash ^= u64::from(*byte);
411        hash = hash.wrapping_mul(PRIME);
412    }
413    hash
414}
415
416/// Builds the wire `Publish` frame, attaching the idempotency key (and its flag)
417/// only when the request carries one so a no-key publish stays byte-identical to
418/// the pre-13-L1 layout.
419fn build_publish_frame(request: &WirePublishRequest) -> Frame {
420    let mut envelope = build_envelope(request.schema().schema.as_ref(), request.payload());
421    envelope.causal_context = request.causal_context();
422    let flags = match request.idempotency_key() {
423        Some(_) => PUBLISH_IDEMPOTENCY_KEY_FLAG,
424        None => 0,
425    };
426    Frame::Publish {
427        flags,
428        stream_id: APPLICATION_STREAM_ID,
429        channel: request.channel().to_string(),
430        envelope,
431        idempotency_key: request.idempotency_key().map(ToString::to_string),
432    }
433}
434
435fn publish_response(frame: Frame) -> Result<PressureResponse, SdkError> {
436    match frame {
437        Frame::PublishAck { .. } => Ok(PressureResponse::Accept),
438        Frame::PublishError {
439            reason_code,
440            message,
441            ..
442        } => Err(publish_reply::publish_error(reason_code, message)),
443        other => Err(unexpected_frame("PublishAck", &other)),
444    }
445}
446
447/// Maps a publish ack into a genuine delivery ack: the `PUBLISH_DELIVERED_FLAG`
448/// bit on the ack reports whether a subscriber actually received the message,
449/// and the ack's durable position, when it carries one, rides along.
450fn publish_delivery_response(frame: Frame) -> Result<DeliveryAck, SdkError> {
451    match frame {
452        Frame::PublishAck {
453            flags,
454            durable_position,
455            ..
456        } => Ok(publish_reply::delivery_ack(flags, durable_position)),
457        Frame::PublishError {
458            reason_code,
459            message,
460            ..
461        } => Err(publish_reply::publish_error(reason_code, message)),
462        other => Err(unexpected_frame("PublishAck", &other)),
463    }
464}
465
466fn subscribe_response(frame: Frame) -> Result<(), SdkError> {
467    match frame {
468        Frame::SubscribeAck { .. } => Ok(()),
469        Frame::SubscribeError {
470            reason_code,
471            message,
472            ..
473        } => Err(SdkError::Protocol {
474            description: format!(
475                "server rejected subscribe (reason {reason_code}): {}",
476                message.unwrap_or_else(|| "no detail".to_string())
477            ),
478        }),
479        other => Err(unexpected_frame("SubscribeAck", &other)),
480    }
481}
482
483#[cfg(all(test, unix))]
484#[path = "concurrency_tests.rs"]
485mod concurrency_tests;
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn schema_ids_are_deterministic_and_distinct() {
493        assert_eq!(schema_id_from_bytes(b"a"), schema_id_from_bytes(b"a"));
494        assert_ne!(schema_id_from_bytes(b"a"), schema_id_from_bytes(b"b"));
495    }
496
497    #[test]
498    fn conversation_ids_are_stable() {
499        assert_eq!(conversation_wire_id("chat"), conversation_wire_id("chat"));
500        assert_ne!(conversation_wire_id("chat"), conversation_wire_id("other"));
501    }
502
503    #[test]
504    fn publish_ack_maps_to_accept() -> Result<(), SdkError> {
505        let frame = Frame::PublishAck {
506            flags: 0,
507            stream_id: 1,
508            message_id: 7,
509            durable_position: None,
510        };
511        assert_eq!(publish_response(frame)?, PressureResponse::Accept);
512        Ok(())
513    }
514
515    #[test]
516    fn publish_ack_with_position_maps_to_accept() -> Result<(), SdkError> {
517        let frame = Frame::PublishAck {
518            flags: 0x02,
519            stream_id: 1,
520            message_id: 7,
521            durable_position: Some(7717),
522        };
523        assert_eq!(publish_response(frame.clone())?, PressureResponse::Accept);
524        let ack = publish_delivery_response(frame)?;
525        assert!(
526            !ack.is_accepted(),
527            "the position bit is not the delivered bit"
528        );
529        assert_eq!(ack.durable_position(), Some(7717));
530        Ok(())
531    }
532
533    #[test]
534    fn publish_unresolved_maps_to_publish_unresolved() {
535        let frame = Frame::PublishError {
536            flags: 0,
537            stream_id: 1,
538            reason_code: 0x0103,
539            message: Some("orders k1".into()),
540        };
541        assert!(matches!(
542            publish_response(frame),
543            Err(SdkError::PublishUnresolved { description }) if description == "orders k1"
544        ));
545    }
546
547    #[test]
548    fn publish_error_maps_to_backpressure() {
549        let frame = Frame::PublishError {
550            flags: 0,
551            stream_id: 1,
552            reason_code: 9,
553            message: Some("nope".to_string()),
554        };
555        assert!(matches!(
556            publish_response(frame),
557            Err(SdkError::Backpressure { .. })
558        ));
559    }
560}