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