liminal_sdk/remote/participant.rs
1//! Remote participant state, durable client records, and typed transport outcomes.
2//!
3//! This module owns process mechanics only. Every participant lifecycle and
4//! correlation decision is delegated to `liminal-protocol`; the SDK stores the
5//! crate aggregate and its sealed one-use authorities without mirroring their
6//! rules.
7
8mod recovery;
9mod replay_apply;
10
11pub use recovery::{
12 CredentialAttachReissueReason, LostCredentialAttachRefusalReason,
13 RemoteCredentialAttachRecovery, RemoteDetachReplayOutcome, RemoteExpectedOperationRecovery,
14 RemoteLostOperationResolution, RemoteLostReconnectResolution, RemoteReconnectAttemptOutcome,
15 RemoteReconnectPermitRecovery, RemoteReplayApplyOutcome, RemoteTransportLossOutcome,
16};
17
18use alloc::sync::Arc;
19use core::fmt;
20use core::time::Duration;
21
22use liminal_protocol::client::{
23 ClientCorrelatedInboundDecision, ClientInboundDecision, ClientInboundRefusalReason,
24 ClientOperationRecordDecision, ClientOperationRecordRefusalReason, ClientParticipantAggregate,
25 ClientResponseCorrelation, ClientResumeRecord, ClientResumeRecordDecodeError,
26 ClientResumeRecordEncodeError, ClientResumeRestoreError, ExpectedOperationFateRefusalReason,
27 ExpectedOperationTransportFate, ExpectedParticipantOperation, ReconnectPermitDecision,
28 decide_correlated_inbound, decide_inbound, record_expected_operation_fate,
29 record_transport_fate,
30};
31use liminal_protocol::outcome::ReconnectDelayResult;
32use liminal_protocol::wire::{
33 ClientRequest, DeliverySeq, ParticipantFrame, ServerPush, ServerValue,
34};
35use spin::Mutex;
36
37use crate::SdkError;
38
39use super::protocol::{ParticipantTransportFrame, RemoteTransport};
40use super::{RemoteConfig, ServerAddress};
41
42/// Storage boundary for canonical `LPCR` client resume bytes.
43///
44/// Implementations must replace the previously committed bytes durably before
45/// returning `Ok(())`. The SDK calls this boundary after the protocol crate's
46/// commit seal and before releasing executable operation authority.
47pub trait ParticipantResumeStore: Send {
48 /// Durably replaces the stored canonical client resume record.
49 ///
50 /// # Errors
51 ///
52 /// Returns [`SdkError::Store`] when the bytes were not durably committed.
53 fn persist(&mut self, canonical_lpcr: &[u8]) -> Result<(), SdkError>;
54}
55
56/// Transport-layer testimony identifying the connection attempt that delivered a frame.
57///
58/// This is the sealed transport context anticipated by rationale 15 in
59/// `LP-CLIENT-GOAL`. It does not alter the wire format or relax the protocol
60/// crate's conservative `RecordAdmission` ambiguity.
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub struct ParticipantResponseProvenance {
63 connection_id: u64,
64 attempt_id: u64,
65}
66
67impl ParticipantResponseProvenance {
68 #[cfg(feature = "std")]
69 pub(super) const fn new(connection_id: u64, attempt_id: u64) -> Self {
70 Self {
71 connection_id,
72 attempt_id,
73 }
74 }
75
76 /// Returns the local identity of the established socket.
77 #[must_use]
78 pub const fn connection_id(self) -> u64 {
79 self.connection_id
80 }
81
82 /// Returns the local identity of the real connection attempt.
83 #[must_use]
84 pub const fn attempt_id(self) -> u64 {
85 self.attempt_id
86 }
87}
88
89/// Failure at the SDK participant state, codec, storage, or transport boundary.
90#[derive(Debug, thiserror::Error)]
91pub enum RemoteParticipantError {
92 /// A prior commit could not be persisted, so no aggregate authority remains reachable.
93 ///
94 /// `source` is the RETAINED cause: the exact [`SdkError`] the caller's own
95 /// store returned at the seam that bricked the handle. It travels on the
96 /// error itself, so a caller who merely CAUGHT this value -- through a `?`,
97 /// across a channel, out of a join -- can attribute the hold by matching a
98 /// typed value rather than parsing rendered text. It is also this error's
99 /// [`source`](core::error::Error::source).
100 ///
101 /// A `None` means the aggregate is unreachable for a reason that did not
102 /// originate in the store. That discrimination is why the field is an
103 /// `Option` and not an `SdkError`: there is no store failure to name, so
104 /// [`source`](core::error::Error::source) reports `None` rather than
105 /// inventing a link, and the rendered message drops the durability clause it
106 /// would otherwise be asserting without evidence.
107 #[error(
108 "participant state is unavailable{}",
109 source.as_ref().map_or("", |_| " after an unreleased durability failure")
110 )]
111 StateUnavailable {
112 /// The store failure that made the aggregate unreachable, when that is why.
113 #[source]
114 source: Option<SdkError>,
115 },
116 /// The protocol crate could not encode the current aggregate as canonical LPCR.
117 #[error("client resume record encode failed: {0:?}")]
118 ResumeEncode(ClientResumeRecordEncodeError),
119 /// Persisted bytes were not a canonical LPCR record.
120 #[error("client resume record decode failed: {0:?}")]
121 ResumeDecode(ClientResumeRecordDecodeError),
122 /// Canonical facts violated a protocol restore invariant.
123 #[error("client resume record restore failed: {0:?}")]
124 ResumeRestore(ClientResumeRestoreError),
125 /// The caller-owned durable store rejected a canonical record.
126 ///
127 /// The `SdkError` is exposed as this error's [`source`](core::error::Error::source)
128 /// so a caller can walk to the typed store failure instead of matching on
129 /// rendered text.
130 #[error("client resume record persistence failed: {0}")]
131 Storage(#[source] SdkError),
132 /// The real transport failed outside a typed fate-reporting operation.
133 ///
134 /// The `SdkError` is exposed as this error's [`source`](core::error::Error::source),
135 /// for the same reason as [`Storage`](RemoteParticipantError::Storage).
136 #[error("participant transport failed: {0}")]
137 Transport(#[source] SdkError),
138 /// A client-to-server request appeared on the SDK receive side.
139 #[error("participant transport decoded a request in the client receive direction")]
140 InvalidInboundDirection,
141 /// No live response correlation exists for a replay-specific input.
142 #[error("no live participant response authority is held")]
143 ResponseAuthorityUnavailable,
144}
145
146/// Opaque one-use operation released only after the SDK persisted sealed LPCR bytes.
147#[derive(Debug)]
148pub struct RemoteParticipantOperation {
149 operation: ExpectedParticipantOperation,
150 durability: OperationDurability,
151}
152
153#[derive(Clone, Copy, Debug, PartialEq, Eq)]
154enum OperationDurability {
155 WriteAhead,
156 Continuous,
157}
158
159/// Result of admitting an outbound request through the crate write-ahead barrier.
160#[derive(Debug)]
161pub enum RemoteOperationRecordOutcome {
162 /// Canonical LPCR bytes were persisted and one operation may now be sent.
163 Recorded(RemoteParticipantOperation),
164 /// A continuous acknowledgement bypassed the write-ahead slot by crate rule.
165 Continuous(RemoteParticipantOperation),
166 /// The crate refused the exact request without changing aggregate state.
167 Refused {
168 /// Exact refused request.
169 request: ClientRequest,
170 /// Closed protocol refusal reason.
171 reason: ClientOperationRecordRefusalReason,
172 },
173}
174
175/// Typed operation-domain consequence of an established transport loss.
176#[derive(Debug, PartialEq, Eq)]
177pub enum RemoteOperationTransportFate {
178 /// A non-detach operation's response became unavailable.
179 Recorded {
180 /// Exact terminalized request.
181 request: ClientRequest,
182 },
183 /// The exact detach was returned to parked replay.
184 DetachParked,
185 /// The crate retained the live correlation unchanged.
186 Refused {
187 /// Closed refusal reason from the generic operation-fate gate.
188 reason: ExpectedOperationFateRefusalReason,
189 },
190 /// No response authority was outstanding.
191 NotOutstanding,
192}
193
194/// Typed reconnect permit returned by a crate-authorized fresh event.
195#[derive(Debug)]
196pub struct RemoteReconnectPermit {
197 pub(super) permit: liminal_protocol::client::ReconnectAttemptPermit,
198}
199
200/// Event-driven reconnect permit decision; there is no delay or timer arm.
201#[derive(Debug)]
202pub enum RemoteReconnectPermitOutcome {
203 /// The crate minted one one-use permit.
204 Permitted {
205 /// Opaque permit for one real connection attempt.
206 permit: RemoteReconnectPermit,
207 /// Legacy-named crate result whose value is event-only.
208 result: ReconnectDelayResult,
209 },
210 /// Existing authority was retained.
211 Refused {
212 /// Closed crate refusal reason.
213 reason: liminal_protocol::client::ReconnectPermitRefusalReason,
214 /// Event-only crate result.
215 result: ReconnectDelayResult,
216 },
217}
218
219/// Result of sending an operation on the participant transport.
220#[derive(Debug)]
221pub enum RemoteParticipantSendOutcome {
222 /// The request bytes were written on this connection attempt.
223 Sent {
224 /// Sealed transport context for a later response.
225 provenance: ParticipantResponseProvenance,
226 },
227 /// The write failed and both operation and reconnect fates were delegated.
228 TransportLost {
229 /// Concrete socket failure.
230 error: SdkError,
231 /// Crate-owned operation-fate result.
232 operation_fate: RemoteOperationTransportFate,
233 /// Crate-owned reconnect permit result.
234 reconnect: RemoteReconnectPermitOutcome,
235 },
236}
237
238/// Default quiet window for [`RemoteParticipantHandle::try_receive`].
239///
240/// Deliberately ONE steady-state transport receive window rather than a new
241/// number of its own: that is the grain both transports already poll on, and it
242/// is the shape consumers' drain loops were written against before a total
243/// response deadline was layered above it. A pump that reports quiet after one
244/// closed window is the behaviour they expect; the deadline above it is the
245/// reply-owed protection they were never asking for.
246pub const PARTICIPANT_PUMP_WINDOW: Duration = super::framing::IO_TIMEOUT;
247
248/// Typed result of one decoded participant frame on the real receive path.
249#[derive(Debug)]
250pub enum RemoteParticipantInbound {
251 /// The protocol crate correlated and applied a semantic response.
252 Applied {
253 /// Exact applied server value.
254 value: ServerValue,
255 /// Connection/attempt that delivered it.
256 provenance: ParticipantResponseProvenance,
257 },
258 /// The protocol crate retained the response and aggregate unchanged.
259 Refused {
260 /// Exact refused server value.
261 value: ServerValue,
262 /// Closed crate refusal reason, including conservative ambiguity.
263 reason: ClientInboundRefusalReason,
264 /// Connection/attempt that delivered it.
265 provenance: ParticipantResponseProvenance,
266 },
267 /// Server push decoded in the client direction; no correlation rule applies.
268 Push {
269 /// Exact pushed value.
270 value: ServerPush,
271 /// Connection/attempt that delivered it.
272 provenance: ParticipantResponseProvenance,
273 },
274}
275
276impl RemoteParticipantInbound {
277 /// The record sequence the server assigned an admitted record, when this
278 /// inbound is an APPLIED [`ServerValue::RecordCommitted`].
279 ///
280 /// This is the answer to a `RecordAdmission`, read off the exact wire value
281 /// the protocol crate applied. It saves every caller destructuring the wire
282 /// enum to reach the one number a record admission is asked for, without
283 /// removing that value: [`Applied`](Self::Applied) still carries the whole
284 /// `ServerValue`, so this is purely additive.
285 ///
286 /// `None` for everything else, and that includes a `RecordCommitted` the
287 /// crate REFUSED. A refused commit carries a sequence on the wire while
288 /// leaving the aggregate and its correlation untouched -- returning it here
289 /// would report a commitment the crate deliberately declined to make. It is
290 /// also `None` for a [`Push`](Self::Push), which is a delivery rather than
291 /// a correlated response.
292 #[must_use]
293 pub const fn committed_delivery_seq(&self) -> Option<DeliverySeq> {
294 match self {
295 Self::Applied {
296 value: ServerValue::RecordCommitted(committed),
297 ..
298 } => Some(committed.delivery_seq()),
299 Self::Applied { .. } | Self::Refused { .. } | Self::Push { .. } => None,
300 }
301 }
302}
303
304pub(super) struct RemoteParticipantState<S> {
305 pub(super) aggregate: Option<ClientParticipantAggregate>,
306 pub(super) correlation: Option<ClientResponseCorrelation>,
307 pub(super) reconnect_attempt: Option<liminal_protocol::client::ReconnectInProgressAttempt>,
308 pub(super) store: S,
309 /// The store failure that made the aggregate unreachable, if that is why.
310 ///
311 /// The failure itself is returned to whoever made the failing call and to
312 /// nobody else. This retains it so the question "why is this handle dead"
313 /// has a typed answer for every later caller: `take_aggregate` reads it back
314 /// out of here onto the
315 /// [`StateUnavailable`](RemoteParticipantError::StateUnavailable) it reports,
316 /// and [`unavailability_cause`](RemoteParticipantHandle::unavailability_cause)
317 /// answers it without a failing call.
318 pub(super) unavailable: Option<SdkError>,
319}
320
321impl<S> RemoteParticipantState<S> {
322 /// Records a store failure as the reason the aggregate is about to become
323 /// unreachable, and names that failure for the caller.
324 ///
325 /// Every seam that drops the aggregate on a store failure goes through here,
326 /// so the retained cause cannot drift from the returned one: they are the
327 /// same value.
328 pub(super) fn brick(&mut self, error: SdkError) -> RemoteParticipantError {
329 self.unavailable = Some(error.clone());
330 RemoteParticipantError::Storage(error)
331 }
332}
333
334/// Remote participant entrypoint backed by protocol-crate state and canonical LPCR storage.
335///
336/// Records are deliberately not promised as generally successful: the reduced-B1
337/// server surface fails fully authorized `RecordAdmission` and `Leave` closed until
338/// live claim-frontier acquisition lands (`docs/design/LP-GAP-CLOSURE-GOAL.md:145`).
339pub struct RemoteParticipantHandle<S> {
340 pub(super) server_address: ServerAddress,
341 pub(super) transport: Arc<dyn RemoteTransport>,
342 pub(super) state: Mutex<RemoteParticipantState<S>>,
343}
344
345impl<S> fmt::Debug for RemoteParticipantHandle<S> {
346 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
347 formatter
348 .debug_struct("RemoteParticipantHandle")
349 .field("server_address", &self.server_address)
350 .finish_non_exhaustive()
351 }
352}
353
354impl<S: ParticipantResumeStore> RemoteParticipantHandle<S> {
355 /// Creates and durably checkpoints a fresh unbound participant.
356 ///
357 /// # Errors
358 ///
359 /// Returns a typed encode or storage error before the handle is exposed.
360 pub fn new(config: &RemoteConfig, store: S) -> Result<Self, RemoteParticipantError> {
361 Self::from_aggregate(config, store, ClientParticipantAggregate::new())
362 }
363
364 /// Decodes, validates, restores, and durably records crash testimony before exposure.
365 ///
366 /// # Errors
367 ///
368 /// Returns typed LPCR decode/restore, encode, or storage errors.
369 pub fn restore(
370 config: &RemoteConfig,
371 store: S,
372 canonical_lpcr: &[u8],
373 ) -> Result<Self, RemoteParticipantError> {
374 let record = ClientResumeRecord::decode_canonical(canonical_lpcr)
375 .map_err(RemoteParticipantError::ResumeDecode)?;
376 let aggregate = record
377 .restore()
378 .map_err(RemoteParticipantError::ResumeRestore)?;
379 Self::from_aggregate(config, store, aggregate)
380 }
381
382 fn from_aggregate(
383 config: &RemoteConfig,
384 mut store: S,
385 aggregate: ClientParticipantAggregate,
386 ) -> Result<Self, RemoteParticipantError> {
387 persist(&mut store, &aggregate)?;
388 Ok(Self {
389 server_address: config.server_address.clone(),
390 transport: Arc::clone(&config.transport),
391 state: Mutex::new(RemoteParticipantState {
392 aggregate: Some(aggregate),
393 correlation: None,
394 reconnect_attempt: None,
395 store,
396 unavailable: None,
397 }),
398 })
399 }
400
401 /// Returns the store failure that made this handle unavailable, if that is
402 /// why it is unavailable.
403 ///
404 /// A `Some` names the exact [`SdkError`] the caller's own store returned, so
405 /// a hold can be attributed by matching a typed value. A `None` means the
406 /// handle is either live or unavailable for a reason that did not originate
407 /// in the store — which is itself the discrimination this exists to provide.
408 ///
409 /// The same retained value is carried on
410 /// [`StateUnavailable`](RemoteParticipantError::StateUnavailable) and is that
411 /// error's [`source`](core::error::Error::source), so a caller who CAUGHT
412 /// that error already holds the cause and does not need this. This accessor
413 /// answers the other question — one asked of a handle you still HOLD, rather
414 /// than of an error you caught — and it answers it without making a call
415 /// that must fail in order to find out. Its `None` therefore covers one case
416 /// the variant's `None` cannot: a handle that is simply still live.
417 #[must_use]
418 pub fn unavailability_cause(&self) -> Option<SdkError> {
419 self.state.lock().unavailable.clone()
420 }
421
422 /// Runs `record_operation -> commit -> LPCR persist -> into_parts` exactly.
423 ///
424 /// # Errors
425 ///
426 /// Returns typed resume encoding or storage failures. A failed post-commit
427 /// persistence leaves the handle unavailable and releases no authority.
428 pub fn record_operation(
429 &self,
430 request: ClientRequest,
431 ) -> Result<RemoteOperationRecordOutcome, RemoteParticipantError> {
432 let mut state = self.state.lock();
433 let aggregate = take_aggregate(&mut state)?;
434 match liminal_protocol::client::record_operation(aggregate, request) {
435 ClientOperationRecordDecision::Pending(pending) => {
436 let commit = pending.commit();
437 let record = commit
438 .resume_record()
439 .map_err(RemoteParticipantError::ResumeEncode)?;
440 if let Err(error) = state.store.persist(&record.encode_canonical()) {
441 return Err(state.brick(error));
442 }
443 let (aggregate, operation) = commit.into_parts();
444 state.aggregate = Some(aggregate);
445 Ok(RemoteOperationRecordOutcome::Recorded(
446 RemoteParticipantOperation {
447 operation,
448 durability: OperationDurability::WriteAhead,
449 },
450 ))
451 }
452 ClientOperationRecordDecision::Continuous(continuous) => {
453 let (aggregate, operation) = continuous.into_parts();
454 state.aggregate = Some(aggregate);
455 Ok(RemoteOperationRecordOutcome::Continuous(
456 RemoteParticipantOperation {
457 operation,
458 durability: OperationDurability::Continuous,
459 },
460 ))
461 }
462 ClientOperationRecordDecision::Refused(refusal) => {
463 let reason = refusal.reason();
464 let (aggregate, request) = refusal.into_parts();
465 state.aggregate = Some(aggregate);
466 Ok(RemoteOperationRecordOutcome::Refused { request, reason })
467 }
468 }
469 }
470
471 /// Persists issued state, writes the exact operation, and retains correlation.
472 ///
473 /// # Errors
474 ///
475 /// Returns typed state, LPCR, or storage failures. Transport failures are
476 /// returned as a typed outcome after crate fate delegation.
477 pub fn send_operation(
478 &self,
479 operation: RemoteParticipantOperation,
480 ) -> Result<RemoteParticipantSendOutcome, RemoteParticipantError> {
481 let mut state = self.state.lock();
482 let mut aggregate = take_aggregate(&mut state)?;
483 if operation.durability == OperationDurability::WriteAhead {
484 aggregate = persist_retaining(&mut state, aggregate)?;
485 }
486 let (request, correlation) = operation.operation.into_request();
487 match self
488 .transport
489 .send_participant(&self.server_address, &request)
490 {
491 Ok(provenance) => {
492 if operation.durability == OperationDurability::WriteAhead {
493 state.correlation = Some(correlation);
494 }
495 state.aggregate = Some(aggregate);
496 Ok(RemoteParticipantSendOutcome::Sent { provenance })
497 }
498 Err(error) => {
499 let operation_fate = if operation.durability == OperationDurability::WriteAhead {
500 record_operation_transport_fate(&mut state, aggregate, correlation)
501 } else {
502 state.aggregate = Some(aggregate);
503 RemoteOperationTransportFate::NotOutstanding
504 };
505 let reconnect = record_connection_fate(&mut state)?;
506 Ok(RemoteParticipantSendOutcome::TransportLost {
507 error,
508 operation_fate,
509 reconnect,
510 })
511 }
512 }
513 }
514
515 /// Receives one real participant frame and delegates every `ServerValue` to the crate.
516 ///
517 /// # The contract, and why it is this one
518 ///
519 /// THIS IS THE REPLY-OWED DOOR. It blocks for up to the transport's full
520 /// response deadline (60 s), because the caller it is written for has just
521 /// sent a request and is waiting for the correlated answer — and there, a
522 /// quiet connection means a slow server, not a dead one. Ending that wait
523 /// early is the 2026-08-10 outage's client-side mechanism, so this method
524 /// keeps the deadline unchanged and unconditionally.
525 ///
526 /// A consumer PUMPING an idle connection — looping to collect whatever the
527 /// server pushes next, where silence is a normal state rather than a fault
528 /// — must use [`receive_within`](Self::receive_within) or
529 /// [`try_receive`](Self::try_receive) instead. That is not a preference: a
530 /// drain loop built on this method waits out the full deadline on every
531 /// quiet read, which is how a 30 s boot gate blows on a healthy server.
532 ///
533 /// The split is by CALLER INTENT and cannot be anything else. At a clean
534 /// frame boundary with an empty buffer, a pump read and an outage-shaped
535 /// reply-owed read are byte-for-byte identical states; only the caller
536 /// knows which one it is making, so only the caller's choice of method can
537 /// carry it. Inferring it from buffered bytes, or from whether an operation
538 /// is outstanding, would silently shorten the deadline for some class of
539 /// genuinely reply-owed read and re-open the outage for it.
540 ///
541 /// Pushed deliveries are at-least-once: the same
542 /// `(conversation_id, delivery_seq)` may arrive more than once on one
543 /// healthy connection, byte-identical each time — deduplicate on the pair
544 /// (participant contract R-C3, amendment A3).
545 ///
546 /// # Errors
547 ///
548 /// Returns transport, direction, LPCR encoding, or storage failures.
549 pub fn receive(&self) -> Result<RemoteParticipantInbound, RemoteParticipantError> {
550 let frame = self
551 .transport
552 .receive_participant(&self.server_address)
553 .map_err(RemoteParticipantError::Transport)?;
554 self.classify_inbound(frame)
555 }
556
557 /// Receives one participant frame if one arrives within `budget`, reporting
558 /// a quiet connection as `Ok(None)` instead of an error.
559 ///
560 /// THE PUMP DOOR — the lawful read for a consumer that is owed nothing.
561 /// `Ok(None)` means "no frame within this window", which is a normal state
562 /// on a healthy connection and never a fault; a real transport failure
563 /// still returns `Err`, and a quiet window never surfaces a raw errno.
564 ///
565 /// `budget` is the CALLER'S bound and is spent across as many transport
566 /// read windows as it takes. It never shortens anything else: a caller that
567 /// uses this method to await a correlated answer simply names its own
568 /// deadline, and passing one at or above the transport's 60 s response
569 /// deadline reproduces [`receive`](Self::receive)'s patience with a typed
570 /// silence at the end instead of an error.
571 ///
572 /// `Duration::ZERO` polls only what has already decoded, without arming a
573 /// read. Bytes of a partly-arrived frame stay buffered across an
574 /// `Ok(None)`, so a frame that was mid-flight when the budget expired is
575 /// never lost — the next call resumes on it.
576 ///
577 /// # Errors
578 ///
579 /// Returns transport, direction, LPCR encoding, or storage failures. A
580 /// quiet window is NOT one of them.
581 pub fn receive_within(
582 &self,
583 budget: Duration,
584 ) -> Result<Option<RemoteParticipantInbound>, RemoteParticipantError> {
585 let Some(frame) = self
586 .transport
587 .receive_participant_within(&self.server_address, budget)
588 .map_err(RemoteParticipantError::Transport)?
589 else {
590 return Ok(None);
591 };
592 self.classify_inbound(frame).map(Some)
593 }
594
595 /// One [`PARTICIPANT_PUMP_WINDOW`] of patience, then `Ok(None)`.
596 ///
597 /// The drain-loop convenience over [`receive_within`](Self::receive_within):
598 /// a consumer that wants "give me the next frame, or tell me the connection
599 /// is quiet" without choosing a number. Loop it until it answers `Ok(None)`
600 /// and the backlog is drained.
601 ///
602 /// # Errors
603 ///
604 /// As [`receive_within`](Self::receive_within).
605 pub fn try_receive(&self) -> Result<Option<RemoteParticipantInbound>, RemoteParticipantError> {
606 self.receive_within(PARTICIPANT_PUMP_WINDOW)
607 }
608
609 /// Routes one decoded transport frame into the crate's inbound decisions.
610 ///
611 /// Shared by [`receive`](Self::receive) and
612 /// [`receive_within`](Self::receive_within) so the two doors differ ONLY in
613 /// how long they wait for a frame. Every correlation, application, and
614 /// refusal rule below is reached identically by both — a pump read that
615 /// does find a frame applies it exactly as a reply-owed read would.
616 fn classify_inbound(
617 &self,
618 frame: ParticipantTransportFrame,
619 ) -> Result<RemoteParticipantInbound, RemoteParticipantError> {
620 let ParticipantTransportFrame { frame, provenance } = frame;
621 match frame {
622 ParticipantFrame::ServerPush(value) => {
623 Ok(RemoteParticipantInbound::Push { value, provenance })
624 }
625 ParticipantFrame::ClientRequest(_) => {
626 Err(RemoteParticipantError::InvalidInboundDirection)
627 }
628 ParticipantFrame::ServerValue(value) => self.apply_inbound(value, provenance),
629 }
630 }
631
632 fn apply_inbound(
633 &self,
634 value: ServerValue,
635 provenance: ParticipantResponseProvenance,
636 ) -> Result<RemoteParticipantInbound, RemoteParticipantError> {
637 let mut state = self.state.lock();
638 let aggregate = take_aggregate(&mut state)?;
639 if let Some(correlation) = state.correlation.take() {
640 match decide_correlated_inbound(aggregate, value, correlation) {
641 ClientCorrelatedInboundDecision::Applied(applied) => {
642 let (aggregate, value) = applied.into_parts();
643 let aggregate = persist_retaining(&mut state, aggregate)?;
644 state.aggregate = Some(aggregate);
645 Ok(RemoteParticipantInbound::Applied { value, provenance })
646 }
647 ClientCorrelatedInboundDecision::Refused(refusal) => {
648 let reason = refusal.reason();
649 let (aggregate, value, correlation) = refusal.into_parts();
650 state.aggregate = Some(aggregate);
651 state.correlation = Some(correlation);
652 Ok(RemoteParticipantInbound::Refused {
653 value,
654 reason,
655 provenance,
656 })
657 }
658 }
659 } else {
660 match decide_inbound(aggregate, value) {
661 ClientInboundDecision::Applied(applied) => {
662 let (aggregate, value) = applied.into_parts();
663 let aggregate = persist_retaining(&mut state, aggregate)?;
664 state.aggregate = Some(aggregate);
665 Ok(RemoteParticipantInbound::Applied { value, provenance })
666 }
667 ClientInboundDecision::Refused(refusal) => {
668 let reason = refusal.reason();
669 let (aggregate, value) = refusal.into_parts();
670 state.aggregate = Some(aggregate);
671 Ok(RemoteParticipantInbound::Refused {
672 value,
673 reason,
674 provenance,
675 })
676 }
677 }
678 }
679 }
680}
681
682pub(super) fn take_aggregate<S>(
683 state: &mut RemoteParticipantState<S>,
684) -> Result<ClientParticipantAggregate, RemoteParticipantError> {
685 let aggregate = state.aggregate.take();
686 aggregate.ok_or_else(|| RemoteParticipantError::StateUnavailable {
687 source: state.unavailable.clone(),
688 })
689}
690
691pub(super) fn persist<S: ParticipantResumeStore>(
692 store: &mut S,
693 aggregate: &ClientParticipantAggregate,
694) -> Result<(), RemoteParticipantError> {
695 let record = aggregate
696 .resume_record()
697 .map_err(RemoteParticipantError::ResumeEncode)?;
698 store
699 .persist(&record.encode_canonical())
700 .map_err(RemoteParticipantError::Storage)
701}
702
703/// Persists `aggregate`, returning it to the caller on success and RE-SEATING it
704/// in `state` when encoding refused.
705///
706/// The two failure modes are not alike, and the difference is durability
707/// ambiguity:
708///
709/// * [`RemoteParticipantError::Storage`] means the store was asked to commit
710/// bytes and did not say it succeeded. Whether those bytes landed is unknown,
711/// so no further authority may be released from this aggregate. The handle
712/// is deliberately left bricked and the next call reports
713/// [`StateUnavailable`](RemoteParticipantError::StateUnavailable) -- the
714/// contract documented on that variant.
715/// * [`RemoteParticipantError::ResumeEncode`] means `resume_record`, a pure
716/// function of the aggregate, refused. Nothing was written and no authority
717/// was released, so there is nothing ambiguous to protect. Dropping the
718/// aggregate here converts a typed, catchable refusal into a permanently dead
719/// participant.
720///
721/// The re-seat lives in this function rather than at each seam on purpose: the
722/// SDK has ten `take_aggregate` -> persist -> re-seat sites, the `?` skips the
723/// re-seat at every one of them, and a new seam would inherit the same bug.
724/// Here it cannot be forgotten.
725///
726/// A caller that still needs the aggregate takes it back from the `Ok`; a
727/// caller that does not re-seats it itself. On the `ResumeEncode` path the
728/// aggregate is already seated, so callers must not seat it again.
729pub(super) fn persist_retaining<S: ParticipantResumeStore>(
730 state: &mut RemoteParticipantState<S>,
731 aggregate: ClientParticipantAggregate,
732) -> Result<ClientParticipantAggregate, RemoteParticipantError> {
733 match aggregate.resume_record() {
734 Ok(record) => match state.store.persist(&record.encode_canonical()) {
735 Ok(()) => Ok(aggregate),
736 // The aggregate is deliberately NOT re-seated: whether the bytes
737 // landed is unknown, so no further authority may be released. The
738 // cause is retained on the way past, because it is about to be the
739 // only thing that could ever explain the `StateUnavailable` every
740 // later call will report.
741 Err(error) => Err(state.brick(error)),
742 },
743 Err(error) => {
744 state.aggregate = Some(aggregate);
745 Err(RemoteParticipantError::ResumeEncode(error))
746 }
747 }
748}
749
750fn record_operation_transport_fate<S: ParticipantResumeStore>(
751 state: &mut RemoteParticipantState<S>,
752 aggregate: ClientParticipantAggregate,
753 correlation: ClientResponseCorrelation,
754) -> RemoteOperationTransportFate {
755 match record_expected_operation_fate(
756 aggregate,
757 correlation,
758 ExpectedOperationTransportFate::ResponseUnavailable,
759 ) {
760 liminal_protocol::client::ExpectedOperationFateDecision::Recorded {
761 aggregate,
762 request,
763 ..
764 } => {
765 state.aggregate = Some(aggregate);
766 RemoteOperationTransportFate::Recorded { request }
767 }
768 liminal_protocol::client::ExpectedOperationFateDecision::Refused {
769 aggregate,
770 correlation,
771 reason: ExpectedOperationFateRefusalReason::DetachUsesReplayFate,
772 ..
773 } => match liminal_protocol::client::transport_fate(
774 aggregate,
775 correlation,
776 liminal_protocol::client::DetachTransportFate::ResponseUnavailable,
777 ) {
778 liminal_protocol::client::DetachTransportFateDecision::Parked(applied) => {
779 state.aggregate = Some(applied.into_aggregate());
780 RemoteOperationTransportFate::DetachParked
781 }
782 liminal_protocol::client::DetachTransportFateDecision::Refused(refusal) => {
783 let (aggregate, (correlation, _)) = refusal.into_parts();
784 state.aggregate = Some(aggregate);
785 state.correlation = Some(correlation);
786 RemoteOperationTransportFate::Refused {
787 reason: ExpectedOperationFateRefusalReason::DetachUsesReplayFate,
788 }
789 }
790 },
791 liminal_protocol::client::ExpectedOperationFateDecision::Refused {
792 aggregate,
793 correlation,
794 reason,
795 ..
796 } => {
797 state.aggregate = Some(aggregate);
798 state.correlation = Some(correlation);
799 RemoteOperationTransportFate::Refused { reason }
800 }
801 }
802}
803
804pub(super) fn record_connection_fate<S: ParticipantResumeStore>(
805 state: &mut RemoteParticipantState<S>,
806) -> Result<RemoteReconnectPermitOutcome, RemoteParticipantError> {
807 let aggregate = take_aggregate(state)?;
808 let (aggregate, outcome) = match record_transport_fate(
809 aggregate,
810 liminal_protocol::client::EstablishedConnectionTransportFate::Lost,
811 ) {
812 ReconnectPermitDecision::Permitted {
813 aggregate,
814 permit,
815 result,
816 } => (
817 aggregate,
818 RemoteReconnectPermitOutcome::Permitted {
819 permit: RemoteReconnectPermit { permit },
820 result,
821 },
822 ),
823 ReconnectPermitDecision::Refused(refusal) => {
824 let reason = refusal.reason();
825 let result = refusal.result();
826 let (aggregate, _) = refusal.into_parts();
827 (
828 aggregate,
829 RemoteReconnectPermitOutcome::Refused { reason, result },
830 )
831 }
832 };
833 let aggregate = persist_retaining(state, aggregate)?;
834 state.aggregate = Some(aggregate);
835 Ok(outcome)
836}
837
838#[cfg(test)]
839mod tests;