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