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}