1use std::path::PathBuf;
37use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
38use std::sync::{Arc, Mutex};
39
40use tokio_util::sync::CancellationToken;
41
42pub const DELTA_CHANNEL_CAPACITY: usize = 64;
44
45pub const DELTA_MAX_CHUNK_BYTES: usize = 64 * 1024;
48
49pub const DELTA_COALESCE_CAP_BYTES: usize = 64 * 1024;
51
52pub const DEFAULT_UI_PREVIEW_BYTES: usize = 256 * 1024;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct OutputBudgets {
67 pub ui_preview_bytes: usize,
69 pub model_history_bytes: usize,
71}
72
73impl OutputBudgets {
74 pub fn for_limits(max_tool_output: usize) -> Self {
77 Self {
78 ui_preview_bytes: DEFAULT_UI_PREVIEW_BYTES,
79 model_history_bytes: max_tool_output,
80 }
81 }
82
83 pub const fn max_ui_retained_bytes() -> u64 {
86 (DELTA_CHANNEL_CAPACITY * DELTA_MAX_CHUNK_BYTES + DELTA_COALESCE_CAP_BYTES) as u64
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct BoundedOutput {
95 pub text: String,
96 pub original_bytes: usize,
97 pub retained_bytes: usize,
98 pub truncated: bool,
99}
100
101pub fn bounded_preview(s: &str, max_bytes: usize) -> String {
105 let bounded = agent_core::BoundedText::new(s, max_bytes);
106 if !bounded.truncated {
107 return bounded.text;
108 }
109 format!(
110 "{}\n\n[preview truncated — {} of {} bytes shown]",
111 bounded.text, bounded.retained_bytes, bounded.original_bytes
112 )
113}
114
115#[derive(Debug, Clone)]
121pub struct SpillPolicy {
122 pub dir: PathBuf,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct SpillReport {
128 pub path: PathBuf,
129 pub bytes: u64,
130 pub failed: bool,
131}
132
133struct SpillState {
138 path: PathBuf,
139 dir: PathBuf,
140 file: Mutex<Option<std::fs::File>>,
141 opened: AtomicBool,
142 bytes: AtomicU64,
143 failed: AtomicBool,
144}
145
146impl std::fmt::Debug for SpillState {
147 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148 f.debug_struct("SpillState")
149 .field("path", &self.path)
150 .field("bytes", &self.bytes.load(Ordering::Relaxed))
151 .field("failed", &self.failed.load(Ordering::Relaxed))
152 .finish()
153 }
154}
155
156impl SpillState {
157 fn new(policy: SpillPolicy) -> Self {
158 static SPILL_SEQ: AtomicU64 = AtomicU64::new(0);
159 let seq = SPILL_SEQ.fetch_add(1, Ordering::Relaxed);
160 let path = policy
161 .dir
162 .join(format!("tool-output-{}-{seq}.log", std::process::id()));
163 Self {
164 path,
165 dir: policy.dir,
166 file: Mutex::new(None),
167 opened: AtomicBool::new(false),
168 bytes: AtomicU64::new(0),
169 failed: AtomicBool::new(false),
170 }
171 }
172
173 fn report(&self) -> SpillReport {
174 SpillReport {
175 path: self.path.clone(),
176 bytes: self.bytes.load(Ordering::Relaxed),
177 failed: self.failed.load(Ordering::Relaxed),
178 }
179 }
180
181 fn fail(&self, action: &str, err: &dyn std::fmt::Display) {
182 if !self.failed.swap(true, Ordering::Relaxed) {
183 tracing::warn!(
185 path = %self.path.display(),
186 error = %err,
187 "tool-output spill {action} failed — artifact disabled for this call"
188 );
189 }
190 }
191
192 fn write(&self, chunk: &str) {
193 if self.failed.load(Ordering::Relaxed) {
194 return;
195 }
196 let mut file = self
197 .file
198 .lock()
199 .unwrap_or_else(std::sync::PoisonError::into_inner);
200 if file.is_none() {
201 if self.opened.swap(true, Ordering::Relaxed) {
202 return; }
204 if let Err(err) = agent_core::core::private_fs::ensure_private_dir(&self.dir) {
205 self.fail("dir create", &err);
206 return;
207 }
208 match agent_core::core::private_fs::open_private_append(&self.path) {
209 Ok(handle) => *file = Some(handle),
210 Err(err) => {
211 self.fail("open", &err);
212 return;
213 }
214 }
215 }
216 if let Some(handle) = file.as_mut() {
217 use std::io::Write as _;
218 match handle.write_all(chunk.as_bytes()) {
219 Ok(()) => {
220 self.bytes.fetch_add(chunk.len() as u64, Ordering::Relaxed);
221 }
222 Err(err) => {
223 self.fail("write", &err);
224 *file = None;
225 }
226 }
227 }
228 }
229}
230
231#[derive(Debug, Clone)]
233pub struct SpillHandle {
234 state: Arc<SpillState>,
235}
236
237impl SpillHandle {
238 pub fn report(&self) -> SpillReport {
239 self.state.report()
240 }
241}
242
243#[derive(Debug, Default)]
248pub struct OutputCounters {
249 produced_chunks: AtomicU64,
250 produced_bytes: AtomicU64,
251 forwarded_chunks: AtomicU64,
252 forwarded_bytes: AtomicU64,
253 coalesced_chunks: AtomicU64,
254 coalesced_bytes: AtomicU64,
255 dropped_chunks: AtomicU64,
256 dropped_bytes: AtomicU64,
257 model_history_truncated_chunks: AtomicU64,
258 model_history_dropped_bytes: AtomicU64,
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
263pub struct OutputCountersSnapshot {
264 pub produced_chunks: u64,
266 pub produced_bytes: u64,
267 pub forwarded_chunks: u64,
269 pub forwarded_bytes: u64,
270 pub coalesced_chunks: u64,
273 pub coalesced_bytes: u64,
274 pub dropped_chunks: u64,
278 pub dropped_bytes: u64,
279 pub model_history_truncated_chunks: u64,
282 pub model_history_dropped_bytes: u64,
283}
284
285impl OutputCountersSnapshot {
286 pub fn retained_bytes(&self) -> u64 {
289 self.produced_bytes
290 .saturating_sub(self.forwarded_bytes)
291 .saturating_sub(self.dropped_bytes)
292 }
293}
294
295impl OutputCounters {
296 pub fn note_produced(&self, bytes: usize) {
297 self.produced_chunks.fetch_add(1, Ordering::Relaxed);
298 self.produced_bytes
299 .fetch_add(bytes as u64, Ordering::Relaxed);
300 }
301
302 pub fn note_forwarded(&self, bytes: usize) {
303 self.forwarded_chunks.fetch_add(1, Ordering::Relaxed);
304 self.forwarded_bytes
305 .fetch_add(bytes as u64, Ordering::Relaxed);
306 }
307
308 pub fn note_coalesced(&self, bytes: usize) {
309 self.coalesced_chunks.fetch_add(1, Ordering::Relaxed);
310 self.coalesced_bytes
311 .fetch_add(bytes as u64, Ordering::Relaxed);
312 }
313
314 pub fn note_dropped(&self, bytes: usize, chunks: u64) {
315 self.dropped_chunks.fetch_add(chunks, Ordering::Relaxed);
316 self.dropped_bytes
317 .fetch_add(bytes as u64, Ordering::Relaxed);
318 }
319
320 fn note_model_history_truncated(&self, bytes: usize) {
321 self.model_history_truncated_chunks
322 .fetch_add(1, Ordering::Relaxed);
323 self.model_history_dropped_bytes
324 .fetch_add(bytes as u64, Ordering::Relaxed);
325 }
326
327 pub fn snapshot(&self) -> OutputCountersSnapshot {
328 OutputCountersSnapshot {
329 produced_chunks: self.produced_chunks.load(Ordering::Relaxed),
330 produced_bytes: self.produced_bytes.load(Ordering::Relaxed),
331 forwarded_chunks: self.forwarded_chunks.load(Ordering::Relaxed),
332 forwarded_bytes: self.forwarded_bytes.load(Ordering::Relaxed),
333 coalesced_chunks: self.coalesced_chunks.load(Ordering::Relaxed),
334 coalesced_bytes: self.coalesced_bytes.load(Ordering::Relaxed),
335 dropped_chunks: self.dropped_chunks.load(Ordering::Relaxed),
336 dropped_bytes: self.dropped_bytes.load(Ordering::Relaxed),
337 model_history_truncated_chunks: self
338 .model_history_truncated_chunks
339 .load(Ordering::Relaxed),
340 model_history_dropped_bytes: self.model_history_dropped_bytes.load(Ordering::Relaxed),
341 }
342 }
343}
344
345struct DeltaShared {
348 overflow: Mutex<String>,
349 notify: tokio::sync::Notify,
350 counters: Arc<OutputCounters>,
351 spill: Option<Arc<SpillState>>,
352 model_history: Mutex<ModelHistoryState>,
353}
354
355#[derive(Debug)]
356struct ModelHistoryState {
357 text: String,
358 original_bytes: usize,
359 budget: usize,
360 truncated: bool,
361}
362
363impl ModelHistoryState {
364 fn new(budget: usize) -> Self {
365 Self {
366 text: String::with_capacity(budget.min(64 * 1024)),
367 original_bytes: 0,
368 budget,
369 truncated: false,
370 }
371 }
372
373 fn push(&mut self, chunk: &str, counters: &OutputCounters) {
374 self.original_bytes = self.original_bytes.saturating_add(chunk.len());
375 let room = self.budget.saturating_sub(self.text.len());
376 let bounded = agent_core::BoundedText::new(chunk, room);
377 self.text.push_str(&bounded.text);
378 if bounded.truncated {
379 self.truncated = true;
380 counters.note_model_history_truncated(
381 bounded
382 .original_bytes
383 .saturating_sub(bounded.retained_bytes),
384 );
385 }
386 }
387
388 fn snapshot(&self) -> BoundedOutput {
389 BoundedOutput {
390 text: self.text.clone(),
391 original_bytes: self.original_bytes,
392 retained_bytes: self.text.len(),
393 truncated: self.truncated,
394 }
395 }
396}
397
398pub struct DeltaSender {
402 tx: tokio::sync::mpsc::Sender<String>,
403 shared: Arc<DeltaShared>,
404}
405
406pub struct DeltaReceiver {
409 rx: tokio::sync::mpsc::Receiver<String>,
410 shared: Arc<DeltaShared>,
411}
412
413#[derive(Clone)]
416pub struct OutputHandle {
417 shared: Arc<DeltaShared>,
418}
419
420impl OutputHandle {
421 pub fn counters(&self) -> Arc<OutputCounters> {
422 Arc::clone(&self.shared.counters)
423 }
424
425 pub fn model_history(&self) -> BoundedOutput {
426 self.shared
427 .model_history
428 .lock()
429 .unwrap_or_else(std::sync::PoisonError::into_inner)
430 .snapshot()
431 }
432
433 pub fn spill_report(&self) -> Option<SpillReport> {
434 self.shared.spill.as_ref().map(|state| state.report())
435 }
436}
437
438pub struct ToolDeltaChannel {
440 pub sender: DeltaSender,
441 pub receiver: DeltaReceiver,
442 output: OutputHandle,
443}
444
445impl ToolDeltaChannel {
446 pub fn output_handle(&self) -> OutputHandle {
447 self.output.clone()
448 }
449}
450
451pub fn delta_channel() -> ToolDeltaChannel {
453 delta_channel_with_budgets(OutputBudgets::for_limits(DEFAULT_UI_PREVIEW_BYTES), None)
454}
455
456pub fn delta_channel_with(spill: Option<SpillPolicy>) -> ToolDeltaChannel {
458 delta_channel_with_budgets(OutputBudgets::for_limits(DEFAULT_UI_PREVIEW_BYTES), spill)
459}
460
461pub fn delta_channel_with_budgets(
463 budgets: OutputBudgets,
464 spill: Option<SpillPolicy>,
465) -> ToolDeltaChannel {
466 let (tx, rx) = tokio::sync::mpsc::channel(DELTA_CHANNEL_CAPACITY);
467 let shared = Arc::new(DeltaShared {
468 overflow: Mutex::new(String::new()),
469 notify: tokio::sync::Notify::new(),
470 counters: Arc::new(OutputCounters::default()),
471 spill: spill.map(|policy| Arc::new(SpillState::new(policy))),
472 model_history: Mutex::new(ModelHistoryState::new(budgets.model_history_bytes)),
473 });
474 ToolDeltaChannel {
475 sender: DeltaSender {
476 tx,
477 shared: Arc::clone(&shared),
478 },
479 receiver: DeltaReceiver {
480 rx,
481 shared: Arc::clone(&shared),
482 },
483 output: OutputHandle { shared },
484 }
485}
486
487fn coalesce_into(overflow: &mut String, chunk: &str, counters: &OutputCounters) {
490 let room = DELTA_COALESCE_CAP_BYTES.saturating_sub(overflow.len());
491 let bounded = agent_core::BoundedText::new(chunk, room);
492 if bounded.retained_bytes > 0 {
493 overflow.push_str(&bounded.text);
494 counters.note_coalesced(bounded.retained_bytes);
495 }
496 if bounded.truncated {
497 counters.note_dropped(bounded.original_bytes - bounded.retained_bytes, 1);
498 }
499}
500
501impl DeltaSender {
502 pub fn counters(&self) -> Arc<OutputCounters> {
504 Arc::clone(&self.shared.counters)
505 }
506
507 pub fn spill_handle(&self) -> Option<SpillHandle> {
509 self.shared.spill.as_ref().map(|state| SpillHandle {
510 state: Arc::clone(state),
511 })
512 }
513
514 pub fn send(&self, chunk: String) {
525 let counters = &self.shared.counters;
526 counters.note_produced(chunk.len());
527 if chunk.is_empty() {
528 return;
529 }
530 if let Some(spill) = &self.shared.spill {
531 spill.write(&chunk);
532 }
533 self.shared
534 .model_history
535 .lock()
536 .unwrap_or_else(std::sync::PoisonError::into_inner)
537 .push(&chunk, counters);
538 let chunk = if chunk.len() > DELTA_MAX_CHUNK_BYTES {
539 let bounded = agent_core::BoundedText::new(&chunk, DELTA_MAX_CHUNK_BYTES);
540 counters.note_dropped(bounded.original_bytes - bounded.retained_bytes, 1);
541 bounded.text
542 } else {
543 chunk
544 };
545 {
546 let mut overflow = self
547 .shared
548 .overflow
549 .lock()
550 .unwrap_or_else(std::sync::PoisonError::into_inner);
551 if !overflow.is_empty() {
552 coalesce_into(&mut overflow, &chunk, counters);
553 drop(overflow);
554 self.shared.notify.notify_one();
555 return;
556 }
557 }
558 match self.tx.try_send(chunk) {
559 Ok(()) => {}
560 Err(tokio::sync::mpsc::error::TrySendError::Full(chunk)) => {
561 let mut overflow = self
562 .shared
563 .overflow
564 .lock()
565 .unwrap_or_else(std::sync::PoisonError::into_inner);
566 coalesce_into(&mut overflow, &chunk, counters);
567 drop(overflow);
568 self.shared.notify.notify_one();
569 }
570 Err(tokio::sync::mpsc::error::TrySendError::Closed(chunk)) => {
571 counters.note_dropped(chunk.len(), 1);
572 }
573 }
574 }
575}
576
577impl DeltaReceiver {
578 pub fn counters(&self) -> Arc<OutputCounters> {
579 Arc::clone(&self.shared.counters)
580 }
581
582 fn drain_overflow(&self) -> Option<String> {
583 let mut overflow = self
584 .shared
585 .overflow
586 .lock()
587 .unwrap_or_else(std::sync::PoisonError::into_inner);
588 if overflow.is_empty() {
589 None
590 } else {
591 Some(std::mem::take(&mut *overflow))
592 }
593 }
594
595 pub fn try_drain(&mut self) -> Option<String> {
598 let mut batch = String::new();
599 while let Ok(chunk) = self.rx.try_recv() {
600 batch.push_str(&chunk);
601 }
602 if let Some(overflow) = self.drain_overflow() {
603 batch.push_str(&overflow);
604 }
605 if batch.is_empty() {
606 None
607 } else {
608 Some(batch)
609 }
610 }
611
612 pub async fn recv(&mut self) -> Option<String> {
615 loop {
616 if let Some(batch) = self.try_drain() {
617 return Some(batch);
618 }
619 tokio::select! {
620 maybe = self.rx.recv() => match maybe {
621 Some(first) => {
622 let mut batch = first;
623 while let Ok(chunk) = self.rx.try_recv() {
624 batch.push_str(&chunk);
625 }
626 if let Some(overflow) = self.drain_overflow() {
627 batch.push_str(&overflow);
628 }
629 return Some(batch);
630 }
631 None => return self.drain_overflow(),
632 },
633 _ = self.shared.notify.notified() => continue,
634 }
635 }
636 }
637}
638
639static ACTIVE_UI_FORWARDERS: AtomicUsize = AtomicUsize::new(0);
644
645pub fn active_ui_forwarder_count() -> usize {
647 ACTIVE_UI_FORWARDERS.load(Ordering::SeqCst)
648}
649
650pub fn spawn_ui_forwarder(
656 mut receiver: DeltaReceiver,
657 ui_budget_bytes: usize,
658 cancel: CancellationToken,
659 emit: impl Fn(String) + Send + 'static,
660) -> tokio::task::JoinHandle<()> {
661 ACTIVE_UI_FORWARDERS.fetch_add(1, Ordering::SeqCst);
662 tokio::spawn(async move {
663 struct Gauge;
665 impl Drop for Gauge {
666 fn drop(&mut self) {
667 ACTIVE_UI_FORWARDERS.fetch_sub(1, Ordering::SeqCst);
668 }
669 }
670 let _gauge = Gauge;
671 let counters = receiver.counters();
672 let mut forwarded_total: usize = 0;
673 loop {
674 tokio::select! {
675 biased;
676 _ = cancel.cancelled() => break,
677 maybe = receiver.recv() => match maybe {
678 None => break,
679 Some(batch) => {
680 let room = ui_budget_bytes.saturating_sub(forwarded_total);
681 let bounded = agent_core::BoundedText::new(&batch, room);
682 if bounded.retained_bytes > 0 {
683 forwarded_total += bounded.retained_bytes;
684 counters.note_forwarded(bounded.retained_bytes);
685 emit(bounded.text);
686 }
687 if bounded.truncated {
688 counters.note_dropped(
689 bounded.original_bytes - bounded.retained_bytes,
690 1,
691 );
692 }
693 }
694 },
695 }
696 }
697 let retained = counters.snapshot().retained_bytes();
701 if retained > 0 {
702 counters.note_dropped(retained as usize, 1);
703 }
704 })
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712 use std::time::Duration;
713
714 #[tokio::test]
718 async fn burst_without_consumer_bounds_retained_bytes_exactly() {
719 let channel = delta_channel();
720 let sender = channel.sender;
721 let counters = sender.counters();
722 let chunk = "x".repeat(1024);
723 for _ in 0..10_000 {
724 sender.send(chunk.clone());
725 }
726 let snap = counters.snapshot();
727 assert_eq!(snap.produced_bytes, 10_000 * 1024);
728 assert_eq!(snap.produced_chunks, 10_000);
729 assert_eq!(snap.forwarded_bytes, 0);
730 let ceiling = (DELTA_CHANNEL_CAPACITY * 1024 + DELTA_COALESCE_CAP_BYTES) as u64;
731 assert!(
732 snap.retained_bytes() <= ceiling,
733 "retained {} exceeds ceiling {ceiling}",
734 snap.retained_bytes()
735 );
736 assert!(snap.dropped_bytes > 0, "beyond-cap bytes must be dropped");
737 assert!(snap.coalesced_chunks > 0, "full channel must coalesce");
738 assert_eq!(
739 snap.produced_bytes,
740 snap.retained_bytes() + snap.forwarded_bytes + snap.dropped_bytes,
741 "accounting must be exact"
742 );
743 }
744
745 #[tokio::test]
749 async fn drained_output_is_an_ordered_prefix_of_production() {
750 let mut channel = delta_channel();
751 let mut full = String::new();
752 for i in 0..2_000 {
753 let chunk = format!("[chunk-{i:05}]");
754 full.push_str(&chunk);
755 channel.sender.send(chunk);
756 }
757 let mut received = String::new();
758 while let Some(batch) = channel.receiver.try_drain() {
759 received.push_str(&batch);
760 }
761 assert!(
762 full.starts_with(&received),
763 "received content must be an ordered prefix of production"
764 );
765 let snap = channel.sender.counters().snapshot();
766 assert_eq!(
767 received.len() as u64,
768 snap.produced_bytes - snap.dropped_bytes,
769 "drained bytes must equal produced minus dropped"
770 );
771 }
772
773 #[tokio::test]
775 async fn oversized_chunk_is_capped_at_production() {
776 let mut channel = delta_channel();
777 let big = "é".repeat(DELTA_MAX_CHUNK_BYTES); channel.sender.send(big.clone());
780 let batch = channel.receiver.try_drain().expect("prefix retained");
781 assert!(batch.len() <= DELTA_MAX_CHUNK_BYTES);
782 assert!(big.starts_with(&batch));
783 let snap = channel.sender.counters().snapshot();
784 assert_eq!(snap.dropped_bytes, (big.len() - batch.len()) as u64);
785 }
786
787 #[tokio::test]
790 async fn closed_channel_releases_producer_with_counted_drops() {
791 let channel = delta_channel();
792 drop(channel.receiver);
793 let counters = channel.sender.counters();
794 channel.sender.send("after close".to_string());
795 let snap = counters.snapshot();
796 assert_eq!(snap.dropped_bytes, "after close".len() as u64);
797 assert_eq!(snap.dropped_chunks, 1);
798 assert_eq!(snap.retained_bytes(), 0);
799 }
800
801 #[tokio::test]
804 async fn forwarder_enforces_ui_budget_and_ends_on_close() {
805 let channel = delta_channel();
806 let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
807 let handle = spawn_ui_forwarder(
808 channel.receiver,
809 10,
810 CancellationToken::new(),
811 move |batch| {
812 let _ = out_tx.send(batch);
813 },
814 );
815 channel.sender.send("hello ".to_string());
816 tokio::time::sleep(Duration::from_millis(50)).await;
819 channel.sender.send("world!!".to_string());
820 drop(channel.sender);
821 tokio::time::timeout(Duration::from_secs(5), handle)
822 .await
823 .expect("forwarder must terminate on close")
824 .expect("forwarder must not panic");
825 let mut emitted = String::new();
826 while let Ok(batch) = out_rx.try_recv() {
827 emitted.push_str(&batch);
828 }
829 assert_eq!(emitted.len(), 10, "UI budget must be exact");
830 assert!("hello world!!".starts_with(&emitted));
831 }
832
833 #[tokio::test]
837 async fn cancellation_closes_forwarder_and_releases_producer() {
838 let baseline = active_ui_forwarder_count();
839 let channel = delta_channel();
840 let cancel = CancellationToken::new();
841 let handle = spawn_ui_forwarder(channel.receiver, usize::MAX, cancel.clone(), |_| {});
842 channel.sender.send("before cancel".to_string());
843 cancel.cancel();
844 tokio::time::timeout(Duration::from_secs(5), handle)
845 .await
846 .expect("forwarder must terminate on cancellation")
847 .expect("forwarder must not panic");
848 let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
850 while active_ui_forwarder_count() > baseline && tokio::time::Instant::now() < deadline {
851 tokio::time::sleep(Duration::from_millis(10)).await;
852 }
853 assert!(
854 active_ui_forwarder_count() <= baseline,
855 "no forwarding task may remain alive after cancellation"
856 );
857 let counters = channel.sender.counters();
858 let before = counters.snapshot();
859 assert_eq!(
860 before.retained_bytes(),
861 0,
862 "cancellation must account every queued/coalesced byte as dropped"
863 );
864 channel.sender.send("after cancel".to_string());
865 let after = counters.snapshot();
866 assert_eq!(
867 after.dropped_bytes - before.dropped_bytes,
868 "after cancel".len() as u64,
869 "post-cancel sends must be released as counted drops"
870 );
871 }
872
873 #[tokio::test]
876 async fn ui_and_model_history_budgets_are_independent() {
877 let budgets = OutputBudgets {
878 ui_preview_bytes: 7,
879 model_history_bytes: 11,
880 };
881 let channel = delta_channel_with_budgets(budgets, None);
882 let output = channel.output_handle();
883 let (ui_tx, mut ui_rx) = tokio::sync::mpsc::unbounded_channel();
884 let forwarder = spawn_ui_forwarder(
885 channel.receiver,
886 budgets.ui_preview_bytes,
887 CancellationToken::new(),
888 move |chunk| {
889 let _ = ui_tx.send(chunk);
890 },
891 );
892 channel.sender.send("abcdefgh".to_string());
893 channel.sender.send("ijklmnop".to_string());
894 drop(channel.sender);
895 forwarder.await.expect("forwarder");
896
897 let mut ui = String::new();
898 while let Ok(chunk) = ui_rx.try_recv() {
899 ui.push_str(&chunk);
900 }
901 let history = output.model_history();
902 assert_eq!(ui, "abcdefg");
903 assert_eq!(history.text, "abcdefghijk");
904 assert!(history.truncated);
905 assert_eq!(history.original_bytes, 16);
906 assert_eq!(history.retained_bytes, 11);
907 }
908
909 #[tokio::test]
915 async fn synthetic_one_gib_production_is_bounded_without_materializing_full_output() {
916 const GIB: u64 = 1024 * 1024 * 1024;
917 const CHUNK: usize = 64 * 1024;
918 let budgets = OutputBudgets {
919 ui_preview_bytes: 1024,
920 model_history_bytes: 4096,
921 };
922 let channel = delta_channel_with_budgets(budgets, None);
923 let output = channel.output_handle();
924 let chunk = "x".repeat(CHUNK);
925 for _ in 0..(GIB / CHUNK as u64) {
926 channel.sender.send(chunk.clone());
927 }
928 let history = output.model_history();
929 let snap = output.counters().snapshot();
930 assert_eq!(history.original_bytes as u64, GIB);
931 assert_eq!(history.retained_bytes, budgets.model_history_bytes);
932 assert_eq!(snap.model_history_dropped_bytes, GIB - 4096);
933 assert_eq!(snap.model_history_truncated_chunks, GIB / CHUNK as u64);
934 assert!(snap.retained_bytes() <= OutputBudgets::max_ui_retained_bytes());
935 }
936
937 #[cfg(unix)]
938 #[test]
939 #[serial_test::serial(umask)]
940 fn optional_spill_is_0600_under_umask_000() {
941 use std::os::unix::fs::PermissionsExt as _;
942
943 struct Umask(libc::mode_t);
944 impl Drop for Umask {
945 fn drop(&mut self) {
946 unsafe {
947 libc::umask(self.0);
948 }
949 }
950 }
951 let guard = Umask(unsafe { libc::umask(0) });
952 let tmp = tempfile::TempDir::new().unwrap();
953 let channel = delta_channel_with_budgets(
954 OutputBudgets::for_limits(8),
955 Some(SpillPolicy {
956 dir: tmp.path().join("spill"),
957 }),
958 );
959 let output = channel.output_handle();
960 channel.sender.send("secret".into());
961 let report = output.spill_report().unwrap();
962 assert!(!report.failed);
963 assert_eq!(
964 std::fs::metadata(&report.path)
965 .unwrap()
966 .permissions()
967 .mode()
968 & 0o777,
969 0o600
970 );
971 assert_eq!(
972 std::fs::metadata(report.path.parent().unwrap())
973 .unwrap()
974 .permissions()
975 .mode()
976 & 0o777,
977 0o700
978 );
979 drop(guard);
980 }
981
982 #[cfg(unix)]
983 #[test]
984 fn optional_spill_refuses_planted_symlink_without_writing_victim() {
985 use std::os::unix::fs::symlink;
986
987 let tmp = tempfile::TempDir::new().unwrap();
988 let dir = tmp.path().join("spill");
989 std::fs::create_dir(&dir).unwrap();
990 let victim = tmp.path().join("victim");
991 std::fs::write(&victim, "unchanged").unwrap();
992 let channel =
993 delta_channel_with_budgets(OutputBudgets::for_limits(8), Some(SpillPolicy { dir }));
994 let output = channel.output_handle();
995 let target = output.spill_report().unwrap().path;
996 symlink(&victim, &target).unwrap();
997 channel.sender.send("must-not-write".into());
998 let report = output.spill_report().unwrap();
999 assert!(report.failed);
1000 assert_eq!(report.bytes, 0);
1001 assert_eq!(std::fs::read_to_string(victim).unwrap(), "unchanged");
1002 }
1003
1004 #[cfg(unix)]
1007 #[tokio::test]
1008 async fn optional_spill_is_exact_and_private_0600() {
1009 use std::os::unix::fs::PermissionsExt as _;
1010
1011 let tmp = tempfile::TempDir::new().expect("tempdir");
1012 let dir = tmp.path().join("spill");
1013 let channel = delta_channel_with_budgets(
1014 OutputBudgets {
1015 ui_preview_bytes: 2,
1016 model_history_bytes: 3,
1017 },
1018 Some(SpillPolicy { dir: dir.clone() }),
1019 );
1020 let output = channel.output_handle();
1021 channel.sender.send("hello".to_string());
1022 channel.sender.send("🌟world".to_string());
1023 let report = output.spill_report().expect("spill enabled");
1024
1025 assert!(!report.failed);
1026 assert_eq!(report.bytes, "hello🌟world".len() as u64);
1027 assert_eq!(
1028 std::fs::read_to_string(&report.path).unwrap(),
1029 "hello🌟world"
1030 );
1031 assert_eq!(
1032 std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777,
1033 0o700
1034 );
1035 assert_eq!(
1036 std::fs::metadata(&report.path)
1037 .unwrap()
1038 .permissions()
1039 .mode()
1040 & 0o777,
1041 0o600
1042 );
1043 }
1044}