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