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