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