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