liminal_sdk/remote/handles.rs
1use alloc::string::{String, ToString};
2use alloc::sync::Arc;
3use alloc::vec::Vec;
4
5use serde::Serialize;
6use serde::de::DeserializeOwned;
7use spin::Mutex;
8
9#[cfg(feature = "std")]
10use crate::DeliveryAck;
11use crate::connection::{
12 ConnectionEvent, ConnectionLifecycle, ConnectionPool, ConnectionState, ResumeRequest,
13 SubscriptionId,
14};
15use crate::embedded::{
16 EmbeddedChannelHandle, EmbeddedConversationHandle, EmptyLifecycleStream, ReadyResult,
17 SdkSubscription,
18};
19use crate::{
20 ChannelHandle, ConversationHandle, ConversationId, PressureResponse, SchemaValidate, SdkError,
21};
22
23pub use super::participant::RemoteParticipantHandle;
24
25use super::config::SdkConfig;
26use super::protocol::{
27 RemoteTransport, WireConversationRequest, WirePublishRequest, WireResumeRequest,
28 deserialize_payload,
29};
30use super::{RemoteConfig, ServerAddress};
31mod negotiated;
32
33/// The refusal typed `subscribe` returns instead of an instantly-dry stream.
34///
35/// No ledger row records this gap: `WIRING-LEDGER.md`'s W6 is the BROWSER
36/// conversation surface (a TypeScript transport lane), not the Rust typed
37/// subscription, and nothing else in `docs/` names it. The refusal says so
38/// rather than borrowing a row that does not cover it.
39fn unwired_typed_subscribe() -> SdkError {
40 SdkError::Unwired {
41 surface: "typed subscribe delivery (ChannelHandle::subscribe on a remote handle)"
42 .to_string(),
43 seam: "no ledger row records this gap; W6 is the browser conversation surface, not this"
44 .to_string(),
45 alternative: "for real delivery use SubscriptionStream::open plus \
46 SubscriptionStream::recv_timeout, or \
47 WebSocketSubscriptionStream::open on the WebSocket transport"
48 .to_string(),
49 }
50}
51
52#[derive(Debug)]
53struct RemoteChannelState {
54 lifecycle: ConnectionLifecycle,
55 pool: ConnectionPool,
56 next_subscription: u64,
57}
58
59/// Channel handle that communicates through SDK-internal wire protocol transport.
60///
61/// The handle inherits whatever transport its [`RemoteConfig`] carries. A config
62/// straight out of `RemoteConfig::new` has never been connected, so every
63/// operation on a handle built from it refuses with
64/// [`SdkError::NotConnected`] rather than reporting a success that reached no
65/// wire. Connect the config first.
66#[derive(Clone, Debug)]
67pub struct RemoteChannelHandle {
68 server_address: ServerAddress,
69 channel_name: String,
70 state: Arc<Mutex<RemoteChannelState>>,
71 transport: Arc<dyn RemoteTransport>,
72}
73
74impl RemoteChannelHandle {
75 /// Creates a remote channel handle from validated configuration.
76 ///
77 /// # Errors
78 ///
79 /// Returns [`SdkError`] if the connection pool cannot be created.
80 pub fn new(config: &RemoteConfig) -> Result<Self, SdkError> {
81 Ok(Self {
82 server_address: config.server_address.clone(),
83 channel_name: config.channel_name.clone(),
84 state: Arc::new(Mutex::new(RemoteChannelState {
85 lifecycle: ConnectionLifecycle::new(),
86 pool: ConnectionPool::new(config.pool_config)?,
87 next_subscription: 0,
88 })),
89 transport: Arc::clone(&config.transport),
90 })
91 }
92
93 /// Returns current lifecycle state from the SDK-003 state machine.
94 #[must_use]
95 pub fn connection_state(&self) -> ConnectionState {
96 self.state.lock().lifecycle.state().clone()
97 }
98
99 /// Records that an externally authorized real reconnect attempt started.
100 ///
101 /// # Errors
102 ///
103 /// Returns [`SdkError`] when the lifecycle rejects the transition.
104 pub fn reconnect_started(&self) -> Result<(), SdkError> {
105 self.state.lock().lifecycle.reconnect_started()
106 }
107
108 /// Marks the remote channel connected and builds subscription resume requests.
109 ///
110 /// # Errors
111 ///
112 /// Returns [`SdkError`] if lifecycle transition or recovery state is invalid.
113 pub fn connected(&self) -> Result<Vec<ResumeRequest>, SdkError> {
114 // Compute the resume requests under the lock, then release it before any
115 // transport I/O so concurrent callers do not spin-wait during resume calls.
116 let requests = {
117 let mut state = self.state.lock();
118 let previous = state.lifecycle.state().clone();
119 state.lifecycle.connected()?;
120 let event = ConnectionEvent::new(previous, state.lifecycle.state().clone());
121 state.pool.resume_requests_for_transition(&event)?
122 };
123 for request in &requests {
124 let wire_request = WireResumeRequest::new(*request);
125 self.transport.resume(&self.server_address, &wire_request)?;
126 }
127 Ok(requests)
128 }
129
130 /// Marks a subscription active and assigns it to the configured pool.
131 ///
132 /// # Errors
133 ///
134 /// Returns [`SdkError`] if a subscription id overflows or pool assignment fails.
135 pub fn track_subscription(&self) -> Result<SubscriptionId, SdkError> {
136 let mut state = self.state.lock();
137 let id = SubscriptionId::new(state.next_subscription);
138 state.next_subscription =
139 state
140 .next_subscription
141 .checked_add(1)
142 .ok_or_else(|| SdkError::Store {
143 description: "subscription id overflow".to_string(),
144 })?;
145 state.pool.assign_subscription(id)?;
146 Ok(id)
147 }
148
149 /// Records an acknowledged sequence for subscription recovery.
150 ///
151 /// # Errors
152 ///
153 /// Returns [`SdkError`] if the subscription is not active in the pool.
154 pub fn acknowledge(
155 &self,
156 subscription_id: SubscriptionId,
157 sequence: u64,
158 ) -> Result<(), SdkError> {
159 let mut state = self.state.lock();
160 state.pool.acknowledge(subscription_id, sequence)
161 }
162
163 /// Returns remote server address used by this handle.
164 #[must_use]
165 pub const fn server_address(&self) -> &ServerAddress {
166 &self.server_address
167 }
168}
169
170impl ChannelHandle for RemoteChannelHandle {
171 type Subscription<M>
172 = SdkSubscription<M>
173 where
174 M: DeserializeOwned;
175
176 type ReplyFuture<'a, Resp>
177 = ReadyResult<Resp>
178 where
179 Self: 'a,
180 Resp: DeserializeOwned + 'a;
181
182 fn publish<M>(&self, message: M) -> Result<PressureResponse, SdkError>
183 where
184 M: Serialize + SchemaValidate,
185 {
186 let request = WirePublishRequest::new(&self.channel_name, &message)?;
187 self.transport.publish(&self.server_address, &request)
188 }
189
190 /// REFUSES. Typed subscription delivery is not implemented on this handle.
191 ///
192 /// The returned stream yields exactly one item — `Err(SdkError::Unwired)` —
193 /// and then ends. It does not touch the transport, so no `Subscribe` is left
194 /// outstanding on the server for a stream that could never deliver.
195 ///
196 /// What this replaces was worse than unimplemented. The old body performed
197 /// the real wire `Subscribe`, registered a subscription in the pool, and
198 /// then returned `SdkSubscription::empty()` — a stream whose `poll_next` is
199 /// `Ready(None)` immediately and forever. A caller's
200 /// `while let Some(m) = sub.next().await` exited on the first poll while the
201 /// server pumped `Deliver` frames at a connection nothing was reading.
202 ///
203 /// For real delivery use the lower-level surface that works today:
204 /// `SubscriptionStream::open` plus `SubscriptionStream::recv_timeout` (or
205 /// `WebSocketSubscriptionStream::open` on the WebSocket transport).
206 fn subscribe<M>(&self) -> Self::Subscription<M>
207 where
208 M: DeserializeOwned,
209 {
210 SdkSubscription::error(unwired_typed_subscribe())
211 }
212
213 fn request_reply<Req, Resp>(&self, request: Req) -> ReadyResult<Resp>
214 where
215 Req: Serialize + SchemaValidate,
216 Resp: DeserializeOwned,
217 {
218 ReadyResult::new(self.request_reply_blocking(&request))
219 }
220}
221
222impl RemoteChannelHandle {
223 /// Publishes a message with an idempotency key and returns a genuine delivery
224 /// ack.
225 ///
226 /// The idempotency key drives dedup-on-delivery on the server: a re-publish of
227 /// the same key is delivered to subscribers at most once. The returned
228 /// [`DeliveryAck`] reports whether this publish was genuinely accepted by a
229 /// subscriber ([`DeliveryAck::is_accepted`]), which a caller such as the aion
230 /// outbox uses to treat the send as done only on real acceptance. This is
231 /// distinct from the backpressure-only [`publish`](ChannelHandle::publish).
232 ///
233 /// # Errors
234 ///
235 /// Returns [`SdkError`] when the message cannot be serialized, the round trip
236 /// fails, or the transport cannot produce a genuine delivery ack.
237 ///
238 /// Only with std: without it no transport could carry the key, so no keyed
239 /// publish is offered rather than one that drops the key (Hermes 7 Oct 08:32).
240 #[cfg(feature = "std")]
241 pub fn publish_with_idempotency_key<M>(
242 &self,
243 message: &M,
244 idempotency_key: &str,
245 ) -> Result<DeliveryAck, SdkError>
246 where
247 M: Serialize + SchemaValidate,
248 {
249 let request =
250 WirePublishRequest::with_idempotency_key(&self.channel_name, message, idempotency_key)?;
251 self.transport
252 .publish_with_delivery(&self.server_address, &request)
253 }
254
255 /// Publishes `message` as the reply to `request` and returns a genuine
256 /// delivery ack (Fable 4c).
257 ///
258 /// `request` is the id a delivered message carried
259 /// ([`DeliveredMessage::message`](crate::DeliveredMessage::message)): the
260 /// reply names it as its causal parent, `<boot>:<id>`, so the server's tap
261 /// can pair the two and measure the dwell between them. A request that
262 /// carried no id (a server before protocol 1.2, or a durable message
263 /// replayed from the store) gives a reply with no parent, never an
264 /// invented one.
265 ///
266 /// # Errors
267 ///
268 /// Returns [`SdkError`] when the message cannot be serialized, the round trip
269 /// fails, or the transport cannot produce a genuine delivery ack.
270 #[cfg(feature = "std")]
271 pub fn reply_to<M>(
272 &self,
273 request: Option<liminal::protocol::DeliveredId>,
274 message: &M,
275 ) -> Result<DeliveryAck, SdkError>
276 where
277 M: Serialize + SchemaValidate,
278 {
279 let reply = WirePublishRequest::reply(&self.channel_name, message, request)?;
280 self.transport
281 .publish_with_delivery(&self.server_address, &reply)
282 }
283
284 /// Performs a correlated request-reply round trip over the transport and
285 /// deserializes the reply.
286 ///
287 /// The round trip rides the conversation request-reply path: the channel name
288 /// is used as the conversation correlation id and subject, so the reply the
289 /// server returns is matched back to this request by `conversation_id` on the
290 /// single synchronous connection. Schema validation still runs on the request
291 /// so a request-reply enforces the same typing contract as `publish`.
292 ///
293 /// # Errors
294 ///
295 /// Returns [`SdkError`] when the request cannot be serialized, the round trip
296 /// fails, or the reply cannot be deserialized into `Resp`.
297 fn request_reply_blocking<Req, Resp>(&self, request: &Req) -> Result<Resp, SdkError>
298 where
299 Req: Serialize + SchemaValidate,
300 Resp: DeserializeOwned,
301 {
302 let conversation_id = ConversationId::new(self.channel_name.clone());
303 let wire_request = WireConversationRequest::new(&conversation_id, request)?;
304 let reply = self
305 .transport
306 .request_reply_conversation(&self.server_address, &wire_request)?;
307 deserialize_payload(&reply)
308 }
309}
310
311/// Conversation handle that communicates through SDK-internal wire protocol transport.
312///
313/// As with [`RemoteChannelHandle`], a handle built from a config that was never
314/// connected refuses every send, request, and receive with
315/// [`SdkError::NotConnected`] instead of reporting a success that reached no
316/// wire.
317#[derive(Clone, Debug)]
318pub struct RemoteConversationHandle {
319 server_address: ServerAddress,
320 conversation_id: ConversationId,
321 lifecycle: Arc<Mutex<ConnectionLifecycle>>,
322 transport: Arc<dyn RemoteTransport>,
323 /// Buffered reply bytes from the most recent [`request`](Self::request) round
324 /// trip, drained by the next [`receive`](ConversationHandle::receive). The
325 /// synchronous transport completes the round trip inside `request`, so the
326 /// correlated reply is held here until the caller deserializes it.
327 pending_reply: Arc<Mutex<Option<Vec<u8>>>>,
328}
329
330impl RemoteConversationHandle {
331 /// Creates a remote conversation handle from validated configuration.
332 #[must_use]
333 pub fn new(config: &RemoteConfig) -> Self {
334 Self {
335 server_address: config.server_address.clone(),
336 conversation_id: config.conversation_id.clone(),
337 lifecycle: Arc::new(Mutex::new(ConnectionLifecycle::new())),
338 transport: Arc::clone(&config.transport),
339 pending_reply: Arc::new(Mutex::new(None)),
340 }
341 }
342
343 /// Sends a typed request on this conversation and blocks for its correlated
344 /// reply, buffering the reply for the next [`receive`](ConversationHandle::receive).
345 ///
346 /// This is the request leg of the conversation request-reply pattern. It is
347 /// kept distinct from [`send`](ConversationHandle::send), which stays
348 /// fire-and-forget (the server is silent on success): only `request` sets the
349 /// reply-requested flag and waits for the server's correlated answer. The aion
350 /// dispatch model (`send` request, then `receive` reply) maps onto a `request`
351 /// followed by `receive`.
352 ///
353 /// # Errors
354 ///
355 /// Returns [`SdkError`] when the request cannot be serialized or the round
356 /// trip fails.
357 pub fn request<Req>(&self, request: Req) -> Result<(), SdkError>
358 where
359 Req: Serialize,
360 {
361 let wire_request = WireConversationRequest::new(&self.conversation_id, &request)?;
362 let reply = self
363 .transport
364 .request_reply_conversation(&self.server_address, &wire_request)?;
365 *self.pending_reply.lock() = Some(reply);
366 Ok(())
367 }
368
369 /// Returns current lifecycle state from the SDK-003 state machine.
370 #[must_use]
371 pub fn connection_state(&self) -> ConnectionState {
372 self.lifecycle.lock().state().clone()
373 }
374
375 /// Returns remote server address used by this handle.
376 #[must_use]
377 pub const fn server_address(&self) -> &ServerAddress {
378 &self.server_address
379 }
380}
381
382impl ConversationHandle for RemoteConversationHandle {
383 type ReceiveFuture<'a, M>
384 = ReadyResult<M>
385 where
386 Self: 'a,
387 M: DeserializeOwned + 'a;
388
389 type LifecycleStream = EmptyLifecycleStream;
390
391 fn send<M>(&self, message: M) -> Result<(), SdkError>
392 where
393 M: Serialize,
394 {
395 let request = WireConversationRequest::new(&self.conversation_id, &message)?;
396 self.transport
397 .send_conversation(&self.server_address, &request)
398 }
399
400 fn receive<M>(&self) -> ReadyResult<M>
401 where
402 M: DeserializeOwned,
403 {
404 ReadyResult::new(self.receive_blocking())
405 }
406
407 fn lifecycle(&self) -> Self::LifecycleStream {
408 EmptyLifecycleStream
409 }
410}
411
412impl RemoteConversationHandle {
413 /// Drains and deserializes the correlated reply buffered by the most recent
414 /// [`request`](Self::request).
415 ///
416 /// # Errors
417 ///
418 /// Returns [`SdkError::Conversation`] when no reply is pending (no `request`
419 /// has completed since the last `receive`), or [`SdkError::Serialization`]
420 /// when the buffered reply cannot be deserialized into `M`.
421 fn receive_blocking<M>(&self) -> Result<M, SdkError>
422 where
423 M: DeserializeOwned,
424 {
425 let payload = self
426 .pending_reply
427 .lock()
428 .take()
429 .ok_or_else(|| SdkError::Conversation {
430 conversation_id: self.conversation_id.as_str().to_string(),
431 description: "no correlated reply is pending; call request before receive"
432 .to_string(),
433 })?;
434 deserialize_payload(&payload)
435 }
436}
437
438/// Runtime-selected channel handle that keeps deployment differences behind configuration.
439#[derive(Clone, Debug)]
440pub enum SdkChannelHandle {
441 /// Embedded direct in-process handle.
442 Embedded(EmbeddedChannelHandle),
443 /// Remote protocol-backed handle.
444 Remote(RemoteChannelHandle),
445}
446
447impl SdkChannelHandle {
448 /// Creates a channel handle selected by SDK configuration.
449 ///
450 /// # Errors
451 ///
452 /// Returns [`SdkError`] if the selected handle cannot be initialized.
453 pub fn new(config: &SdkConfig) -> Result<Self, SdkError> {
454 match config {
455 SdkConfig::Embedded(config) => Ok(Self::Embedded(EmbeddedChannelHandle::new(config))),
456 SdkConfig::Remote(config) => Ok(Self::Remote(RemoteChannelHandle::new(config)?)),
457 }
458 }
459}
460
461impl ChannelHandle for SdkChannelHandle {
462 type Subscription<M>
463 = SdkSubscription<M>
464 where
465 M: DeserializeOwned;
466
467 type ReplyFuture<'a, Resp>
468 = ReadyResult<Resp>
469 where
470 Self: 'a,
471 Resp: DeserializeOwned + 'a;
472
473 fn publish<M>(&self, message: M) -> Result<PressureResponse, SdkError>
474 where
475 M: Serialize + SchemaValidate,
476 {
477 match self {
478 Self::Embedded(handle) => handle.publish(message),
479 Self::Remote(handle) => handle.publish(message),
480 }
481 }
482
483 /// BOTH ARMS REFUSE. Typed subscription delivery is not implemented in
484 /// either deployment mode, so whichever mode configuration selected, the
485 /// returned stream yields exactly one `Err(SdkError::Unwired)` and then
486 /// ends.
487 ///
488 /// The remote arm's refusal names `SubscriptionStream::open` as the surface
489 /// that works. The embedded arm's names the same remote surface, because
490 /// embedded mode has no in-process delivery path to offer instead. That arm
491 /// used to return a locally empty stream — described here as "what embedded
492 /// mode has always meant", which was true of the implementation and
493 /// misleading to a caller: an empty stream is indistinguishable from a live
494 /// subscription on a quiet channel.
495 fn subscribe<M>(&self) -> Self::Subscription<M>
496 where
497 M: DeserializeOwned,
498 {
499 match self {
500 Self::Embedded(handle) => handle.subscribe(),
501 Self::Remote(handle) => handle.subscribe(),
502 }
503 }
504
505 fn request_reply<Req, Resp>(&self, request: Req) -> ReadyResult<Resp>
506 where
507 Req: Serialize + SchemaValidate,
508 Resp: DeserializeOwned,
509 {
510 match self {
511 Self::Embedded(handle) => handle.request_reply(request),
512 Self::Remote(handle) => handle.request_reply(request),
513 }
514 }
515}
516
517/// Runtime-selected conversation handle that keeps deployment differences behind configuration.
518#[derive(Clone, Debug)]
519pub enum SdkConversationHandle {
520 /// Embedded direct in-process handle.
521 Embedded(EmbeddedConversationHandle),
522 /// Remote protocol-backed handle.
523 Remote(RemoteConversationHandle),
524}
525
526impl SdkConversationHandle {
527 /// Creates a conversation handle selected by SDK configuration.
528 ///
529 /// # Errors
530 ///
531 /// Returns [`SdkError`] if the selected handle cannot be initialized.
532 pub fn new(config: &SdkConfig) -> Result<Self, SdkError> {
533 match config {
534 SdkConfig::Embedded(config) => {
535 Ok(Self::Embedded(EmbeddedConversationHandle::new(config)))
536 }
537 SdkConfig::Remote(config) => Ok(Self::Remote(RemoteConversationHandle::new(config))),
538 }
539 }
540}
541
542impl ConversationHandle for SdkConversationHandle {
543 type ReceiveFuture<'a, M>
544 = ReadyResult<M>
545 where
546 Self: 'a,
547 M: DeserializeOwned + 'a;
548
549 type LifecycleStream = EmptyLifecycleStream;
550
551 fn send<M>(&self, message: M) -> Result<(), SdkError>
552 where
553 M: Serialize,
554 {
555 match self {
556 Self::Embedded(handle) => handle.send(message),
557 Self::Remote(handle) => handle.send(message),
558 }
559 }
560
561 fn receive<M>(&self) -> ReadyResult<M>
562 where
563 M: DeserializeOwned,
564 {
565 match self {
566 Self::Embedded(handle) => handle.receive(),
567 Self::Remote(handle) => handle.receive(),
568 }
569 }
570
571 fn lifecycle(&self) -> Self::LifecycleStream {
572 EmptyLifecycleStream
573 }
574}