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}