返回 CodeWhale
mod.rs
根目录 / crates / tui / src / tui / streaming / mod.rs
1 //! Provider-delta ingest for display-clock rendering.
2 //!
3 //! Provider deltas arrive as dozens of tiny SSE chunks. This module buffers
4 //! them without mutating the visible transcript ([`StreamBuffer`]) and bounds
5 //! both how often that buffer is flushed ([`StreamDisplayClock`]) and how much
6 //! each beat may uncover ([`reveal_budget`]), so the pace a reader sees is set
7 //! by the clock rather than by how the provider happened to chunk its output.
8 //!
9 //! Deltas are *input*, never animation timing. A commit beat reveals a bounded
10 //! slice at [`REVEAL_PER_SECOND`], so the pace a reader sees is the same
11 //! whatever shape the provider's chunks arrive in. There is still no
12 //! per-grapheme typewriter and no adaptive drain policy. A two-gear "adaptive
13 //! chunking" policy lived here until v0.9.4; it could never emit anything other
14 //! than "drain everything available", so it was deleted rather than wired up.
15 //! What replaced it is not that policy: it takes a measured slice per beat
16 //! instead of deciding between two gears.
17 //!
18 //! Buffering never changes what the model emitted. `accumulated_text` and
19 //! `accumulated_thinking` always track the full raw stream, so anything
20 //! building API messages or retrying sees the whole receipt regardless of how
21 //! much of it is currently visible.
22 //!
23 //! # Known limitations
24 //!
25 //! - **Latency is backlog over rate, not a fixed window.** The ceiling caps how
26 //! fast bytes become visible, so a receipt larger than one beat's slice takes
27 //! `backlog / REVEAL_PER_SECOND` to finish. A 4 KiB burst takes about 1.7 s to
28 //! uncover. That is the deliberate trade (even pace over minimum latency) and
29 //! it is not configurable.
30 //! - **A receipt that lands after a long pause is shown whole.** A pause
31 //! longer than the beat interval leaves the next beat due immediately, and
32 //! finalization takes a [`StreamDisplayClock::flush_now`] forced beat that
33 //! drains everything buffered, bypassing pacing entirely — so very large
34 //! receipts still land in one step.
35 //! - **Tool output does not pass through here.** It is unbuffered and has no
36 //! pacing of its own.
37 //! - **Catch-up is staged, not wired.** [`CATCH_UP_QUEUE_DEPTH`] and
38 //! [`CATCH_UP_OLDEST_AGE`] exist and are tested, but every production drain
39 //! site calls `note_delta` with a queue depth of 1, so catch-up never fires.
40 //! See the honesty note in `docs/MOTION_CONTRACT.md`.
41 //! - **Nothing measures this.** There is no benchmark and no budget for reveal
42 //! throughput or visible latency; the numbers in the commit that introduced
43 //! paced reveal came from a throwaway harness. See the runtime-perf-gate gap.
44 //!
45 //! Newline-boundary safety (never showing a half-written code fence) is owned
46 //! by the incremental markdown parser downstream — see
47 //! `ParseState::commit_complete_lines` in `tui/markdown_render.rs`, which
48 //! leaves the trailing partial line uncommitted and re-parses it each tick.
49 //! A separate `LineBuffer` gate used to sit here, but both constructors
50 //! bypassed it, so it protected nothing and was removed with the policy.
51
52 use std::time::Duration;
53 use std::time::Instant;
54
55 /// Default cadence for moving queued provider deltas into visible transcript
56 /// text. This intentionally tracks animation frames rather than upstream SSE
57 /// cadence, so tiny bursty deltas coalesce into one history/cache mutation.
58 ///
59 /// ~60 FPS (16ms). Full motion may catch up sooner when backlog crosses
60 /// [`CATCH_UP_QUEUE_DEPTH`] / [`CATCH_UP_OLDEST_AGE`]; reduced motion never
61 /// accelerates — it stays on this steady clock (not a slow typewriter).
62 pub const DEFAULT_STREAM_COMMIT_INTERVAL: Duration = Duration::from_millis(16);
63
64 /// Queue-depth threshold that pulls the display clock forward (catch-up).
65 ///
66 /// Staged, not live: every production drain site calls
67 /// [`StreamDisplayClock::note_delta`] (queued = 1), so catch-up never fires
68 /// today. See the honesty note in `docs/MOTION_CONTRACT.md`.
69 pub const CATCH_UP_QUEUE_DEPTH: usize = 160;
70
71 /// Oldest-chunk age that pulls the display clock forward (catch-up).
72 /// Staged alongside [`CATCH_UP_QUEUE_DEPTH`].
73 pub const CATCH_UP_OLDEST_AGE: Duration = Duration::from_millis(1_200);
74
75 /// Ceiling on how fast received text may become visible, in bytes per second.
76 ///
77 /// This is the pace the reader sees. At the display clock's beat it is also the
78 /// size of one step, so the visible text advances by roughly the same amount
79 /// every beat instead of in provider-sized chunks.
80 ///
81 /// Set several times a fast model's own output rate (a brisk stream is a few
82 /// hundred bytes per second): ordinary streaming is then limited by when the
83 /// bytes arrive, not by this ceiling, and receives no artificial delay. The
84 /// ceiling only spreads a burst that lands ahead of that rate.
85 pub const REVEAL_PER_SECOND: usize = 2_400;
86
87 /// How many bytes may become visible on a beat that is `interval` long, given
88 /// `backlog` bytes waiting.
89 #[must_use]
90 pub fn reveal_budget(interval: Duration, backlog: usize) -> usize {
91 if backlog == 0 {
92 return 0;
93 }
94 let per_beat = (REVEAL_PER_SECOND as u128 * interval.as_nanos() / 1_000_000_000) as usize;
95 per_beat.max(1).min(backlog)
96 }
97
98 /// Frame-clock gate for stream display commits.
99 ///
100 /// Provider deltas may arrive in dozens of tiny chunks inside one event-loop
101 /// drain. This clock lets the TUI ingest those bytes cheaply, then mutate the
102 /// visible transcript at most once per display beat unless the stream is being
103 /// finalized or measured backlog demands catch-up.
104 #[derive(Debug, Clone)]
105 pub struct StreamDisplayClock {
106 interval: Duration,
107 pending: bool,
108 next_due_at: Option<Instant>,
109 last_commit_at: Option<Instant>,
110 commit_count: u64,
111 catch_up_count: u64,
112 /// When true, backlog may pull `next_due_at` forward to `now`.
113 allow_catch_up: bool,
114 }
115
116 impl Default for StreamDisplayClock {
117 fn default() -> Self {
118 Self::new(DEFAULT_STREAM_COMMIT_INTERVAL)
119 }
120 }
121
122 impl StreamDisplayClock {
123 pub fn new(interval: Duration) -> Self {
124 Self {
125 interval,
126 pending: false,
127 next_due_at: None,
128 last_commit_at: None,
129 commit_count: 0,
130 catch_up_count: 0,
131 allow_catch_up: true,
132 }
133 }
134
135 /// Enable/disable catch-up acceleration. Reduced motion keeps the steady
136 /// clock and never pulls beats forward.
137 pub fn set_allow_catch_up(&mut self, allow: bool) {
138 self.allow_catch_up = allow;
139 }
140
141 /// Note that at least one stream delta is waiting to become visible.
142 pub fn note_delta(&mut self, now: Instant) {
143 self.note_delta_with_backlog(now, 1, None);
144 }
145
146 /// Note a delta together with measured queue pressure.
147 ///
148 /// Normal motion coalesces onto the steady interval unless backlog crosses
149 /// the catch-up thresholds, in which case the next beat is due immediately.
150 pub fn note_delta_with_backlog(
151 &mut self,
152 now: Instant,
153 queued: usize,
154 oldest_age: Option<Duration>,
155 ) {
156 self.pending = true;
157 let catch_up = self.allow_catch_up
158 && (queued >= CATCH_UP_QUEUE_DEPTH
159 || oldest_age.is_some_and(|age| age >= CATCH_UP_OLDEST_AGE));
160
161 if catch_up {
162 self.next_due_at = Some(now);
163 self.catch_up_count = self.catch_up_count.saturating_add(1);
164 return;
165 }
166
167 if self.next_due_at.is_some() {
168 return;
169 }
170 self.next_due_at = Some(match self.last_commit_at {
171 Some(last) => last.checked_add(self.interval).unwrap_or(now).max(now),
172 None => now,
173 });
174 }
175
176 /// Returns the time until the pending commit is due, if any.
177 pub fn due_in(&self, now: Instant) -> Option<Duration> {
178 let due = self.next_due_at?;
179 Some(due.saturating_duration_since(now))
180 }
181
182 /// Consume a due commit beat.
183 pub fn take_due(&mut self, now: Instant) -> bool {
184 if !self.pending {
185 self.next_due_at = None;
186 return false;
187 }
188 let Some(due) = self.next_due_at else {
189 return false;
190 };
191 if now < due {
192 return false;
193 }
194 self.pending = false;
195 self.next_due_at = None;
196 self.last_commit_at = Some(now);
197 self.commit_count = self.commit_count.saturating_add(1);
198 true
199 }
200
201 /// Force a commit beat, used when the stream is being finalized.
202 pub fn flush_now(&mut self, now: Instant) -> bool {
203 let had_pending = self.pending;
204 self.pending = false;
205 self.next_due_at = None;
206 if had_pending {
207 self.last_commit_at = Some(now);
208 self.commit_count = self.commit_count.saturating_add(1);
209 }
210 had_pending
211 }
212
213 pub fn reset(&mut self) {
214 self.pending = false;
215 self.next_due_at = None;
216 self.last_commit_at = None;
217 self.commit_count = 0;
218 self.catch_up_count = 0;
219 }
220
221 /// The configured beat length, so a caller can size work per beat.
222 #[must_use]
223 pub fn interval(&self) -> Duration {
224 self.interval
225 }
226
227 /// Number of commit beats consumed since the last reset (observability).
228 #[cfg(test)]
229 pub fn commit_count(&self) -> u64 {
230 self.commit_count
231 }
232
233 /// Number of beats pulled forward by backlog since the last reset
234 /// (observability; see the staged-catch-up note on
235 /// [`CATCH_UP_QUEUE_DEPTH`]).
236 #[cfg(test)]
237 pub fn catch_up_count(&self) -> u64 {
238 self.catch_up_count
239 }
240 }
241
242 /// Buffers raw provider deltas between display-clock beats.
243 ///
244 /// One buffer per active block (assistant / thinking). Tool output is
245 /// unbuffered and bypasses this path entirely. Each commit beat takes a
246 /// bounded slice ([`StreamBuffer::take_up_to`]) so the visible text advances
247 /// at a steady rate rather than in provider-sized steps.
248 #[derive(Debug, Default, Clone)]
249 pub struct StreamBuffer {
250 pending: String,
251 }
252
253 impl StreamBuffer {
254 pub fn new() -> Self {
255 Self::default()
256 }
257
258 /// Append a raw model delta.
259 pub fn push_delta(&mut self, delta: &str) {
260 self.pending.push_str(delta);
261 }
262
263 /// Whether any text is waiting for the next commit beat.
264 pub fn has_pending(&self) -> bool {
265 !self.pending.is_empty()
266 }
267
268 /// Take at most `budget` bytes, leaving the rest for a later beat.
269 ///
270 /// The cut lands on a grapheme boundary: slicing through a cluster would
271 /// flash a broken glyph for one beat. A budget too small to hold the next
272 /// cluster still takes that cluster whole, so a drain can never stall.
273 pub fn take_up_to(&mut self, budget: usize) -> String {
274 use unicode_segmentation::UnicodeSegmentation;
275 if budget >= self.pending.len() {
276 return std::mem::take(&mut self.pending);
277 }
278 let mut end = budget.min(self.pending.len());
279 while end > 0 && !self.pending.is_char_boundary(end) {
280 end -= 1;
281 }
282 if end > 0 {
283 end = self
284 .pending
285 .grapheme_indices(true)
286 .map(|(index, _)| index)
287 .take_while(|index| *index <= end)
288 .last()
289 .unwrap_or(0);
290 }
291 if end == 0 {
292 end = self.pending.graphemes(true).next().map_or(0, str::len);
293 }
294 let rest = self.pending.split_off(end);
295 std::mem::replace(&mut self.pending, rest)
296 }
297
298 /// Bytes waiting for a later beat.
299 pub fn pending_len(&self) -> usize {
300 self.pending.len()
301 }
302
303 /// Take everything buffered since the previous beat.
304 pub fn take(&mut self) -> String {
305 std::mem::take(&mut self.pending)
306 }
307 }
308
309 /// Per-block streaming substate.
310 ///
311 /// ```text
312 /// raw delta -> StreamBuffer.push_delta -> commit beat -> take -> transcript
313 /// ```
314 #[derive(Debug, Default)]
315 struct BlockState {
316 /// Thinking blocks route to `accumulated_thinking`; text blocks to
317 /// `accumulated_text`.
318 is_thinking: bool,
319 /// Cleared once the block has been finalized.
320 is_streaming: bool,
321 /// Deltas received but not yet flushed to the transcript.
322 buffer: StreamBuffer,
323 }
324
325 /// State for managing multiple stream buffers (one per content block)
326 #[derive(Debug, Default)]
327 pub struct StreamingState {
328 /// Per-block state by index.
329 blocks: Vec<Option<BlockState>>,
330 /// Whether any stream is currently active
331 pub is_active: bool,
332 /// Accumulated text for display
333 pub accumulated_text: String,
334 /// Accumulated thinking for display
335 pub accumulated_thinking: String,
336 }
337
338 impl StreamingState {
339 /// Create a new streaming state
340 pub fn new() -> Self {
341 Self::default()
342 }
343
344 /// Start a new text block. Assistant prose is buffered until the next
345 /// display-clock beat so provider bursts produce one visible mutation.
346 pub fn start_text(&mut self, index: usize) {
347 self.start_block(index, false);
348 }
349
350 /// Start a new thinking block. Thinking deltas are buffered exactly like
351 /// assistant prose — long reasoning often arrives as one paragraph with no
352 /// intermediate newlines, so nothing here waits on a line boundary.
353 pub fn start_thinking(&mut self, index: usize) {
354 self.start_block(index, true);
355 }
356
357 fn start_block(&mut self, index: usize, is_thinking: bool) {
358 self.ensure_capacity(index);
359 self.blocks[index] = Some(BlockState {
360 is_thinking,
361 is_streaming: true,
362 buffer: StreamBuffer::new(),
363 });
364 self.is_active = true;
365 }
366
367 /// Push content to a block.
368 ///
369 /// `accumulated_text` / `accumulated_thinking` always track the full raw
370 /// stream so callers building API messages or doing retries see exactly
371 /// what the model emitted, regardless of UI pacing.
372 pub fn push_content(&mut self, index: usize, content: &str) {
373 if let Some(Some(block)) = self.blocks.get_mut(index) {
374 if block.is_thinking {
375 self.accumulated_thinking.push_str(content);
376 } else {
377 self.accumulated_text.push_str(content);
378 }
379 block.buffer.push_delta(content);
380 }
381 }
382
383 /// Run one commit beat and return the text to flush to the transcript,
384 /// taking at most `budget` bytes. Empty when nothing arrived since the
385 /// previous beat or the budget admits none.
386 pub fn commit_text(&mut self, index: usize, budget: usize) -> String {
387 match self.blocks.get_mut(index) {
388 Some(Some(block)) => block.buffer.take_up_to(budget),
389 _ => String::new(),
390 }
391 }
392
393 /// Bytes waiting in a block's buffer.
394 pub fn pending_len(&self, index: usize) -> usize {
395 self.blocks
396 .get(index)
397 .and_then(|b| b.as_ref())
398 .map_or(0, |b| b.buffer.pending_len())
399 }
400
401 /// Whether a block holds text waiting to be flushed by the next commit
402 /// beat. Callers use this to keep the display clock ticking while a
403 /// buffer drains.
404 pub fn has_pending_stream_text(&self, index: usize) -> bool {
405 self.blocks
406 .get(index)
407 .and_then(|b| b.as_ref())
408 .is_some_and(|b| b.buffer.has_pending())
409 }
410
411 /// Finalize a block and return whatever text it still holds.
412 pub fn finalize_block_text(&mut self, index: usize) -> String {
413 let out = match self.blocks.get_mut(index) {
414 Some(Some(block)) => {
415 block.is_streaming = false;
416 block.buffer.take()
417 }
418 _ => return String::new(),
419 };
420 self.check_active();
421 out
422 }
423
424 /// Check if any stream is still active
425 fn check_active(&mut self) {
426 self.is_active = self.blocks.iter().flatten().any(|b| b.is_streaming);
427 }
428
429 /// Ensure capacity for the given index
430 fn ensure_capacity(&mut self, index: usize) {
431 while self.blocks.len() <= index {
432 self.blocks.push(None);
433 }
434 }
435
436 /// Reset the streaming state
437 pub fn reset(&mut self) {
438 self.blocks.clear();
439 self.is_active = false;
440 self.accumulated_text.clear();
441 self.accumulated_thinking.clear();
442 }
443 }
444
445 #[cfg(test)]
446 mod tests {
447 use super::*;
448
449 #[test]
450 fn assistant_text_streams_before_newline() {
451 let mut state = StreamingState::new();
452 state.start_text(0);
453 state.push_content(0, "hello world");
454
455 assert_eq!(state.commit_text(0, usize::MAX), "hello world");
456 assert!(!state.has_pending_stream_text(0));
457 }
458
459 #[test]
460 fn thinking_text_streams_before_newline() {
461 let mut state = StreamingState::new();
462 state.start_thinking(0);
463 state.push_content(0, "thinking deeply");
464
465 assert_eq!(state.commit_text(0, usize::MAX), "thinking deeply");
466 assert!(!state.has_pending_stream_text(0));
467 }
468
469 #[test]
470 fn commit_beat_drains_everything_received_since_the_previous_beat() {
471 // A burst arriving "at once" is displayed at the same cadence instead
472 // of being synthetically dripped and flushed at end of turn.
473 let mut state = StreamingState::new();
474 state.start_text(0);
475
476 let burst = "abcdefghijklmnopqrstuvwxyz".repeat(8);
477 state.push_content(0, &burst);
478 assert_eq!(state.commit_text(0, usize::MAX), burst);
479 // Second beat with nothing new is empty, not a replay.
480 assert_eq!(state.commit_text(0, usize::MAX), "");
481 }
482
483 #[test]
484 fn combining_marks_stay_with_their_base_letter() {
485 let mut state = StreamingState::new();
486 state.start_text(0);
487 state.push_content(0, "e\u{301}x");
488 assert_eq!(state.commit_text(0, usize::MAX), "e\u{301}x");
489 }
490
491 #[test]
492 fn finalize_drains_partial_tail() {
493 // The final, possibly-unterminated line must survive finalization.
494 let mut state = StreamingState::new();
495 state.start_text(0);
496 state.push_content(0, "done\nno-newline-here");
497 assert_eq!(state.finalize_block_text(0), "done\nno-newline-here");
498 assert!(!state.is_active);
499 }
500
501 #[test]
502 fn finalize_after_commit_has_nothing_left() {
503 let mut state = StreamingState::new();
504 state.start_text(0);
505 state.push_content(0, "abc");
506 assert_eq!(state.commit_text(0, usize::MAX), "abc");
507 assert_eq!(state.finalize_block_text(0), "");
508 }
509
510 #[test]
511 fn accumulators_track_raw_stream_by_block_kind() {
512 let mut state = StreamingState::new();
513 state.start_thinking(0);
514 state.push_content(0, "reasoning");
515 state.start_text(1);
516 state.push_content(1, "answer");
517
518 assert_eq!(state.accumulated_thinking, "reasoning");
519 assert_eq!(state.accumulated_text, "answer");
520 }
521
522 #[test]
523 fn bursty_stream_state_has_no_text_loss_after_coalesced_flushes() {
524 let mut state = StreamingState::new();
525 state.start_text(0);
526 let mut expected = String::new();
527
528 for idx in 0..250 {
529 let chunk = format!("{idx}.");
530 expected.push_str(&chunk);
531 state.push_content(0, &chunk);
532 }
533
534 let first_flush = state.commit_text(0, usize::MAX);
535 assert_eq!(first_flush, expected);
536 assert_eq!(state.finalize_block_text(0), "");
537 }
538
539 #[test]
540 fn stream_display_clock_coalesces_bursty_tiny_deltas() {
541 let interval = Duration::from_millis(33);
542 let mut clock = StreamDisplayClock::new(interval);
543 let t0 = Instant::now();
544
545 for _ in 0..100 {
546 clock.note_delta(t0);
547 }
548
549 assert_eq!(clock.due_in(t0), Some(Duration::ZERO));
550 assert!(clock.take_due(t0));
551 assert_eq!(clock.commit_count(), 1);
552
553 for _ in 0..25 {
554 clock.note_delta(t0 + Duration::from_millis(5));
555 }
556 assert!(!clock.take_due(t0 + Duration::from_millis(5)));
557 assert_eq!(
558 clock.due_in(t0 + Duration::from_millis(5)),
559 Some(Duration::from_millis(28))
560 );
561 assert!(clock.take_due(t0 + interval));
562 assert_eq!(clock.commit_count(), 2);
563 }
564
565 #[test]
566 fn stream_display_clock_bounds_long_reasoning_commit_count() {
567 let interval = Duration::from_millis(33);
568 let mut clock = StreamDisplayClock::new(interval);
569 let t0 = Instant::now();
570 let mut commits = 0u64;
571
572 for millis in 0..300 {
573 let now = t0 + Duration::from_millis(millis);
574 clock.note_delta(now);
575 if clock.take_due(now) {
576 commits += 1;
577 }
578 }
579
580 assert!(commits > 1, "long streams should keep advancing visibly");
581 assert!(
582 commits <= 11,
583 "300 one-ms deltas should not commit on provider cadence: {commits}"
584 );
585 assert_eq!(commits, clock.commit_count());
586 }
587
588 #[test]
589 fn stream_display_clock_final_flush_consumes_pending_delta() {
590 let mut clock = StreamDisplayClock::new(Duration::from_millis(33));
591 let t0 = Instant::now();
592
593 clock.note_delta(t0);
594 assert!(clock.take_due(t0));
595 clock.note_delta(t0 + Duration::from_millis(4));
596
597 assert!(!clock.take_due(t0 + Duration::from_millis(4)));
598 assert!(clock.flush_now(t0 + Duration::from_millis(5)));
599 assert_eq!(clock.due_in(t0 + Duration::from_millis(5)), None);
600 assert!(!clock.take_due(t0 + Duration::from_millis(33)));
601 assert_eq!(clock.commit_count(), 2);
602 }
603
604 #[test]
605 fn normal_clock_catch_up_only_when_backlog_crosses_threshold() {
606 let interval = Duration::from_millis(33);
607 let mut clock = StreamDisplayClock::new(interval);
608 let t0 = Instant::now();
609
610 clock.note_delta(t0);
611 assert!(clock.take_due(t0));
612 // Small backlog after a commit stays on the steady interval.
613 clock.note_delta_with_backlog(
614 t0 + Duration::from_millis(1),
615 3,
616 Some(Duration::from_millis(5)),
617 );
618 assert!(!clock.take_due(t0 + Duration::from_millis(1)));
619 assert_eq!(clock.catch_up_count(), 0);
620
621 // Measured backlog crosses the catch-up threshold → due immediately.
622 clock.note_delta_with_backlog(
623 t0 + Duration::from_millis(2),
624 CATCH_UP_QUEUE_DEPTH,
625 Some(Duration::from_millis(10)),
626 );
627 assert!(clock.take_due(t0 + Duration::from_millis(2)));
628 assert!(clock.catch_up_count() >= 1);
629 }
630
631 #[test]
632 fn reduced_motion_keeps_steady_clock_without_catch_up_or_typewriter() {
633 let interval = Duration::from_millis(33);
634 let mut clock = StreamDisplayClock::new(interval);
635 clock.set_allow_catch_up(false);
636 let t0 = Instant::now();
637
638 clock.note_delta(t0);
639 assert!(clock.take_due(t0));
640 clock.note_delta_with_backlog(
641 t0 + Duration::from_millis(1),
642 CATCH_UP_QUEUE_DEPTH * 2,
643 Some(CATCH_UP_OLDEST_AGE),
644 );
645 // Reduced motion must not pull the beat forward.
646 assert!(!clock.take_due(t0 + Duration::from_millis(1)));
647 assert_eq!(clock.catch_up_count(), 0);
648 assert_eq!(
649 clock.due_in(t0 + Duration::from_millis(1)),
650 Some(Duration::from_millis(32))
651 );
652 assert!(clock.take_due(t0 + interval));
653 // Same interval as full motion — not a slower artificial typewriter.
654 assert_eq!(clock.commit_count(), 2);
655 }
656
657 #[test]
658 fn reveal_budget_is_a_steady_step_and_always_progresses() {
659 let interval = DEFAULT_STREAM_COMMIT_INTERVAL;
660 let full = reveal_budget(interval, usize::MAX);
661 assert_eq!(full, REVEAL_PER_SECOND * 16 / 1000);
662 // A backlog smaller than one step is taken whole, so a small receipt
663 // is never slowed down by the ceiling.
664 assert_eq!(reveal_budget(interval, 3), 3);
665 // Nothing waiting means nothing to reveal.
666 assert_eq!(reveal_budget(interval, 0), 0);
667 // A budget too small to hold a character still admits progress.
668 assert!(reveal_budget(Duration::from_nanos(1), 10_000) >= 1);
669 }
670
671 #[test]
672 fn a_burst_is_spread_across_beats_instead_of_landing_at_once() {
673 // The defect this guards: the whole buffer used to move to the
674 // transcript on the first beat, so the reader saw provider-sized jumps.
675 let interval = DEFAULT_STREAM_COMMIT_INTERVAL;
676 let burst = "x".repeat(2_000);
677 let mut state = StreamingState::default();
678 state.start_text(0);
679 state.push_content(0, &burst);
680
681 let step = reveal_budget(interval, state.pending_len(0));
682 assert!(
683 step < burst.len() / 4,
684 "one beat took {step} of {} bytes",
685 burst.len()
686 );
687
688 let mut revealed = String::new();
689 for beat in 0..200 {
690 let budget = reveal_budget(interval, state.pending_len(0));
691 let taken = state.commit_text(0, budget);
692 assert!(taken.len() <= budget, "beat {beat} overspent its budget");
693 revealed.push_str(&taken);
694 if !state.has_pending_stream_text(0) {
695 break;
696 }
697 }
698 assert_eq!(revealed, burst, "every byte must arrive, in order");
699 }
700
701 #[test]
702 fn take_up_to_never_splits_a_character_and_always_progresses() {
703 let mut buffer = StreamBuffer::new();
704 buffer.push_delta("a👩🏽‍💻b");
705 // A budget that cannot hold the emoji still yields it whole rather
706 // than stalling the drain forever.
707 assert_eq!(buffer.take_up_to(1), "a");
708 assert_eq!(buffer.take_up_to(1), "👩🏽‍💻");
709 assert!(buffer.has_pending());
710 assert_eq!(buffer.take_up_to(usize::MAX), "b");
711 assert!(!buffer.has_pending());
712 }
713 }
714
715 /// Runtime performance gate for the reveal path (#6193 first slice).
716 #[cfg(test)]
717 #[path = "tests/perf_gate.rs"]
718 mod perf_gate;
719
719 lines RUST