Skip to main content

liminal_sdk/
remote.rs

1mod config;
2/// The byte-stream framing layer every real transport shares: one handshake,
3/// one partial-frame buffer, one conversation drain. It is generic over
4/// [`framing::FrameStream`] rather than duplicated per transport — the third
5/// parallel copy is what `docs/design/IN-PROCESS-TRANSPORT.md` §9 ruling 2
6/// refuses.
7#[cfg(feature = "std")]
8mod framing;
9mod handles;
10#[cfg(feature = "embedded")]
11mod loopback;
12/// The bounded mailbox every reader-thread stream hands its consumer.
13#[cfg(feature = "std")]
14mod mailbox;
15mod participant;
16mod protocol;
17#[cfg(all(feature = "std", unix))]
18mod read_wake;
19#[cfg(all(feature = "std", unix))]
20pub use read_wake::ReadWake;
21/// The one mapping from a publish reply frame to the SDK's publish result,
22/// shared by every byte-level transport.
23#[cfg(feature = "std")]
24mod publish_reply;
25#[cfg(feature = "std")]
26mod tcp;
27pub mod websocket;
28
29#[cfg(feature = "std")]
30pub use mailbox::{
31    DEFAULT_PUSH_MAILBOX_BYTES, DEFAULT_PUSH_MAILBOX_FRAMES, DEFAULT_SUBSCRIPTION_MAILBOX_BYTES,
32    DEFAULT_SUBSCRIPTION_MAILBOX_FRAMES, MailboxOverflow, MailboxStats,
33};
34#[cfg(feature = "std")]
35pub use tcp::{
36    DeliveredMessage, FlushMode, FlushOutcome, OBSERVABILITY_CHANNEL, PendingPushConnect,
37    PublishRejection, PushClient, PushCloseCause, PushReceiveOutcome, PushReceiver, PushWriter,
38    PushedFrame, SubscriptionStream, TcpRemoteTransport,
39};
40#[cfg(feature = "std")]
41pub use websocket::{
42    WebSocketDeliveredMessage, WebSocketRemoteTransport, WebSocketSubscriptionStream,
43};
44
45pub use config::{SdkConfig, build_channel_handle, build_conversation_handle};
46pub use handles::{
47    RemoteChannelHandle, RemoteConversationHandle, RemoteParticipantHandle, SdkChannelHandle,
48    SdkConversationHandle,
49};
50pub use participant::{
51    CredentialAttachReissueReason, LostCredentialAttachRefusalReason, PARTICIPANT_PUMP_WINDOW,
52    ParticipantPumpDiagnostics, ParticipantResponseProvenance, ParticipantResumeStore,
53    RecordAdmissionFate, RemoteCredentialAttachRecovery, RemoteDetachReplayOutcome,
54    RemoteExpectedOperationRecovery, RemoteLostOperationResolution, RemoteLostReconnectResolution,
55    RemoteOperationRecordOutcome, RemoteOperationTransportFate, RemoteParticipantError,
56    RemoteParticipantInbound, RemoteParticipantOperation, RemoteParticipantSendOutcome,
57    RemoteReconnectAttemptOutcome, RemoteReconnectPermit, RemoteReconnectPermitOutcome,
58    RemoteReconnectPermitRecovery, RemoteReplayApplyOutcome, RemoteTransportLossOutcome,
59};
60
61#[cfg(test)]
62mod tests;
63
64use alloc::string::{String, ToString};
65use alloc::sync::Arc;
66
67use crate::connection::ConnectionPoolConfig;
68use crate::{ConversationId, SdkError};
69
70use self::protocol::{ProtocolRemoteTransport, RemoteTransport};
71
72/// Shared transport I/O window, also available to transport-neutral participants.
73/// This bounds one read/write window; the std framing layer separately owns
74/// the total deadline for a reply-owed receive. Moving this value does not
75/// change either deadline.
76pub(crate) const IO_TIMEOUT: core::time::Duration = core::time::Duration::from_secs(5);
77/// How long a connected socket stays silent before the kernel probes its
78/// peer: the server's own value for the sockets it accepts.
79#[cfg(feature = "std")]
80pub(crate) const KEEPALIVE_IDLE: core::time::Duration = core::time::Duration::from_secs(30);
81/// The gap between unanswered keepalive probes, the server's value.
82#[cfg(feature = "std")]
83pub(crate) const KEEPALIVE_INTERVAL: core::time::Duration = core::time::Duration::from_secs(10);
84/// Unanswered probes before the kernel ends the connection, the server's value:
85/// a peer that is gone is found within 80 seconds (30 + 5 x 10).
86#[cfg(feature = "std")]
87pub(crate) const KEEPALIVE_RETRIES: u32 = 5;
88
89/// The one named deadline every reader gives a synchronous control-frame reply.
90///
91/// Five seconds — the estate's already-ratified value, generalized rather than
92/// re-chosen: the WebSocket socket layer and the TCP subscription reader both
93/// already read `Duration::from_secs(5)`, and the TCP push reader is the odd one
94/// out. Ruled 2026-07-28 by Waffles the Terrible, coordinator seat
95/// (PUSH-HANDSHAKE-DEADLINE; see `docs/design/WIRING-LEDGER.md` and
96/// `docs/design/sdk/briefs/SDK-010.json`).
97///
98/// It is armed for the control exchange ONLY — `Connect`/`ConnectAck`,
99/// `WorkerRegister`/`WorkerRegisterAck`, `Subscribe`/`SubscribeAck` — and
100/// disarmed with `set_read_timeout(None)` before any background reader starts.
101/// It MUST NOT survive into steady state: a deadline that outlives its exchange
102/// is just a slower cadence, and LAW-1 refuses cadences whatever their period.
103///
104/// What it replaces was never chosen. `connect_socket` armed a 100 ms reader
105/// poll cadence before the handshake, and the synchronous setup read was fatal
106/// on the first timeout — composing, by accident, into a 100 ms-per-read fatal
107/// deadline on connect. Nobody chose it.
108#[cfg(feature = "std")]
109pub(crate) const SETUP_TIMEOUT: core::time::Duration = core::time::Duration::from_secs(5);
110
111/// Application-level address for a remote liminal server.
112#[derive(Clone, Debug, PartialEq, Eq)]
113pub struct ServerAddress(String);
114
115impl ServerAddress {
116    /// Creates and validates a remote server address.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`SdkError`] when the supplied address is empty.
121    pub fn new(value: impl Into<String>) -> Result<Self, SdkError> {
122        let value = value.into();
123        if value.trim().is_empty() {
124            return Err(connection_error("remote mode requires a server address"));
125        }
126        Ok(Self(value))
127    }
128
129    /// Returns the server address string.
130    #[must_use]
131    pub fn as_str(&self) -> &str {
132        self.0.as_str()
133    }
134}
135
136/// Configuration for remote SDK handles.
137#[derive(Clone, Debug)]
138pub struct RemoteConfig {
139    /// Remote server address. Remote mode cannot be created without this value.
140    pub server_address: ServerAddress,
141    /// Application-visible channel name.
142    pub channel_name: String,
143    /// Application-visible conversation identifier.
144    pub conversation_id: ConversationId,
145    /// Caller/runtime-supplied connection pool configuration.
146    pub pool_config: ConnectionPoolConfig,
147    transport: Arc<dyn RemoteTransport>,
148    /// The concretely typed WebSocket transport, retained when
149    /// [`connect_websocket`](Self::connect_websocket) installed it so callers
150    /// can drive its typed reconnect path.
151    #[cfg(feature = "std")]
152    websocket: Option<Arc<websocket::WebSocketRemoteTransport>>,
153}
154
155impl RemoteConfig {
156    /// The transport's read wake, when it has one (the TCP transport on unix).
157    ///
158    /// The wake is the transport's, shared by every handle over it: it
159    /// interrupts budgeted participant reads (`receive_within`, and the fence
160    /// wait in `try_receive`) on any of them, and never the reply-owed
161    /// `receive` or the transport's own request/response exchanges. See
162    /// [`ReadWake`] for exactly which reads.
163    #[cfg(all(feature = "std", unix))]
164    #[must_use]
165    pub fn read_wake(&self) -> Option<ReadWake> {
166        self.transport.read_wake()
167    }
168
169    /// Creates remote configuration with a required server address and pool config.
170    ///
171    /// **The returned configuration is NOT connected.** It carries a placeholder
172    /// transport that owns no socket, and every publish, subscribe,
173    /// conversation, and resume operation performed through it refuses with
174    /// [`SdkError::NotConnected`]. Call `connect_tcp`, `connect_tcp_with_auth`,
175    /// `connect_websocket`, `connect_websocket_with_auth`, or (with the
176    /// `embedded` feature) `connect_loopback` to install a live transport
177    /// first.
178    ///
179    /// This is deliberate loudness. The placeholder used to encode each frame,
180    /// discard it, and return a synthesised `Accept`, so a caller who forgot to
181    /// connect saw nothing but green results for messages that reached no wire.
182    ///
183    /// # Errors
184    ///
185    /// Returns [`SdkError`] if the address or pool configuration is invalid.
186    pub fn new(
187        server_address: impl Into<String>,
188        channel_name: impl Into<String>,
189        conversation_id: impl Into<ConversationId>,
190        pool_config: ConnectionPoolConfig,
191    ) -> Result<Self, SdkError> {
192        Ok(Self {
193            server_address: ServerAddress::new(server_address)?,
194            channel_name: channel_name.into(),
195            conversation_id: conversation_id.into(),
196            pool_config: pool_config.validate()?,
197            transport: Arc::new(ProtocolRemoteTransport),
198            #[cfg(feature = "std")]
199            websocket: None,
200        })
201    }
202
203    /// Opens a real TCP connection to the configured server and installs the
204    /// live wire transport, replacing the in-process protocol transport.
205    ///
206    /// This performs the protocol handshake (`Connect` -> `ConnectAck`) eagerly,
207    /// so a returned configuration is already connected to the server. Subsequent
208    /// publish, subscribe, and conversation calls traverse the socket.
209    ///
210    /// # Errors
211    ///
212    /// Returns [`SdkError::Connection`] when the TCP connection cannot be
213    /// established and [`SdkError::Protocol`] when the handshake is rejected.
214    #[cfg(feature = "std")]
215    pub fn connect_tcp(mut self) -> Result<Self, SdkError> {
216        let transport = self::tcp::TcpRemoteTransport::connect(&self.server_address)?;
217        self.transport = Arc::new(transport);
218        self.websocket = None;
219        Ok(self)
220    }
221
222    /// Opens a real TCP connection whose handshake carries `auth_token`, for a
223    /// server gated by an `[auth]` section, and installs the live wire transport.
224    ///
225    /// Additive to [`connect_tcp`]: an empty token behaves identically to it. The
226    /// server compares the token during the handshake and closes the connection on
227    /// a mismatch, which surfaces here as [`SdkError::Connection`].
228    ///
229    /// # Errors
230    ///
231    /// Returns [`SdkError::Connection`] when the TCP connection cannot be
232    /// established or the token is rejected, and [`SdkError::Protocol`] when the
233    /// handshake frames cannot be encoded or sent.
234    ///
235    /// [`connect_tcp`]: Self::connect_tcp
236    #[cfg(feature = "std")]
237    pub fn connect_tcp_with_auth(mut self, auth_token: &[u8]) -> Result<Self, SdkError> {
238        let transport =
239            self::tcp::TcpRemoteTransport::connect_with_auth(&self.server_address, auth_token)?;
240        self.transport = Arc::new(transport);
241        self.websocket = None;
242        Ok(self)
243    }
244
245    /// Opens an in-process connection to `server` and installs the loopback
246    /// wire transport, replacing the in-process protocol transport.
247    ///
248    /// Same shape as [`connect_tcp`], same guarantee, different mount. This
249    /// performs the protocol handshake (`Connect` -> `ConnectAck`) eagerly
250    /// against a REAL server — the same admission slot pool, the same durable
251    /// connection incarnation, the same constant-time token compare, the same
252    /// frame preflight and participant gate — so a returned configuration is
253    /// already connected and every later call traverses the identical framed
254    /// wire image a socket would have carried. What it removes is the syscall,
255    /// the kernel copy, the descriptor lifecycle, and the round trip; what it
256    /// does not remove is any part of the record path.
257    ///
258    /// The mount is TRUSTED CODE. A co-resident caller already reaches the host
259    /// process's heap, descriptors and store handle without this transport, so
260    /// what the record vouches for here is that the append came through the
261    /// same door — never that its author was contained.
262    ///
263    /// `self.server_address` is untouched and stays a diagnostic label: nothing
264    /// on this path reads a socket fact, and the server's own record carries
265    /// `peer_addr: None` for the same reason.
266    ///
267    /// The server is taken as an [`Arc`] because the participant contract
268    /// includes reconnect, and a transport that can open a second connection
269    /// later must outlive the call that built it.
270    ///
271    /// # Errors
272    ///
273    /// Returns [`SdkError::Connection`] when the server refuses to admit the
274    /// connection — at `max_connections` this is the same refusal a socket
275    /// connect receives — and [`SdkError::Protocol`] when the handshake is
276    /// rejected or its frames cannot be encoded.
277    ///
278    /// [`connect_tcp`]: Self::connect_tcp
279    #[cfg(feature = "embedded")]
280    pub fn connect_loopback(
281        self,
282        server: Arc<liminal_server::server::embedded::EmbeddedServer>,
283    ) -> Result<Self, SdkError> {
284        self.connect_loopback_with_auth(server, &[])
285    }
286
287    /// Opens an in-process connection whose handshake carries `auth_token`, for
288    /// a server gated by an `[auth]` section, and installs the loopback wire
289    /// transport. Additive to [`connect_loopback`]: an empty token behaves
290    /// identically to it.
291    ///
292    /// **Admission is admission.** An embedded caller presenting the wrong
293    /// token is refused on its own loopback by the same `connect_response`
294    /// compare that refuses a socket caller, and the refusal surfaces here as
295    /// the same [`SdkError::Connection`] the socket path produces.
296    ///
297    /// # Errors
298    ///
299    /// Returns [`SdkError::Connection`] when the connection cannot be admitted
300    /// or the token is rejected, and [`SdkError::Protocol`] when the handshake
301    /// frames cannot be encoded or sent.
302    ///
303    /// [`connect_loopback`]: Self::connect_loopback
304    #[cfg(feature = "embedded")]
305    pub fn connect_loopback_with_auth(
306        mut self,
307        server: Arc<liminal_server::server::embedded::EmbeddedServer>,
308        auth_token: &[u8],
309    ) -> Result<Self, SdkError> {
310        let transport =
311            self::loopback::LoopbackRemoteTransport::connect_with_auth(server, auth_token)?;
312        self.transport = Arc::new(transport);
313        self.websocket = None;
314        Ok(self)
315    }
316
317    /// Opens a real WebSocket connection to the configured `ws://` server
318    /// address and installs the live wire transport, replacing the in-process
319    /// protocol transport.
320    ///
321    /// This performs the WebSocket upgrade and the protocol handshake
322    /// (`Connect` -> `ConnectAck`) eagerly through the client unit's typed
323    /// permit path, so a returned configuration is already connected.
324    /// Subsequent publish, subscribe, and conversation calls traverse the
325    /// socket; the concretely typed transport stays reachable through
326    /// [`websocket_transport`](Self::websocket_transport) for the typed
327    /// reconnect path.
328    ///
329    /// # Errors
330    ///
331    /// Returns [`SdkError::Connection`] when the address is not a usable
332    /// `ws://` URL, the connection cannot be established, or the handshake is
333    /// rejected, and [`SdkError::Protocol`] when frames cannot be encoded.
334    #[cfg(feature = "std")]
335    pub fn connect_websocket(self) -> Result<Self, SdkError> {
336        self.connect_websocket_with_auth(&[])
337    }
338
339    /// Opens a real WebSocket connection whose handshake carries
340    /// `auth_token`, for a server gated by an `[auth]` section, and installs
341    /// the live wire transport. Additive to [`connect_websocket`]: an empty
342    /// token behaves identically to it.
343    ///
344    /// # Errors
345    ///
346    /// Returns [`SdkError::Connection`] when the connection cannot be
347    /// established or the token is rejected, and [`SdkError::Protocol`] when
348    /// the handshake frames cannot be encoded or sent.
349    ///
350    /// [`connect_websocket`]: Self::connect_websocket
351    #[cfg(feature = "std")]
352    pub fn connect_websocket_with_auth(mut self, auth_token: &[u8]) -> Result<Self, SdkError> {
353        let transport = Arc::new(websocket::WebSocketRemoteTransport::connect_with_auth(
354            &self.server_address,
355            auth_token,
356        )?);
357        self.transport = Arc::clone(&transport) as Arc<dyn RemoteTransport>;
358        self.websocket = Some(transport);
359        Ok(self)
360    }
361
362    /// The concretely typed WebSocket transport installed by
363    /// [`connect_websocket`](Self::connect_websocket), when one is installed.
364    #[cfg(feature = "std")]
365    #[must_use]
366    pub fn websocket_transport(&self) -> Option<Arc<websocket::WebSocketRemoteTransport>> {
367        self.websocket.clone()
368    }
369}
370
371fn connection_error(description: &str) -> SdkError {
372    SdkError::Connection {
373        description: description.to_string(),
374    }
375}