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}