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