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}