Skip to main content

liminal_sdk/remote/tcp/
flush.rs

1//! Flush surface types and the verdict ledger behind [`PushClient::flush`].
2//!
3//! The push connection's `publish` is fire-and-forget: bytes hit the socket and
4//! the call returns. The server answers every publish on an ordinary channel
5//! with a `Frame::PublishAck` or `Frame::PublishError`; before 0.4.0 the
6//! background reader discarded both, so a caller could never learn a publish's
7//! fate. This module holds the machinery that captures those verdicts and
8//! returns them at an explicit [`PushClient::flush`]/[`PushClient::close`]:
9//!
10//! * the public outcome types ([`FlushOutcome`], [`FlushMode`],
11//!   [`PublishRejection`]);
12//! * the crate-internal [`FlushLedger`] that counts response-eliciting
13//!   publishes, receives verdicts from the background reader, and drains them
14//!   under the serial flush guard.
15//!
16//! Design of record: `docs/design/SDK-PUSH-FLUSH.md` r2 (torn). The decision
17//! sockets referenced below («FLUSH-UNRESOLVED-DISCLOSURE» = T1,
18//! «CONCURRENT-FLUSH-SERIAL» = T2, «FIFO-MINIMAL-CORRELATION-DEFERRED» = R1,
19//! «RAW-REASON-NO-MAPPING» = R4, «OBSERVABILITY-UNACKED» = D5) are rulings.
20//!
21//! [`PushClient::flush`]: super::PushClient::flush
22//! [`PushClient::close`]: super::PushClient::close
23
24use alloc::format;
25use alloc::string::String;
26use alloc::vec::Vec;
27use core::time::Duration;
28
29use std::sync::Mutex;
30use std::sync::atomic::{AtomicU64, Ordering};
31use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
32use std::time::Instant;
33
34use crate::SdkError;
35
36/// Total wall-clock budget bounding one flush, in the spirit of the drop-time
37/// `DROP_DRAIN_BUDGET`: a flush blocks on a deadline'd channel receive for the
38/// outstanding verdicts and returns when they have all arrived or the budget
39/// elapses — never an unbounded wait, never a poll loop (LAW-1).
40pub const FLUSH_BUDGET: Duration = Duration::from_secs(5);
41
42/// A single publish the server rejected, reported verbatim from the wire.
43///
44/// Carries the raw `Frame::PublishError` fields with **no mapping** onto the
45/// SDK error taxonomy (ruling R4 «RAW-REASON-NO-MAPPING»): today the server
46/// sets a blanket `reason_code` of `0xFFFF` for every publish failure, so a
47/// schema mismatch is not wire-distinguishable from any other rejection —
48/// fabricating a typed error from the message string would be a lie wearing a
49/// type. Only the `message` text differs per failure.
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct PublishRejection {
52    /// The server's numeric reason, verbatim. Today always `0xFFFF`.
53    reason_code: u16,
54    /// The server's human-readable detail (carries the schema-mismatch text).
55    message: Option<String>,
56}
57
58impl PublishRejection {
59    /// Builds a rejection from the raw `PublishError` wire fields.
60    pub(crate) const fn new(reason_code: u16, message: Option<String>) -> Self {
61        Self {
62            reason_code,
63            message,
64        }
65    }
66
67    /// The server's numeric reason code, verbatim from the wire.
68    #[must_use]
69    pub const fn reason_code(&self) -> u16 {
70        self.reason_code
71    }
72
73    /// The server's human-readable rejection detail, verbatim from the wire.
74    #[must_use]
75    pub fn message(&self) -> Option<&str> {
76        self.message.as_deref()
77    }
78}
79
80/// What a [`FlushOutcome`]'s collection actually did to the socket, disclosed
81/// so a degraded close is never silent (ruling R2 «SHARED-SOCKET-VERDICT-ONLY»).
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum FlushMode {
84    /// Sole owner at [`PushClient::close`]: acks drained AND the write half was
85    /// FIN'd, so the server saw a graceful half-close.
86    ///
87    /// [`PushClient::close`]: super::PushClient::close
88    FlushedAndHalfClosed,
89    /// Verdicts collected, no FIN. Every plain [`PushClient::flush`] reports
90    /// this mode (a flush leaves the connection fully usable), as does a
91    /// [`PushClient::close`] degraded by a live [`PushWriter`] clone still
92    /// sharing the socket — a write-half `shutdown` would break the clone's
93    /// publishes, so close MUST NOT half-close and says so here instead of
94    /// degrading silently.
95    ///
96    /// [`PushClient::flush`]: super::PushClient::flush
97    /// [`PushClient::close`]: super::PushClient::close
98    /// [`PushWriter`]: super::PushWriter
99    VerdictOnly,
100}
101
102/// The typed result of a [`PushClient::flush`] or [`PushClient::close`].
103///
104/// T1 «FLUSH-UNRESOLVED-DISCLOSURE» invariant: **`failures.is_empty() &&
105/// unresolved == 0` is the ONLY shape that reads as proven-accepted** (see
106/// [`FlushOutcome::is_proven_accepted`]). Budget expiry with unresolved
107/// publishes is a NORMAL outcome the caller must inspect, never an `Err`; the
108/// outer `Err` is reserved for failures of the flush mechanism itself.
109///
110/// [`PushClient::flush`]: super::PushClient::flush
111/// [`PushClient::close`]: super::PushClient::close
112#[derive(Clone, Debug, PartialEq, Eq)]
113pub struct FlushOutcome {
114    /// Per-publish rejections observed among the flushed publishes, in wire
115    /// order (D4 «FIFO-VERDICT-NO-CORRELATION»: there is no correlation id on
116    /// the wire, so order is the only binding).
117    failures: Vec<PublishRejection>,
118    /// Flushed publishes still unresolved when the budget expired — neither
119    /// acked nor rejected. A NORMAL outcome the caller MUST inspect, not an
120    /// error: a nonzero `unresolved` means those publishes' fate is
121    /// connection-indeterminate.
122    unresolved: usize,
123    /// Whether this call also half-closed the socket or only collected
124    /// verdicts.
125    mode: FlushMode,
126}
127
128impl FlushOutcome {
129    /// Assembles an outcome from a drained verdict window.
130    pub(crate) const fn new(
131        failures: Vec<PublishRejection>,
132        unresolved: usize,
133        mode: FlushMode,
134    ) -> Self {
135        Self {
136            failures,
137            unresolved,
138            mode,
139        }
140    }
141
142    /// Per-publish rejections among the flushed publishes, in wire order.
143    #[must_use]
144    pub fn failures(&self) -> &[PublishRejection] {
145        &self.failures
146    }
147
148    /// Consumes the outcome, returning the owned rejections in wire order.
149    #[must_use]
150    pub fn into_failures(self) -> Vec<PublishRejection> {
151        self.failures
152    }
153
154    /// Flushed publishes whose verdict never arrived inside the budget.
155    #[must_use]
156    pub const fn unresolved(&self) -> usize {
157        self.unresolved
158    }
159
160    /// Whether the call half-closed the socket or only collected verdicts.
161    #[must_use]
162    pub const fn mode(&self) -> FlushMode {
163        self.mode
164    }
165
166    /// `true` ONLY when every flushed publish was proven accepted by the
167    /// server: no failures AND no unresolved publishes (the T1
168    /// «FLUSH-UNRESOLVED-DISCLOSURE» invariant). Any other shape is the
169    /// caller's to inspect.
170    #[must_use]
171    pub fn is_proven_accepted(&self) -> bool {
172        self.failures.is_empty() && self.unresolved == 0
173    }
174}
175
176/// One server response to a response-eliciting publish, forwarded by the
177/// background reader in wire order.
178#[derive(Clone, Debug, PartialEq, Eq)]
179pub enum PublishVerdict {
180    /// A `Frame::PublishAck`: the server accepted the publish.
181    Accepted,
182    /// A `Frame::PublishError`: the server rejected the publish.
183    Rejected(PublishRejection),
184}
185
186/// Verdict receiver plus the resolution cursor, owned by the flush guard so no
187/// two flushes ever consume from the FIFO response sequence at once (T2).
188struct VerdictInbox {
189    /// Wire-ordered verdicts forwarded by the background reader.
190    verdicts: Receiver<PublishVerdict>,
191    /// Response-eliciting publishes already resolved by earlier flushes.
192    resolved: u64,
193}
194
195/// Shared accounting between the publish writers, the background reader, and
196/// `flush()`/`close()`.
197///
198/// * `written` counts response-eliciting publishes whose bytes reached the
199///   socket (incremented under the writer lock, so the count follows wire
200///   order); reserved observability-channel publishes are never counted — the
201///   server answers them with no frame by design (D5 «OBSERVABILITY-UNACKED»).
202/// * `arrived` counts publish responses the reader has captured.
203/// * `inbox` is the T2 serial flush guard: a second concurrent flush WAITS on
204///   this mutex (a blocking lock, not a poll) and then covers only its own
205///   write-boundary.
206#[derive(Debug)]
207pub struct FlushLedger {
208    /// Response-eliciting publishes written to the socket, ever.
209    written: AtomicU64,
210    /// Publish responses (`PublishAck`/`PublishError`) captured by the reader,
211    /// ever. `arrived > written` can only mean the elicits-response
212    /// classification broke — the R1 fail-loud tripwire.
213    arrived: AtomicU64,
214    /// Serial flush guard owning the verdict receiver and resolution cursor.
215    inbox: Mutex<VerdictInbox>,
216}
217
218impl core::fmt::Debug for VerdictInbox {
219    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
220        formatter
221            .debug_struct("VerdictInbox")
222            .field("resolved", &self.resolved)
223            .finish_non_exhaustive()
224    }
225}
226
227impl FlushLedger {
228    /// Builds the ledger and the sender half the background reader forwards
229    /// verdicts on.
230    pub(crate) fn new() -> (Self, Sender<PublishVerdict>) {
231        let (sender, verdicts) = channel();
232        let ledger = Self {
233            written: AtomicU64::new(0),
234            arrived: AtomicU64::new(0),
235            inbox: Mutex::new(VerdictInbox {
236                verdicts,
237                resolved: 0,
238            }),
239        };
240        (ledger, sender)
241    }
242
243    /// Records one response-eliciting publish written to the socket. Called
244    /// under the writer lock so the count follows wire order.
245    pub(crate) fn record_written(&self) {
246        self.written.fetch_add(1, Ordering::SeqCst);
247    }
248
249    /// Records one publish response captured by the background reader.
250    pub(crate) fn record_arrival(&self) {
251        self.arrived.fetch_add(1, Ordering::SeqCst);
252    }
253
254    /// Drains verdicts for every response-eliciting publish written before
255    /// this call, bounded by `budget`.
256    ///
257    /// Returns the wire-ordered rejections plus the count of publishes still
258    /// unresolved at budget expiry (T1: a normal outcome, not an error).
259    ///
260    /// # Errors
261    ///
262    /// Returns [`SdkError::Connection`] when the flush guard is poisoned, and
263    /// [`SdkError::Protocol`] when more publish responses arrived than
264    /// response-eliciting publishes were written — a broken pairing invariant;
265    /// per R1 the flush fails loudly rather than ever mispairing a verdict.
266    pub(crate) fn drain(
267        &self,
268        budget: Duration,
269    ) -> Result<(Vec<PublishRejection>, usize), SdkError> {
270        // Snapshot the write boundary BEFORE waiting on the guard (T2): a
271        // flush that waited behind another covers the publishes written before
272        // *it* was called, minus those the earlier flush already resolved.
273        let written_at_call = self.written.load(Ordering::SeqCst);
274        let mut inbox = self.inbox.lock().map_err(|error| SdkError::Connection {
275            description: format!("flush guard poisoned: {error}"),
276        })?;
277        self.check_pairing_invariant()?;
278        let window = written_at_call.saturating_sub(inbox.resolved);
279        let deadline = Instant::now() + budget;
280        let mut failures = Vec::new();
281        let mut collected: u64 = 0;
282        while collected < window {
283            let now = Instant::now();
284            if now >= deadline {
285                break;
286            }
287            match inbox.verdicts.recv_timeout(deadline.duration_since(now)) {
288                Ok(verdict) => {
289                    collected += 1;
290                    inbox.resolved += 1;
291                    if let PublishVerdict::Rejected(rejection) = verdict {
292                        failures.push(rejection);
293                    }
294                }
295                // Timeout: the budget elapsed with verdicts still outstanding.
296                // Disconnected: the reader ended (connection gone), so the
297                // outstanding verdicts can never arrive. Both leave the
298                // remainder connection-indeterminate — counted as unresolved,
299                // never an `Err` (T1).
300                Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => break,
301            }
302        }
303        // Release the serial guard before the final invariant check (which
304        // reads only the atomics): a waiting flush may proceed immediately.
305        drop(inbox);
306        self.check_pairing_invariant()?;
307        let unresolved = usize::try_from(window.saturating_sub(collected)).map_err(|error| {
308            SdkError::Protocol {
309                description: format!("unresolved publish count overflowed usize: {error}"),
310            }
311        })?;
312        Ok((failures, unresolved))
313    }
314
315    /// R1 fail-loud tripwire: more publish responses than response-eliciting
316    /// publishes means the classification (and therefore any FIFO pairing) is
317    /// broken — return a typed mechanism error, never mispair.
318    fn check_pairing_invariant(&self) -> Result<(), SdkError> {
319        let arrived = self.arrived.load(Ordering::SeqCst);
320        let written = self.written.load(Ordering::SeqCst);
321        if arrived > written {
322            return Err(SdkError::Protocol {
323                description: format!(
324                    "publish response-count mismatch: {arrived} publish responses arrived for \
325                     {written} response-eliciting publishes; refusing to pair verdicts"
326                ),
327            });
328        }
329        Ok(())
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use alloc::string::ToString;
337    use alloc::vec;
338
339    /// A short budget so expiry pins stay brisk; the drain is a deadline'd
340    /// blocking receive, so the pin's duration IS the budget.
341    const SHORT_BUDGET: Duration = Duration::from_millis(50);
342
343    /// T1 pin (unit leg): budget expiry with outstanding publishes is a NORMAL
344    /// outcome — unresolved counted, empty failures, `Ok` — and its shape is
345    /// distinguishable from proven-accepted.
346    #[test]
347    fn budget_expiry_counts_unresolved_and_is_not_an_error() -> Result<(), SdkError> {
348        let (ledger, _sender) = FlushLedger::new();
349        ledger.record_written();
350        ledger.record_written();
351        ledger.record_written();
352        let (failures, unresolved) = ledger.drain(SHORT_BUDGET)?;
353        assert!(failures.is_empty());
354        assert_eq!(unresolved, 3);
355        let expired = FlushOutcome::new(failures, unresolved, FlushMode::VerdictOnly);
356        assert!(!expired.is_proven_accepted());
357        let clean = FlushOutcome::new(Vec::new(), 0, FlushMode::VerdictOnly);
358        assert!(clean.is_proven_accepted());
359        Ok(())
360    }
361
362    /// R1 pin (unit leg): a verdict with no response-eliciting publish written
363    /// is a broken pairing invariant — a typed mechanism `Err`, not a
364    /// per-publish failure.
365    #[test]
366    fn surplus_verdict_is_a_typed_mechanism_error() {
367        let (ledger, sender) = FlushLedger::new();
368        assert!(sender.send(PublishVerdict::Accepted).is_ok());
369        ledger.record_arrival();
370        let result = ledger.drain(SHORT_BUDGET);
371        assert!(matches!(result, Err(SdkError::Protocol { .. })));
372    }
373
374    /// D4/R4 pin (unit leg): rejections surface in wire order, verbatim.
375    #[test]
376    fn rejections_surface_in_wire_order_verbatim() -> Result<(), SdkError> {
377        let (ledger, sender) = FlushLedger::new();
378        let first = PublishRejection::new(0xFFFF, Some("first".to_string()));
379        let second = PublishRejection::new(0xFFFF, None);
380        for verdict in [
381            PublishVerdict::Rejected(first.clone()),
382            PublishVerdict::Accepted,
383            PublishVerdict::Rejected(second.clone()),
384        ] {
385            ledger.record_written();
386            ledger.record_arrival();
387            assert!(sender.send(verdict).is_ok());
388        }
389        let (failures, unresolved) = ledger.drain(SHORT_BUDGET)?;
390        assert_eq!(failures, vec![first, second]);
391        assert_eq!(unresolved, 0);
392        Ok(())
393    }
394
395    /// T2 pin: two threads drain concurrently. The flush guard serializes
396    /// them (a mutex wait, not a poll), no verdict is misattributed or
397    /// double-consumed, and whichever drain proceeds second covers only its
398    /// own write-boundary (already fully resolved here, so a zero window).
399    ///
400    /// This pin drives the ledger directly because the hazard lives here: the
401    /// guard is the single reader of the FIFO verdict sequence for every
402    /// `flush()`/`close()` above it.
403    #[test]
404    fn concurrent_drains_serialize_without_misattribution() -> Result<(), SdkError> {
405        use std::sync::{Arc, Barrier};
406        let (ledger, sender) = FlushLedger::new();
407        let ledger = Arc::new(ledger);
408        let rejection = PublishRejection::new(0xFFFF, Some("boom".to_string()));
409        for verdict in [
410            PublishVerdict::Accepted,
411            PublishVerdict::Accepted,
412            PublishVerdict::Rejected(rejection.clone()),
413            PublishVerdict::Accepted,
414        ] {
415            ledger.record_written();
416            ledger.record_arrival();
417            assert!(sender.send(verdict).is_ok());
418        }
419        let barrier = Arc::new(Barrier::new(2));
420        let spawn_drain = |ledger: Arc<FlushLedger>, barrier: Arc<Barrier>| {
421            std::thread::spawn(move || {
422                barrier.wait();
423                ledger.drain(FLUSH_BUDGET)
424            })
425        };
426        let first = spawn_drain(Arc::clone(&ledger), Arc::clone(&barrier));
427        let second = spawn_drain(Arc::clone(&ledger), barrier);
428        let joined =
429            |handle: std::thread::JoinHandle<Result<(Vec<PublishRejection>, usize), SdkError>>| {
430                handle.join().map_err(|_| SdkError::Protocol {
431                    description: "drain thread panicked".to_string(),
432                })
433            };
434        let (failures_a, unresolved_a) = joined(first)??;
435        let (failures_b, unresolved_b) = joined(second)??;
436        // Exactly one drain consumed the four-verdict window (the guard
437        // winner); the other covered its already-resolved zero window. The one
438        // rejection surfaces exactly once, never split or duplicated.
439        let combined: Vec<PublishRejection> = failures_a.into_iter().chain(failures_b).collect();
440        assert_eq!(combined, vec![rejection]);
441        assert_eq!(unresolved_a, 0);
442        assert_eq!(unresolved_b, 0);
443        Ok(())
444    }
445
446    /// T2 boundary math (unit leg): a drain after everything was resolved
447    /// covers a zero window and returns immediately clean.
448    #[test]
449    fn second_drain_covers_only_its_own_boundary() -> Result<(), SdkError> {
450        let (ledger, sender) = FlushLedger::new();
451        ledger.record_written();
452        ledger.record_arrival();
453        assert!(sender.send(PublishVerdict::Accepted).is_ok());
454        let (_, unresolved) = ledger.drain(SHORT_BUDGET)?;
455        assert_eq!(unresolved, 0);
456        let started = Instant::now();
457        let (failures, unresolved) = ledger.drain(FLUSH_BUDGET)?;
458        assert!(failures.is_empty());
459        assert_eq!(unresolved, 0);
460        // A zero window never waits on the budget: the guard is the only wait.
461        assert!(started.elapsed() < FLUSH_BUDGET);
462        Ok(())
463    }
464}