Skip to main content

protocol_droid/adapters/primary/transport/http/
resume.rs

1//! Turning a `Last-Event-ID` header into a decision, before a byte is written.
2//!
3//! # Why this is a separate type
4//!
5//! Because the decision and the writing fail differently. Writing a stream is
6//! a loop over a socket with a poll in it: it is hard to test exhaustively and
7//! a mistake in it is invisible until a real client reconnects mid-stream. The
8//! decision — *given this cursor, does the client get a replay, an open stream,
9//! a notice, or a refusal* — has no I/O in it at all and can be checked on
10//! every branch. So the half that can be wrong quietly is the half that is not
11//! entangled with the half that is loud.
12//!
13//! [`plan`] takes the header and the ring and returns a [`Resume`]. The router
14//! in the next dispatch matches on it, writes [`Resume::pending`], and then
15//! goes live. Nothing here owns a stream, polls, or names a protocol.
16//!
17//! # The gap is the load-bearing case
18//!
19//! The ring drops old events to stay bounded. A client whose cursor is older
20//! than the drop point asks for a stream the server cannot produce, and there
21//! is a tempting way to answer that is wrong: send what is still held. The
22//! client receives a contiguous-looking stream, believes it is current, and the
23//! events that were dropped in between are gone with no error anywhere. That is
24//! a silent data loss, and it is the failure this module exists to make
25//! impossible to write by accident.
26//!
27//! So the gap gets its own arm, its own wire form, and its own test. What goes
28//! on the wire is a **single control frame, and it carries no `id:`**:
29//!
30//! ```text
31//! data: {"type":"resume-gap","evictedThrough":3,"resumeFrom":4,"lastEventId":9}
32//!
33//! ```
34//!
35//! Three things about that line are deliberate, and each of them is a way the
36//! alternative is worse:
37//!
38//! 1. **It is a frame, not a close.** Closing the stream leaves the client to
39//!    infer *why* from an absent stream, and a client that cannot tell a gap
40//!    from a network failure retries blindly forever. The notice says what
41//!    happened in the client's own terms.
42//! 2. **It is not a keep-alive comment.** A `:`-prefixed comment dispatches
43//!    nothing, so a client would learn the gap never happened. An event with
44//!    data is dispatched, so it is seen.
45//! 3. **It has no `id:`, deliberately.** The `id:` field is the client's resume
46//!    cursor, and the browser sets the last-event-id buffer from it whether or
47//!    not an event is dispatched. An encoder that put *any* id here would be
48//!    making the server assert that the client holds events it demonstrably
49//!    does not: `id: <last>` would skip everything up to the newest, and
50//!    `id: <evictedThrough>` would claim the client received the very events
51//!    that were dropped. Leaving the cursor untouched keeps the client's next
52//!    reconnect honest, and the notice hands it the one number that lets it
53//!    recover — `resumeFrom`.
54//!
55//! A client can therefore always tell the cases apart, which is the property
56//! the whole shape rests on: `Live` writes *nothing*, and `Gap` writes exactly
57//! one frame whose body names itself. Neither can be mistaken for the other,
58//! and neither can be mistaken for a successful replay.
59//!
60//! # Why `resumeFrom` is `evictedThrough + 1`
61//!
62//! The ring's watermark is inclusive of the dropped event itself — a client
63//! quoting the id of a dropped event is asking for what comes *after* it, and
64//! the ring cannot prove it kept the rest contiguous. So `evicted_through` is
65//! still a gap, and the first cursor that is guaranteed to yield a complete
66//! answer is the one after it. The notice publishes that cursor, which turns
67//! an unrecoverable gap into a single retry the client can make correctly.
68//!
69//! # Protocol neutrality
70//!
71//! No method name, no tool, no revision, no session. The only wire vocabulary
72//! here is the event-stream framing in [`SseEvent`] and the three integers above,
73//! and a second protocol with a different envelope slots in beside this without
74//! a refactor. The `type` member of the notice is the extension point, not a
75//! method name: a peer branches on `"resume-gap"` because that is the string
76//! this module writes, not because it is a method in any specification.
77
78use serde_json::json;
79
80use super::{FramePayload, Refusal, SseEvent};
81use crate::domain::{EventId, EventRing, Replay, StreamEvent};
82
83/// The notice a client gets when its cursor is behind the drop point, reduced
84/// to the member that names it.
85///
86/// Only reachable if the full notice fails to serialise, which a value built by
87/// [`json!`] cannot do — its keys are strings by construction. It is here so
88/// that the failure mode is a client that is still *told* there was a gap,
89/// rather than one handed an empty stream that looks like a clean resume.
90const MINIMAL_GAP_NOTICE: &str = r#"{"type":"resume-gap"}"#;
91
92/// What to do with a stream a client has asked to resume.
93///
94/// Four arms, and the first three are all ways of keeping the stream open. The
95/// fourth is not: it means no stream is opened at all, and the client is
96/// answered with an error it can branch on. A caller that treated it as a
97/// fourth way of staying open would have invented a response the client never
98/// receives.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum Resume {
101    /// Write these frames, then continue the live stream.
102    ///
103    /// The frames are the complete answer: everything after the client's
104    /// cursor is in here, and every one of them carries its own `id:` so the
105    /// client's next reconnect has a cursor to quote.
106    Replay {
107        /// The frames to write, oldest first.
108        frames: Vec<SseEvent>,
109        /// The newest event the stream has ever issued.
110        last: EventId,
111    },
112
113    /// Write nothing, and hold the stream open.
114    ///
115    /// Not a close, and not a keep-alive comment: a client must not be able to
116    /// mistake an empty write for an event, and the comment form dispatches
117    /// nothing so a client would learn of no error — but a *client that asked
118    /// to resume and is already current* has lost nothing, so the honest answer
119    /// is silence until something real happens.
120    Live {
121        /// The newest event the stream has ever issued.
122        last: EventId,
123    },
124
125    /// Write [`notice`](Self::Gap::notice), then continue the live stream.
126    ///
127    /// The client is behind by more than the ring retains. It has lost events
128    /// and cannot get them back, and this is how it finds out. See the module
129    /// docs for why the notice carries no `id:`.
130    Gap {
131        /// The control frame to write before going live.
132        notice: SseEvent,
133        /// The newest event the ring has dropped. Everything at or before this
134        /// is gone.
135        evicted_through: EventId,
136        /// The cursor that yields a complete answer on a retry.
137        resume_from: EventId,
138        /// The newest event the stream has ever issued.
139        last: EventId,
140    },
141
142    /// A refusal. Reuses [`Refusal`] so the status code and the JSON-RPC body
143    /// come from the one place that defines them.
144    Refused(Refusal),
145}
146
147impl Resume {
148    /// The frames to write before switching to the live stream.
149    ///
150    /// The write loop's whole job: write these, then go live. Empty for
151    /// [`Live`](Self::Live) and [`Refused`](Self::Refused), one frame for
152    /// [`Gap`](Self::Gap), and the whole window for [`Replay`](Self::Replay).
153    ///
154    /// Note that a refusal is `[]` *and* the writer must not open the stream
155    /// for it, which is why this is not enough on its own to drive the
156    /// response — a caller matches on the arm to get the status.
157    #[must_use]
158    pub fn pending(&self) -> &[SseEvent] {
159        match self {
160            Self::Replay { frames, .. } => frames,
161            Self::Gap { notice, .. } => core::slice::from_ref(notice),
162            Self::Live { .. } | Self::Refused(_) => &[],
163        }
164    }
165
166    /// The newest event id the stream has issued, when the decision has one.
167    ///
168    /// `None` only for a refusal, which is not a stream at all. Useful for a
169    /// log line or a metric, both of which want the watermark whether the
170    /// client got a replay, an open stream, or a notice.
171    #[must_use]
172    pub const fn last(&self) -> Option<EventId> {
173        match self {
174            Self::Replay { last, .. } | Self::Live { last } | Self::Gap { last, .. } => Some(*last),
175            Self::Refused(_) => None,
176        }
177    }
178}
179
180/// Decides what a client asking to resume gets, given the header it sent.
181///
182/// A missing header means "I have seen nothing", which is different from a
183/// corrupt one and is passed to the ring as "everything". A header that is
184/// present and unreadable is a refusal, decided before the ring is consulted at
185/// all: a client quoting a cursor this server cannot parse deserves to be told,
186/// and quietly restarting it from the top would hand it a stream it believes
187/// it has already seen.
188///
189/// # Example
190///
191/// ```
192/// use protocol_droid::adapters::primary::transport::http::Resume;
193///
194/// let decision = Resume::Live {
195///     last: protocol_droid::domain::EventId::new(9),
196/// };
197/// assert!(decision.pending().is_empty());
198/// assert_eq!(decision.last().map(|id| id.get()), Some(9));
199/// ```
200#[must_use]
201pub fn plan<T: FramePayload>(ring: &EventRing<T>, header: Option<&str>) -> Resume {
202    let Some(header) = header else {
203        return decide(&ring.replay_from(None));
204    };
205    parse_cursor(header).map_or(Resume::Refused(Refusal::MalformedCursor), |cursor| {
206        decide(&ring.replay_from(Some(cursor)))
207    })
208}
209
210/// Maps a ring's answer to a decision, for a caller that already holds one.
211///
212/// Separate from [`plan`] because the mapping is worth testing on its own, and
213/// because a caller that has already asked the ring — to log
214/// [`Replay::is_complete`], say — should not ask it twice.
215#[must_use]
216pub fn decide<T: FramePayload>(replay: &Replay<'_, T>) -> Resume {
217    match replay {
218        Replay::UpToDate { last } => Resume::Live { last: *last },
219        Replay::Buffered { events, last } => buffered(events, *last),
220        Replay::Gap {
221            evicted_through,
222            last,
223            ..
224        } => gap(*evicted_through, *last),
225        Replay::Unknown { requested, last } => Resume::Refused(Refusal::UnknownCursor {
226            requested: *requested,
227            last: *last,
228        }),
229    }
230}
231
232/// Reads a header value as a cursor, using the event-stream parser's rules.
233///
234/// The rules are [`SseEvent`]'s because they are already the rules for a
235/// numeric `id:` field, and a second parser would be a second opinion about
236/// what an id is. The header is wrapped in a throwaway block with a data line,
237/// because the standard dispatches nothing for a block with no data and this
238/// wants the parsed id rather than a dispatched event.
239///
240/// Returns `None` for anything the parser rejects, which includes the empty
241/// string: a header that is present and empty is a client that quoted a
242/// cursor, and it quoted nothing.
243fn parse_cursor(header: &str) -> Option<EventId> {
244    // A line terminator in the value is refused rather than parsed, and the
245    // parser cannot catch it: the block below is built by concatenation, so a
246    // newline in the value would start a *new field* in a block this function
247    // assembled. `1\nid: 99` would then be read as the cursor `99` — the
248    // untrusted value rewriting the id of a block the server built itself.
249    // HTTP forbids this in a real request, but this function is a `&str` over
250    // arbitrary text and does not rely on a caller having checked.
251    if header.contains(['\n', '\r']) {
252        return None;
253    }
254    let block = format!("id: {header}\ndata: {{}}\n");
255    // The second `?` is the block dispatching nothing, which the block above
256    // cannot do — it always carries a data line. Answering "unreadable" rather
257    // than treating it as a fresh client keeps that hypothetical fail-secure
258    // instead of fail-open, and an `id()` of `None` lands in the same place.
259    SseEvent::dispatch(&block).ok()??.id()
260}
261
262/// Turns a complete window into frames, keeping every `id:`.
263fn buffered<T: FramePayload>(events: &[StreamEvent<T>], last: EventId) -> Resume {
264    let mut frames = Vec::with_capacity(events.len());
265    for event in events {
266        match event.payload().to_frame(event.id()) {
267            Ok(frame) => frames.push(frame),
268            // A payload with no JSON form — a non-string map key — has no wire
269            // representation, and this is the server's bug rather than the
270            // client's, so it is the one refusal here that is a 500.
271            Err(_) => {
272                return Resume::Refused(Refusal::Handler(
273                    "an event payload has no JSON form".to_owned(),
274                ));
275            }
276        }
277    }
278    Resume::Replay { frames, last }
279}
280
281/// Builds the gap notice and the watermarks the client recovers from.
282fn gap(evicted_through: EventId, last: EventId) -> Resume {
283    let resume_from = EventId::new(evicted_through.get().saturating_add(1));
284    let notice = gap_notice(evicted_through, resume_from, last);
285    Resume::Gap {
286        notice,
287        evicted_through,
288        resume_from,
289        last,
290    }
291}
292
293/// The control frame that tells a client it missed events.
294///
295/// `without_id` and not [`SseEvent::json`], and the reason is the load-bearing
296/// one from the module docs: `json` cannot produce a frame without an id, and
297/// this frame must not have one. There is nothing here to serialise but three
298/// integers this function already holds, so the payload is built directly and
299/// the `json` constructor — which is right for a *payload*, where the id is
300/// the event's own — is not the right tool for a control frame.
301fn gap_notice(evicted_through: EventId, resume_from: EventId, last: EventId) -> SseEvent {
302    let value = json!({
303        "type": "resume-gap",
304        "evictedThrough": evicted_through.get(),
305        "resumeFrom": resume_from.get(),
306        "lastEventId": last.get(),
307    });
308    let data = serde_json::to_string(&value).unwrap_or_else(|_| MINIMAL_GAP_NOTICE.to_owned());
309    SseEvent::without_id(data)
310}
311
312#[cfg(test)]
313mod tests {
314    use core::num::NonZeroUsize;
315
316    use axum::http::StatusCode;
317    use serde_json::{Value, json};
318
319    use super::{Resume, decide, plan};
320    use crate::adapters::primary::transport::http::{Refusal, SseEvent};
321    use crate::domain::{EventId, EventRing};
322
323    type TestError = Box<dyn std::error::Error>;
324
325    /// A ring holding `values` and nothing dropped, so a quote inside it is a
326    /// complete answer.
327    ///
328    /// The capacity is the count rather than a constant: a ring that is not
329    /// full has no drop point, and a test about the *absence* of a gap is only
330    /// meaningful if the ring genuinely cannot produce one.
331    fn ring_of(values: &[Value]) -> Result<EventRing<Value>, TestError> {
332        let capacity =
333            NonZeroUsize::new(values.len().max(1)).ok_or("a ring needs a non-zero capacity")?;
334        let mut ring = EventRing::new(capacity);
335        for value in values {
336            ring.push(value.clone());
337        }
338        Ok(ring)
339    }
340
341    /// Three events and the ids the ring issues for them.
342    fn three_events() -> Vec<Value> {
343        vec![json!({"seq": 1}), json!({"seq": 2}), json!({"seq": 3})]
344    }
345
346    /// The ids on the frames a decision wants written, for the id-preservation
347    /// assertions. `None` is a real result here and is kept: a frame with no
348    /// cursor is a meaningful outcome, not a missing value.
349    fn frame_ids(decision: &Resume) -> Vec<Option<EventId>> {
350        decision.pending().iter().map(SseEvent::id).collect()
351    }
352
353    /// Walks a decoded body, because `Value`'s `Index` panics on a missing key
354    /// and a test that panics cannot tell a wrong expectation from a wrong
355    /// body.
356    fn at<'a>(body: &'a Value, path: &[&str]) -> Option<&'a Value> {
357        let mut node = body;
358        for key in path {
359            node = node.get(key)?;
360        }
361        Some(node)
362    }
363
364    /// The slug a refusal reports, which is the member a client branches on.
365    ///
366    /// `as_str` and not `to_string`: the body is a [`Value`], whose `Display`
367    /// is JSON, so formatting the slug would hand back `"unknown_cursor"`
368    /// *including the quotes*. Reading it as a string also proves the client
369    /// receives a JSON string and not a bare word. Owned because the body is
370    /// built fresh by `body()` and does not outlive this call.
371    fn slug_of(refusal: &Refusal) -> Option<String> {
372        let body = refusal.body();
373        at(&body, &["error", "data", "error"])
374            .and_then(Value::as_str)
375            .map(ToOwned::to_owned)
376    }
377
378    /// A client that has never seen the stream gets the whole window.
379    ///
380    /// The mutation this kills is treating "no header" as a corrupt header and
381    /// refusing a brand-new client, or — worse — reading it as the watermark
382    /// and answering `UpToDate`, which would connect a fresh client to a
383    /// stream that has already said everything and never replays it.
384    #[test]
385    fn a_fresh_client_with_no_header_gets_the_whole_window() -> Result<(), TestError> {
386        let ring = ring_of(&three_events())?;
387        let decision = plan(&ring, None);
388        let Resume::Replay { frames, last } = &decision else {
389            return Err(format!("expected a replay, got {decision:?}").into());
390        };
391        assert_eq!(frames.len(), 3);
392        assert_eq!(*last, EventId::new(3));
393        Ok(())
394    }
395
396    /// A client sitting exactly at the newest id is already current.
397    ///
398    /// The mutation this kills is answering `UpToDate` with a close, or with a
399    /// synthetic event to fill the space. Both are wrong in a way the client
400    /// can act on: a close is indistinguishable from a server that went away,
401    /// and a synthetic event advances the client's cursor over a position no
402    /// real event ever occupied.
403    #[test]
404    fn a_client_exactly_at_the_end_is_held_open_and_writes_nothing() -> Result<(), TestError> {
405        let ring = ring_of(&three_events())?;
406        let decision = plan(&ring, Some("3"));
407        assert_eq!(
408            decision,
409            Resume::Live {
410                last: EventId::new(3)
411            }
412        );
413        assert!(decision.pending().is_empty());
414        assert_eq!(decision.last(), Some(EventId::new(3)));
415        Ok(())
416    }
417
418    /// A client behind the drop point is told, with a cursor it can recover on.
419    ///
420    /// This is the data-loss case. The mutation it kills is the important one
421    /// in this file: answering a gap with the retained window anyway, or with
422    /// silence, so the client receives a stream that looks contiguous while
423    /// events 2 and 3 are simply gone. The second assertion is the half that
424    /// makes the first honest — the `resume_from` this publishes has to be a
425    /// cursor the ring actually serves completely, or the notice is advice the
426    /// client cannot follow.
427    #[test]
428    fn a_client_quoting_a_dropped_event_is_told_it_missed_them() -> Result<(), TestError> {
429        let mut ring = EventRing::new(NonZeroUsize::new(2).ok_or("capacity")?);
430        for seq in 1..=5 {
431            ring.push(json!({ "seq": seq }));
432        }
433        // The ring holds two of five, so ids 1..=3 are gone.
434        let Resume::Gap {
435            evicted_through,
436            resume_from,
437            last,
438            ..
439        } = plan(&ring, Some("1"))
440        else {
441            return Err("expected a gap".into());
442        };
443        assert_eq!(evicted_through, EventId::new(3));
444        assert_eq!(resume_from, EventId::new(4));
445        assert_eq!(last, EventId::new(5));
446
447        // The published recovery cursor is one the ring answers completely.
448        let retry = plan(&ring, Some(&resume_from.get().to_string()));
449        let Resume::Replay { .. } = &retry else {
450            return Err(format!("expected a complete replay, got {retry:?}").into());
451        };
452        assert_eq!(frame_ids(&retry), vec![Some(EventId::new(5))]);
453        Ok(())
454    }
455
456    /// The gap notice is dispatched, and it does not touch the cursor.
457    ///
458    /// Three properties in one test because they are one property: a client
459    /// must be able to tell this apart from [`Resume::Live`], and every way of
460    /// failing to tells it the wrong thing. A leading `:` would make it a
461    /// comment that dispatches nothing, so the client would never learn it had
462    /// lost anything; a leading `id:` would move the client's resume cursor to
463    /// a position it never received an event for, which is the server asserting
464    /// a delivery that did not happen.
465    #[test]
466    fn the_gap_notice_is_dispatched_and_carries_no_id() -> Result<(), TestError> {
467        let mut ring = EventRing::new(NonZeroUsize::new(2).ok_or("capacity")?);
468        for seq in 1..=5 {
469            ring.push(json!({ "seq": seq }));
470        }
471        let decision = plan(&ring, Some("1"));
472        let pending = decision.pending();
473        let [notice] = pending else {
474            return Err(format!("expected exactly one frame, got {pending:?}").into());
475        };
476        assert_eq!(notice.id(), None, "the gap notice must not move the cursor");
477        assert_eq!(frame_ids(&decision), vec![None]);
478
479        let encoded = notice.encode();
480        assert!(
481            !encoded.starts_with(':'),
482            "a comment dispatches nothing, so the client would never see the gap"
483        );
484        assert!(
485            !encoded.starts_with("id:"),
486            "an id here would claim events the client never received"
487        );
488        assert!(encoded.starts_with("data:"));
489
490        // The body names itself, which is what makes the case recoverable
491        // rather than merely detectable.
492        let body: Value = serde_json::from_str(notice.data())?;
493        assert_eq!(at(&body, &["type"]), Some(&json!("resume-gap")));
494        assert_eq!(at(&body, &["evictedThrough"]), Some(&json!(3)));
495        assert_eq!(at(&body, &["resumeFrom"]), Some(&json!(4)));
496        assert_eq!(at(&body, &["lastEventId"]), Some(&json!(5)));
497        Ok(())
498    }
499
500    /// A cursor past the end is out of step, and is refused with both ends of
501    /// the comparison so the client can see which way it is wrong.
502    ///
503    /// The mutation this kills is clamping a cursor past the end down to the
504    /// newest id, which would answer `UpToDate` to a client that is not up to
505    /// date and hand it a stream it silently never catches up on.
506    #[test]
507    fn a_client_quoting_an_id_past_the_end_is_refused() -> Result<(), TestError> {
508        let ring = ring_of(&three_events())?;
509        let decision = plan(&ring, Some("99"));
510        assert_eq!(
511            decision,
512            Resume::Refused(Refusal::UnknownCursor {
513                requested: EventId::new(99),
514                last: EventId::new(3),
515            })
516        );
517        let Resume::Refused(refusal) = &decision else {
518            return Err("expected a refusal".into());
519        };
520        assert_eq!(refusal.status(), StatusCode::CONFLICT);
521        assert_eq!(slug_of(refusal).as_deref(), Some("unknown_cursor"));
522        assert!(decision.pending().is_empty());
523        assert_eq!(decision.last(), None);
524        Ok(())
525    }
526
527    /// A cursor that is not a number is refused, not guessed at.
528    ///
529    /// The mutation this kills is falling back to a fresh start, which is the
530    /// worst one available: the client believes it has seen the stream, is sent
531    /// the whole window again, and re-processes every event it already
532    /// handled. It is also the reason this is a 400 and not a 500 — nothing is
533    /// broken here but the client's header.
534    #[test]
535    fn a_corrupt_cursor_is_refused_rather_than_read_as_a_fresh_start() -> Result<(), TestError> {
536        let ring = ring_of(&three_events())?;
537        for corrupt in ["abc", "", "1x", " ", "-1", "99999999999999999999999"] {
538            let decision = plan(&ring, Some(corrupt));
539            assert_eq!(
540                decision,
541                Resume::Refused(Refusal::MalformedCursor),
542                "expected {corrupt:?} to be refused"
543            );
544            let Resume::Refused(refusal) = &decision else {
545                return Err("expected a refusal".into());
546            };
547            assert_eq!(refusal.status(), StatusCode::BAD_REQUEST);
548            assert_eq!(slug_of(refusal).as_deref(), Some("malformed_cursor"));
549            assert!(decision.pending().is_empty());
550            assert_eq!(decision.last(), None);
551        }
552        Ok(())
553    }
554
555    /// A cursor containing a line terminator cannot inject a second field.
556    ///
557    /// The parse reuses the event-stream parser by wrapping the header in a
558    /// block it assembles itself, so a newline in the value would start a new
559    /// field *in that block* — and the untrusted value would be rewriting the
560    /// id of a frame the server built. The mutation this kills is dropping the
561    /// guard, which reads `1\nid: 99` as the cursor `99`.
562    #[test]
563    fn a_cursor_with_a_line_terminator_cannot_inject_a_second_id() -> Result<(), TestError> {
564        let ring = ring_of(&three_events())?;
565        for injected in ["1\nid: 99", "1\r\nid: 99", "abc\ndata: forged"] {
566            let decision = plan(&ring, Some(injected));
567            assert_eq!(
568                decision,
569                Resume::Refused(Refusal::MalformedCursor),
570                "expected {injected:?} to be refused"
571            );
572        }
573        Ok(())
574    }
575
576    /// Every replayed frame carries its own id, and the id is the event's.
577    ///
578    /// The mutation this kills is emitting the window with `without_id`, which
579    /// reads correct in a log and breaks every subsequent reconnect: the client
580    /// has no cursor to quote, so it reconnects from wherever it last was and
581    /// is re-sent the same window forever. The assertion is on the ids
582    /// themselves rather than on the text containing `id:`, so a frame carrying
583    /// the *wrong* id fails too.
584    #[test]
585    fn every_replayed_frame_keeps_its_own_id() -> Result<(), TestError> {
586        let ring = ring_of(&three_events())?;
587        let decision = plan(&ring, Some("1"));
588        assert_eq!(
589            frame_ids(&decision),
590            vec![Some(EventId::new(2)), Some(EventId::new(3))]
591        );
592        for frame in decision.pending() {
593            assert!(frame.encode().starts_with("id: "));
594        }
595        Ok(())
596    }
597
598    /// A payload containing a newline survives the wire.
599    ///
600    /// A JSON payload can never contain a *raw* newline — the encoder escapes
601    /// it — so this asserts the escaped form survives the whole trip: encoded,
602    /// decoded by the standard's own parser, and parsed back to a value whose
603    /// string holds a real newline. The mutation this kills is writing the
604    /// payload into the frame with `Debug`, which emits `\n` for a newline and
605    /// a literal `\u{7f}` for DEL — neither of which is JSON, and both of which
606    /// arrive as the six characters a client sees.
607    #[test]
608    fn a_payload_containing_a_newline_survives_the_round_trip() -> Result<(), TestError> {
609        let original = json!({ "text": "one\ntwo\nthree" });
610        let ring = ring_of(core::slice::from_ref(&original))?;
611        let decision = plan(&ring, None);
612        let [frame] = decision.pending() else {
613            return Err(format!("expected one frame, got {decision:?}").into());
614        };
615
616        // Exactly one `data:` line. The newline is escaped by the JSON
617        // encoder, so the payload can never legitimately need a second — and an
618        // encoder that wrote a raw one would have split the frame, which a
619        // client reads as an event followed by a field named after the tail.
620        let encoded = frame.encode();
621        assert_eq!(
622            encoded.matches("\ndata: ").count(),
623            1,
624            "the payload must occupy exactly one data line: {encoded:?}"
625        );
626
627        let decoded = SseEvent::decode(&encoded)?;
628        let recovered: Value = serde_json::from_str(decoded.data())?;
629        assert_eq!(recovered, original);
630        assert_eq!(
631            at(&recovered, &["text"]).and_then(Value::as_str),
632            Some("one\ntwo\nthree")
633        );
634        Ok(())
635    }
636
637    /// A payload that is not a string goes out as JSON, not as text.
638    ///
639    /// The mutation this kills is `format!("{value:?}")` on the payload, which
640    /// is the mistake `SseEvent::json` exists to make unreachable. Rust's
641    /// `Debug` is not JSON: an object prints as `Object {"a": 1}`, which no
642    /// client can parse, and it is still *valid-looking* enough that the
643    /// failure shows up as a parse error on the far side rather than here.
644    #[test]
645    fn a_payload_that_is_not_a_string_is_written_as_json() -> Result<(), TestError> {
646        let original = json!([1, "two", null, { "nested": true }]);
647        let ring = ring_of(core::slice::from_ref(&original))?;
648        let decision = plan(&ring, None);
649        let [frame] = decision.pending() else {
650            return Err(format!("expected one frame, got {decision:?}").into());
651        };
652        let data = frame.data();
653        assert!(data.starts_with('['), "Debug would print `Object`/`Array`");
654        assert!(!data.contains("Object"), "Debug leaked: {data}");
655        assert_eq!(serde_json::from_str::<Value>(data)?, original);
656        Ok(())
657    }
658
659    /// DEL is written as itself, not as a Rust escape.
660    ///
661    /// The specific character the module docs in `sse.rs` name, isolated
662    /// because it is the one a general round-trip test will not catch: a
663    /// newline round-trips through *both* `Debug` and JSON as the two
664    /// characters `\n`, so it cannot tell the two encoders apart. DEL does —
665    /// `Debug` writes the six characters `\u{7f}`, which is not a JSON escape
666    /// sequence at all.
667    #[test]
668    fn a_delete_character_is_not_written_as_a_rust_escape() -> Result<(), TestError> {
669        let original = json!({ "t": "\u{7f}" });
670        let ring = ring_of(core::slice::from_ref(&original))?;
671        let decision = plan(&ring, None);
672        let [frame] = decision.pending() else {
673            return Err(format!("expected one frame, got {decision:?}").into());
674        };
675        let data = frame.data();
676        assert!(!data.contains("\\u{7f}"), "Debug escape leaked: {data}");
677        assert!(data.contains('\u{7f}'), "DEL should be written literally");
678        assert_eq!(serde_json::from_str::<Value>(data)?, original);
679        Ok(())
680    }
681
682    /// The mapping is available on its own, for a caller holding a `Replay`.
683    ///
684    /// This is the seam Stage 2d uses to log [`Replay::is_complete`] before
685    /// asking what to do about it, without asking the ring twice — so it needs
686    /// to agree with [`plan`] on the arms rather than being a second opinion.
687    #[test]
688    fn the_mapping_agrees_with_the_whole_ring() -> Result<(), TestError> {
689        let ring = ring_of(&three_events())?;
690        for header in [None, Some("1"), Some("2"), Some("3"), Some("4")] {
691            let planned = plan(&ring, header);
692            let cursor = header.and_then(|raw| raw.parse::<u64>().ok());
693            let mapped = cursor.map_or_else(
694                || decide(&ring.replay_from(None)),
695                |value| decide(&ring.replay_from(Some(EventId::new(value)))),
696            );
697            assert_eq!(planned, mapped, "disagreed on {header:?}");
698        }
699        Ok(())
700    }
701}