Skip to main content

kaish_kernel/
output_limit.rs

1//! Configurable output size limits for agent safety.
2//!
3//! When output exceeds the threshold the result is capped and `ExecResult.out`
4//! is replaced with a head+tail preview. Two strategies, selected at runtime by
5//! [`SpillMode`]:
6//! - [`SpillMode::Disk`] (default): the full output is written to a spill file
7//!   on the real filesystem and the preview points at it. The agent can then
8//!   selectively read the file.
9//! - [`SpillMode::Memory`]: the output is truncated in memory only — no disk
10//!   I/O, no recoverable file. For runtime read-only kernels (e.g. kaibo) that
11//!   must not touch the host filesystem even when `localfs` is compiled in.
12//!   Memory stays bounded regardless of how much the command produces.
13//!
14//! Either way the exit code is remapped to 3 (`did_spill`) so callers can tell
15//! the output was capped.
16//!
17//! Per-mode defaults: sandboxed-agent kernels get an 8KB limit, REPL/test
18//! kernels are unlimited. Runtime-switchable via the `kaish-output-limit` builtin.
19
20use std::path::PathBuf;
21
22use crate::interpreter::ExecResult;
23#[cfg(feature = "localfs")]
24use crate::paths;
25
26/// Default output limit for the sandboxed-agent preset (8KB).
27const DEFAULT_AGENT_LIMIT: usize = 8 * 1024;
28
29/// Default head preview size (bytes of output start to keep).
30const DEFAULT_HEAD_BYTES: usize = 1024;
31
32/// Default tail preview size (bytes of output end to keep).
33const DEFAULT_TAIL_BYTES: usize = 512;
34
35/// Where overflow output goes when it exceeds the limit.
36///
37/// This is a *runtime* choice, distinct from the compile-time `localfs`
38/// feature: a `localfs`-built kernel can still be told to truncate in memory.
39/// A build without `localfs` always behaves as [`SpillMode::Memory`] regardless
40/// of this setting, since disk I/O is unavailable.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub enum SpillMode {
43    /// Write overflow to a disk spill file under `paths::spill_dir()` and keep a
44    /// head+tail preview in the result (the message carries the file path).
45    /// Requires the `localfs` feature. This is the default.
46    ///
47    /// Auto-overridden to [`Memory`](Self::Memory) at kernel construction when
48    /// the VFS mount is `NoLocal` (memory-only) — such a kernel has no host
49    /// filesystem to spill to. See `Kernel::assemble`.
50    #[default]
51    Disk,
52    /// Truncate in memory to head+tail only — no disk I/O, no recoverable file.
53    /// For runtime read-only kernels (e.g. kaibo) that must never touch the host
54    /// filesystem even when `localfs` is compiled in.
55    Memory,
56}
57
58/// Configurable output size limit.
59///
60/// Threaded through `KernelConfig` → `ExecContext` → kernel pipeline execution.
61/// Runtime-mutable via the `kaish-output-limit` builtin.
62#[derive(Debug, Clone)]
63pub struct OutputLimitConfig {
64    max_bytes: Option<usize>,
65    head_bytes: usize,
66    tail_bytes: usize,
67    spill_mode: SpillMode,
68}
69
70impl OutputLimitConfig {
71    /// No limiting — REPL/embedded/test default.
72    pub fn none() -> Self {
73        Self {
74            max_bytes: None,
75            head_bytes: DEFAULT_HEAD_BYTES,
76            tail_bytes: DEFAULT_TAIL_BYTES,
77            spill_mode: SpillMode::Disk,
78        }
79    }
80
81    /// Default limit used by `on` subcommand and `set -o output-limit`.
82    pub fn default_limit() -> usize {
83        DEFAULT_AGENT_LIMIT
84    }
85
86    /// Sandboxed-agent defaults: 8KB limit, 1KB head, 512B tail, disk spill.
87    pub fn agent() -> Self {
88        Self {
89            max_bytes: Some(DEFAULT_AGENT_LIMIT),
90            head_bytes: DEFAULT_HEAD_BYTES,
91            tail_bytes: DEFAULT_TAIL_BYTES,
92            spill_mode: SpillMode::Disk,
93        }
94    }
95
96    /// Switch to in-memory truncation — no disk spill, no host filesystem
97    /// writes. For runtime read-only kernels (e.g. kaibo). Builder form of
98    /// [`set_spill_mode`](Self::set_spill_mode).
99    ///
100    /// Note: a `NoLocal` VFS mount forces this mode automatically at kernel
101    /// construction, so an embedder only needs this for a `localfs`-mounted
102    /// kernel it nonetheless wants to keep off the host disk.
103    pub fn in_memory(mut self) -> Self {
104        self.spill_mode = SpillMode::Memory;
105        self
106    }
107
108    /// Whether output limiting is enabled.
109    pub fn is_enabled(&self) -> bool {
110        self.max_bytes.is_some()
111    }
112
113    /// The spill mode (disk vs in-memory truncation).
114    pub fn spill_mode(&self) -> SpillMode {
115        self.spill_mode
116    }
117
118    /// Set the spill mode.
119    pub fn set_spill_mode(&mut self, mode: SpillMode) {
120        self.spill_mode = mode;
121    }
122
123    /// The maximum output size in bytes, if set.
124    pub fn max_bytes(&self) -> Option<usize> {
125        self.max_bytes
126    }
127
128    /// Bytes of output head to preserve in truncated result.
129    pub fn head_bytes(&self) -> usize {
130        self.head_bytes
131    }
132
133    /// Bytes of output tail to preserve in truncated result.
134    pub fn tail_bytes(&self) -> usize {
135        self.tail_bytes
136    }
137
138    /// Set the output limit. `None` disables limiting.
139    pub fn set_limit(&mut self, max: Option<usize>) {
140        self.max_bytes = max;
141    }
142
143    /// Set the head preview size.
144    pub fn set_head_bytes(&mut self, bytes: usize) {
145        self.head_bytes = bytes;
146    }
147
148    /// Set the tail preview size.
149    pub fn set_tail_bytes(&mut self, bytes: usize) {
150        self.tail_bytes = bytes;
151    }
152}
153
154/// Result of a spill operation.
155pub struct SpillResult {
156    pub path: PathBuf,
157    pub total_bytes: usize,
158}
159
160/// Check if the result output exceeds the limit and spill to disk if so.
161///
162/// Mutates `result.out` in place: replaces with head+tail+pointer message.
163/// Returns `Some(SpillResult)` if a spill file was written, `None` otherwise.
164///
165/// If the filesystem write fails, the result is replaced with an error.
166/// Fail fast: truncating output silently could corrupt structured data
167/// that an agent acts on. An explicit error is safer.
168///
169/// In [`SpillMode::Memory`], or in any build without the `localfs` feature,
170/// performs in-memory head+tail truncation (no disk I/O) instead.
171pub async fn spill_if_needed(
172    result: &mut ExecResult,
173    config: &OutputLimitConfig,
174) -> Option<SpillResult> {
175    let max = config.max_bytes?;
176
177    // Remember whether a lower-level capture ring already overflowed before
178    // this check ran (kernel.rs's `try_execute_external` / dispatch.rs's
179    // test-only twin flip `did_spill` when the fixed ~10MB stdout ring evicts
180    // output, independent of whether this output-limit is even enabled). If
181    // so, `result`'s current bytes are ALREADY a ring-capped tail, not the
182    // command's full output — any disk spill performed below would be
183    // spilling that already-partial tail, so a "full output at <path>"
184    // message would mislead (GH #212 part 2). Only read by the disk-spill
185    // path below, which is itself `localfs`-only.
186    #[cfg(feature = "localfs")]
187    let ring_already_overflowed = result.did_spill;
188
189    // Binary payloads are measured and spilled by RAW bytes. text_out() would
190    // lossy-decode a Bytes result — corrupting the spill file and mis-measuring
191    // the size (U+FFFD is 3 bytes per invalid byte). Handle them up front.
192    if let Some(total) = result.out_bytes().map(|b| b.len()) {
193        if total <= max {
194            return None;
195        }
196        #[cfg(feature = "localfs")]
197        if config.spill_mode == SpillMode::Disk {
198            let bytes = result.out_bytes().unwrap_or_default().to_vec();
199            return match write_spill_file(&bytes).await {
200                Ok((path, written)) => {
201                    result.set_out(format!(
202                        "[binary output: {total} bytes spilled to {} — read it with `cat {}`]",
203                        path.display(),
204                        path.display()
205                    ));
206                    result.did_spill = true;
207                    Some(SpillResult { path, total_bytes: written })
208                }
209                Err(e) => {
210                    tracing::error!("binary output spill failed: {}", e);
211                    *result = ExecResult::failure(
212                        1,
213                        format!(
214                            "binary output exceeded {max} byte limit ({total} bytes) and spill \
215                             to disk failed: {e}"
216                        ),
217                    );
218                    None
219                }
220            };
221        }
222        // Memory mode (or no localfs): bounded head+tail of the raw bytes. The
223        // result stays binary, just truncated.
224        let bytes = result.out_bytes().unwrap_or_default().to_vec();
225        let head_n = config.head_bytes.min(bytes.len());
226        let tail_n = config.tail_bytes.min(bytes.len().saturating_sub(head_n));
227        let mut truncated = bytes[..head_n].to_vec();
228        truncated.extend_from_slice(&bytes[bytes.len() - tail_n..]);
229        result.set_out_bytes(truncated);
230        result.did_spill = true;
231        return None;
232    }
233
234    // Disk spill requires `localfs` AND the caller selecting it. Memory mode
235    // (or a build without `localfs`) falls through to in-memory truncation.
236    #[cfg(feature = "localfs")]
237    if config.spill_mode == SpillMode::Disk {
238        // If result.out is already populated (external commands), check it directly
239        if !result.text_out().is_empty() && !result.has_output() {
240            let total = result.text_out().len();
241            if total <= max {
242                return None;
243            }
244            return spill_string(result, config, max, ring_already_overflowed).await;
245        }
246
247        // If we have structured OutputData, estimate size before materializing
248        if let Some(output) = result.output() {
249            let estimate = output.estimated_byte_size();
250            if estimate <= max {
251                // Small enough — materialize normally
252                result.materialize();
253                // Re-check actual size (estimate is a lower bound)
254                if result.text_out().len() <= max {
255                    return None;
256                }
257                return spill_string(result, config, max, ring_already_overflowed).await;
258            }
259
260            // Large — stream directly to spill file, never holding full String
261            return spill_output_data(result, config, max, ring_already_overflowed).await;
262        }
263
264        return None;
265    }
266
267    // In-memory head+tail truncation (Memory mode or no `localfs`): no disk I/O.
268    truncate_in_memory(result, config, max)
269}
270
271/// Apply the kernel's spill/exit-3 contract to a raw `ExecResult`: run the
272/// output-limit spill check when enabled, then remap the exit code to 3
273/// whenever `did_spill` ends up set — whether `spill_if_needed` just set it,
274/// or a lower-level capture-ring overflow already had (kernel.rs's
275/// `try_execute_external`, GH #191).
276///
277/// This is the ONE seam every execution surface that produces a raw
278/// `ExecResult` — the foreground pipeline (`Kernel::execute_pipeline`), a
279/// background job (`Kernel::execute_background`), a scatter worker
280/// (`run_parallel`) — must funnel through before treating that result as
281/// final. Without it, spilled output silently reports its ORIGINAL exit code
282/// instead of the loud 3, and a downstream success/failure decision (a
283/// background job's `JobStatus`, gather's 0-vs-123 aggregation) reads the
284/// wrong thing (GH #212).
285pub async fn apply_spill_contract(result: &mut ExecResult, config: &OutputLimitConfig) {
286    if config.is_enabled() {
287        let _ = spill_if_needed(result, config).await;
288    }
289    if result.did_spill {
290        // Idempotent: only capture `original_code` the first time. A result can
291        // arrive already remapped — backend conversions preserve both fields
292        // (`kaish-types/src/backend.rs`), so a pre-remapped result is supported
293        // input, not impossible state. Overwriting unconditionally replaced a
294        // real `Some(7)` with `Some(3)` and lost the actual exit code.
295        if result.original_code.is_none() {
296            result.original_code = Some(result.code);
297        }
298        result.code = 3;
299    }
300}
301
302/// Truncate output in memory to head+tail, with no disk I/O.
303///
304/// Sets `did_spill = true` so the kernel remaps the exit code to 3 — the same
305/// "output was capped" signal as a disk spill — but the message carries no file
306/// path because there is no recoverable file. Returns `None` (no `SpillResult`,
307/// since nothing was written); the caller distinguishes truncation via
308/// `result.did_spill`.
309///
310/// Memory is bounded: large structured `OutputData` is streamed through a byte
311/// budget rather than materialized into a full `String`, so a builtin emitting
312/// a huge tree (e.g. a recursive `ls` of a giant directory) cannot OOM a
313/// read-only kernel.
314fn truncate_in_memory(
315    result: &mut ExecResult,
316    config: &OutputLimitConfig,
317    max: usize,
318) -> Option<SpillResult> {
319    // Structured OutputData: estimate first. If it would clearly overflow,
320    // render only a bounded head prefix via `write_canonical` rather than
321    // materializing the whole thing.
322    if let Some(output) = result.output() {
323        let estimate = output.estimated_byte_size();
324        if estimate > max {
325            // Render a bounded head prefix only — no full materialization.
326            let mut buf = Vec::with_capacity(config.head_bytes + 64);
327            // write_canonical stops shortly after the budget; ignore the count.
328            let _ = output.write_canonical(&mut buf, Some(config.head_bytes));
329            let s = String::from_utf8_lossy(&buf);
330            let head = truncate_to_char_boundary(&s, config.head_bytes);
331            let truncated = format!(
332                "{}\n...\n[output truncated in memory: ~{} bytes (exceeds {} byte limit) — head only, no spill file]",
333                head, estimate, max
334            );
335            result.set_out(truncated);
336            result.did_spill = true;
337            return None;
338        }
339        // Small enough to materialize safely.
340        result.materialize();
341    }
342
343    let total = result.text_out().len();
344    if total <= max {
345        return None;
346    }
347
348    // Already-materialized text fits in memory (it was produced into RAM
349    // regardless) — give a precise head+tail+total.
350    let text = result.text_out().into_owned();
351    let head = truncate_to_char_boundary(&text, config.head_bytes);
352    let tail = tail_from_str(&text, config.tail_bytes);
353    let truncated = format!(
354        "{}\n...\n{}\n[output truncated in memory: {} bytes total — no spill file]",
355        head, tail, total
356    );
357    result.set_out(truncated);
358    result.did_spill = true;
359    None
360}
361
362/// Spill an already-materialized string in result.out.
363///
364/// `ring_already_overflowed` is `true` when a lower-level capture ring had
365/// already flipped `did_spill` before this check ran (see `spill_if_needed`)
366/// — the message then must not claim the spill file holds the "full output",
367/// since it only holds the ring-capped tail (GH #212 part 2).
368#[cfg(feature = "localfs")]
369async fn spill_string(
370    result: &mut ExecResult,
371    config: &OutputLimitConfig,
372    max: usize,
373    ring_already_overflowed: bool,
374) -> Option<SpillResult> {
375    let total = result.text_out().len();
376    match write_spill_file(result.text_out().as_bytes()).await {
377        Ok((path, written)) => {
378            let truncated =
379                build_truncated_output(&result.text_out(), config, &path, total, ring_already_overflowed);
380            result.set_out(truncated);
381            result.did_spill = true;
382            Some(SpillResult {
383                path,
384                total_bytes: written,
385            })
386        }
387        Err(e) => {
388            tracing::error!("output spill failed: {}", e);
389            *result = ExecResult::failure(1, format!(
390                "output exceeded {} byte limit ({} bytes) and spill to disk failed: {}",
391                max, total, e
392            ));
393            None
394        }
395    }
396}
397
398/// Stream OutputData directly to a spill file without materializing the full String.
399///
400/// `ring_already_overflowed` — see `spill_string`'s doc comment; same caveat
401/// applies to the "full output at" message built below.
402#[cfg(feature = "localfs")]
403async fn spill_output_data(
404    result: &mut ExecResult,
405    config: &OutputLimitConfig,
406    max: usize,
407    ring_already_overflowed: bool,
408) -> Option<SpillResult> {
409    let output = result.output()?;
410
411    let dir = paths::spill_dir();
412    if let Err(e) = tokio::fs::create_dir_all(&dir).await {
413        tracing::error!("output spill dir creation failed: {}", e);
414        *result = ExecResult::failure(1, format!(
415            "output exceeded {} byte limit and spill dir creation failed: {}", max, e
416        ));
417        return None;
418    }
419
420    let filename = generate_spill_filename();
421    let path = dir.join(&filename);
422
423    // Write OutputData directly to file via write_canonical
424    let total = match std::fs::File::create(&path) {
425        Ok(mut file) => {
426            match output.write_canonical(&mut file, None) {
427                Ok(n) => n,
428                Err(e) => {
429                    tracing::error!("output spill write failed: {}", e);
430                    *result = ExecResult::failure(1, format!(
431                        "output exceeded {} byte limit and spill to disk failed: {}", max, e
432                    ));
433                    return None;
434                }
435            }
436        }
437        Err(e) => {
438            tracing::error!("output spill file creation failed: {}", e);
439            *result = ExecResult::failure(1, format!(
440                "output exceeded {} byte limit and spill to disk failed: {}", max, e
441            ));
442            return None;
443        }
444    };
445
446    // Read head and tail from the spill file for the truncated preview
447    let head = read_head_from_file(&path, config.head_bytes).await.unwrap_or_default();
448    let tail = read_tail_from_file(&path, config.tail_bytes).await.unwrap_or_default();
449    let path_str = path.to_string_lossy();
450
451    result.set_out(format!(
452        "{}\n...\n{}\n[output truncated: {}]",
453        head, tail, spill_summary(total, ring_already_overflowed, &path_str)
454    ));
455    result.did_spill = true;
456
457    Some(SpillResult {
458        path,
459        total_bytes: total,
460    })
461}
462
463/// Write output bytes to a new spill file. Returns (path, bytes_written).
464#[cfg(feature = "localfs")]
465async fn write_spill_file(data: &[u8]) -> Result<(PathBuf, usize), std::io::Error> {
466    let dir = paths::spill_dir();
467    tokio::fs::create_dir_all(&dir).await?;
468
469    let filename = generate_spill_filename();
470    let path = dir.join(filename);
471    tokio::fs::write(&path, data).await?;
472    Ok((path, data.len()))
473}
474
475/// The whole body of the `[output truncated: ...]` message: the byte count,
476/// the word that qualifies it, and the pointer at the spill file.
477///
478/// The count and its qualifier are built together on purpose. `total` is the
479/// number of bytes this spill wrote, which is the command's whole output only
480/// when no capture ring evicted anything first (GH #212 part 2 — see
481/// `spill_if_needed`'s `ring_already_overflowed`). After a ring overflow the
482/// same number is just what survived, so the message says "captured" and sends
483/// the reader to the stderr overflow marker, which reports the true size.
484/// Splitting the number from its qualifier is how the two branches drifted into
485/// contradicting each other the first time.
486#[cfg(feature = "localfs")]
487fn spill_summary(total: usize, ring_already_overflowed: bool, path: &str) -> String {
488    if ring_already_overflowed {
489        format!(
490            "{total} bytes captured — tail only at {path}; earlier output was dropped \
491             before the spill, so see the stderr overflow marker for the true size"
492        )
493    } else {
494        format!("{total} bytes total — full output at {path}")
495    }
496}
497
498/// Build the truncated output string with head, tail, and pointer.
499#[cfg(feature = "localfs")]
500fn build_truncated_output(
501    full: &str,
502    config: &OutputLimitConfig,
503    spill_path: &std::path::Path,
504    total_bytes: usize,
505    ring_already_overflowed: bool,
506) -> String {
507    let head = truncate_to_char_boundary(full, config.head_bytes);
508    let tail = tail_from_str(full, config.tail_bytes);
509    let path_str = spill_path.to_string_lossy();
510    format!(
511        "{}\n...\n{}\n[output truncated: {}]",
512        head, tail, spill_summary(total_bytes, ring_already_overflowed, &path_str)
513    )
514}
515
516/// Truncate a string to at most `max_bytes`, respecting UTF-8 char boundaries.
517fn truncate_to_char_boundary(s: &str, max_bytes: usize) -> &str {
518    if s.len() <= max_bytes {
519        return s;
520    }
521    // Find the last char boundary at or before max_bytes
522    let mut end = max_bytes;
523    while end > 0 && !s.is_char_boundary(end) {
524        end -= 1;
525    }
526    &s[..end]
527}
528
529/// Get the last `max_bytes` of a string, respecting UTF-8 char boundaries.
530fn tail_from_str(s: &str, max_bytes: usize) -> &str {
531    if s.len() <= max_bytes {
532        return s;
533    }
534    let start = s.len() - max_bytes;
535    let mut adjusted = start;
536    while adjusted < s.len() && !s.is_char_boundary(adjusted) {
537        adjusted += 1;
538    }
539    &s[adjusted..]
540}
541
542/// Read the first N bytes from a file for head preview.
543#[cfg(feature = "localfs")]
544async fn read_head_from_file(path: &std::path::Path, max_bytes: usize) -> Result<String, std::io::Error> {
545    use tokio::io::AsyncReadExt;
546
547    let mut file = tokio::fs::File::open(path).await?;
548    let mut buf = vec![0u8; max_bytes];
549    let n = file.read(&mut buf).await?;
550    buf.truncate(n);
551
552    let s = String::from_utf8_lossy(&buf);
553    // Truncate to char boundary
554    let result = truncate_to_char_boundary(&s, max_bytes);
555    Ok(result.to_string())
556}
557
558/// Read the last N bytes from a file for tail preview.
559#[cfg(feature = "localfs")]
560async fn read_tail_from_file(path: &std::path::Path, max_bytes: usize) -> Result<String, std::io::Error> {
561    use tokio::io::{AsyncReadExt, AsyncSeekExt};
562
563    let mut file = tokio::fs::File::open(path).await?;
564    let metadata = file.metadata().await?;
565    let len = metadata.len() as usize;
566
567    if len <= max_bytes {
568        let mut buf = Vec::new();
569        file.read_to_end(&mut buf).await?;
570        return Ok(String::from_utf8_lossy(&buf).into_owned());
571    }
572
573    let offset = len - max_bytes;
574    file.seek(std::io::SeekFrom::Start(offset as u64)).await?;
575    let mut buf = vec![0u8; max_bytes];
576    let n = file.read(&mut buf).await?;
577    buf.truncate(n);
578
579    // Adjust to char boundary
580    let s = String::from_utf8_lossy(&buf);
581    Ok(s.into_owned())
582}
583
584/// Generate a unique spill filename using timestamp, PID, and monotonic counter.
585#[cfg(feature = "localfs")]
586fn generate_spill_filename() -> String {
587    use std::sync::atomic::{AtomicUsize, Ordering};
588    use std::time::SystemTime;
589
590    static COUNTER: AtomicUsize = AtomicUsize::new(0);
591    let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
592    let ts = SystemTime::now()
593        .duration_since(SystemTime::UNIX_EPOCH)
594        .unwrap_or_default();
595    let pid = std::process::id();
596    format!("spill-{}.{}-{}-{}.txt", ts.as_secs(), ts.subsec_nanos(), pid, seq)
597}
598
599/// Parse a size string with optional K/M suffix into bytes.
600///
601/// Accepts: "64K", "64k", "1M", "1m", "65536" (raw bytes).
602pub fn parse_size(s: &str) -> Result<usize, String> {
603    let s = s.trim();
604    if s.is_empty() {
605        return Err("empty size string".to_string());
606    }
607
608    let (num_str, multiplier) = if let Some(n) = s.strip_suffix('K').or_else(|| s.strip_suffix('k')) {
609        (n, 1024)
610    } else if let Some(n) = s.strip_suffix('M').or_else(|| s.strip_suffix('m')) {
611        (n, 1024 * 1024)
612    } else {
613        (s, 1)
614    };
615
616    let num: usize = num_str
617        .parse()
618        .map_err(|_| format!("invalid size: {}", s))?;
619
620    Ok(num * multiplier)
621}
622
623#[cfg(all(test, feature = "localfs"))]
624mod tests {
625    use super::*;
626
627    #[test]
628    fn test_none_is_disabled() {
629        let config = OutputLimitConfig::none();
630        assert!(!config.is_enabled());
631        assert_eq!(config.max_bytes(), None);
632    }
633
634    #[test]
635    fn test_agent_preset_is_enabled() {
636        let config = OutputLimitConfig::agent();
637        assert!(config.is_enabled());
638        assert_eq!(config.max_bytes(), Some(8 * 1024));
639        assert_eq!(config.head_bytes(), 1024);
640        assert_eq!(config.tail_bytes(), 512);
641    }
642
643    #[test]
644    fn test_set_limit() {
645        let mut config = OutputLimitConfig::none();
646        assert!(!config.is_enabled());
647
648        config.set_limit(Some(1024));
649        assert!(config.is_enabled());
650        assert_eq!(config.max_bytes(), Some(1024));
651
652        config.set_limit(None);
653        assert!(!config.is_enabled());
654    }
655
656    #[test]
657    fn test_set_head_tail() {
658        let mut config = OutputLimitConfig::agent();
659        config.set_head_bytes(2048);
660        config.set_tail_bytes(1024);
661        assert_eq!(config.head_bytes(), 2048);
662        assert_eq!(config.tail_bytes(), 1024);
663    }
664
665    #[test]
666    fn test_parse_size() {
667        assert_eq!(parse_size("64K").unwrap(), 64 * 1024);
668        assert_eq!(parse_size("64k").unwrap(), 64 * 1024);
669        assert_eq!(parse_size("1M").unwrap(), 1024 * 1024);
670        assert_eq!(parse_size("1m").unwrap(), 1024 * 1024);
671        assert_eq!(parse_size("65536").unwrap(), 65536);
672        assert!(parse_size("").is_err());
673        assert!(parse_size("abc").is_err());
674    }
675
676    #[test]
677    fn test_truncate_to_char_boundary() {
678        assert_eq!(truncate_to_char_boundary("hello", 10), "hello");
679        assert_eq!(truncate_to_char_boundary("hello", 3), "hel");
680        // Multi-byte: "日" is 3 bytes
681        assert_eq!(truncate_to_char_boundary("日本語", 3), "日");
682        assert_eq!(truncate_to_char_boundary("日本語", 4), "日");
683        assert_eq!(truncate_to_char_boundary("日本語", 6), "日本");
684    }
685
686    #[test]
687    fn test_tail_from_str() {
688        assert_eq!(tail_from_str("hello", 10), "hello");
689        assert_eq!(tail_from_str("hello", 3), "llo");
690        // Multi-byte
691        assert_eq!(tail_from_str("日本語", 3), "語");
692        assert_eq!(tail_from_str("日本語", 6), "本語");
693    }
694
695    #[test]
696    fn test_generate_spill_filename() {
697        let name = generate_spill_filename();
698        assert!(name.starts_with("spill-"));
699        assert!(name.ends_with(".txt"));
700    }
701
702    #[tokio::test]
703    async fn test_spill_if_needed_under_limit() {
704        let config = OutputLimitConfig::agent();
705        let mut result = ExecResult::success("short output");
706        let spill = spill_if_needed(&mut result, &config).await;
707        assert!(spill.is_none());
708        assert_eq!(&*result.text_out(), "short output");
709        assert!(!result.did_spill);
710    }
711
712    #[tokio::test]
713    async fn test_spill_if_needed_over_limit() {
714        let config = OutputLimitConfig {
715            max_bytes: Some(100),
716            head_bytes: 20,
717            tail_bytes: 10,
718            spill_mode: SpillMode::Disk,
719        };
720        let big_output = "x".repeat(200);
721        let mut result = ExecResult::success(big_output);
722        let spill = spill_if_needed(&mut result, &config).await;
723        assert!(spill.is_some());
724        assert!(result.did_spill);
725
726        let spill = spill.unwrap();
727        assert_eq!(spill.total_bytes, 200);
728        assert!(spill.path.exists());
729
730        // Verify truncated output
731        assert!(result.text_out().contains("..."));
732        assert!(result.text_out().contains("[output truncated: 200 bytes total"));
733        assert!(result.text_out().contains(&spill.path.to_string_lossy().to_string()));
734
735        // Verify head (first 20 bytes)
736        assert!(result.text_out().starts_with(&"x".repeat(20)));
737
738        // Verify spill file has full content
739        let spill_content = tokio::fs::read_to_string(&spill.path).await.unwrap();
740        assert_eq!(spill_content.len(), 200);
741
742        // Clean up
743        let _ = tokio::fs::remove_file(&spill.path).await;
744    }
745
746    #[tokio::test]
747    async fn test_spill_if_needed_disabled() {
748        let config = OutputLimitConfig::none();
749        let big_output = "x".repeat(200);
750        let mut result = ExecResult::success(big_output.clone());
751        let spill = spill_if_needed(&mut result, &config).await;
752        assert!(spill.is_none());
753        assert_eq!(&*result.text_out(), big_output);
754        assert!(!result.did_spill);
755    }
756
757    // GH #212 part 2: when a lower-level capture ring already overflowed
758    // (did_spill already true, e.g. kernel.rs's try_execute_external stdout
759    // ring, GH #191) BEFORE this enabled-limit spill runs, the file it writes
760    // is itself only the ring-capped tail — the message must say so instead
761    // of claiming "full output at <path>".
762    #[tokio::test]
763    async fn test_spill_if_needed_tunes_message_when_ring_already_overflowed() {
764        let config = OutputLimitConfig {
765            max_bytes: Some(100),
766            head_bytes: 20,
767            tail_bytes: 10,
768            spill_mode: SpillMode::Disk,
769        };
770        let big_output = "x".repeat(200);
771        let mut result = ExecResult::success(big_output).with_code(0);
772        // Simulate the ring having already overflowed before this check runs.
773        result.did_spill = true;
774
775        let spill = spill_if_needed(&mut result, &config).await;
776        let spill = spill.expect("still over the limit, must spill");
777        assert!(
778            !result.text_out().contains("full output at"),
779            "must not claim the spill file is the full output when the ring \
780             already truncated it: {}",
781            result.text_out()
782        );
783        // The byte count must be qualified as "captured", never "total": in
784        // this branch it is only what survived the ring, and the message goes
785        // on to say the true size is elsewhere.
786        assert!(
787            !result.text_out().contains("bytes total"),
788            "the count is not the total after a ring overflow: {}",
789            result.text_out()
790        );
791        assert!(
792            result.text_out().contains("bytes captured — tail only at"),
793            "should name the tail-only nature of the spill file: {}",
794            result.text_out()
795        );
796        let _ = tokio::fs::remove_file(&spill.path).await;
797    }
798
799    // GH #212: `apply_spill_contract` is the one seam every execution surface
800    // (foreground pipeline, background job, scatter worker) must funnel a raw
801    // `ExecResult` through — these two tests pin its two responsibilities
802    // directly, independent of any particular surface's plumbing.
803
804    #[tokio::test]
805    async fn apply_spill_contract_remaps_exit_code_when_spill_if_needed_flips_did_spill() {
806        // Memory mode: no disk I/O, nothing to clean up (CLAUDE.md: no real
807        // system paths in tests) — the exit-code remap is identical either way.
808        let config = OutputLimitConfig {
809            max_bytes: Some(100),
810            head_bytes: 20,
811            tail_bytes: 10,
812            spill_mode: SpillMode::Memory,
813        };
814        let mut result = ExecResult::success("x".repeat(200));
815        apply_spill_contract(&mut result, &config).await;
816
817        assert!(result.did_spill);
818        assert_eq!(result.code, 3, "a spill must remap to exit 3");
819        assert_eq!(result.original_code, Some(0), "original exit code preserved");
820    }
821
822    #[tokio::test]
823    async fn apply_spill_contract_remaps_exit_code_when_did_spill_already_set_and_limit_disabled() {
824        // Mirrors the disabled-limit ring-overflow path (GH #191): a lower
825        // level already flipped `did_spill` with the ORIGINAL exit code still
826        // in place, and the output limit itself is disabled (so
827        // `spill_if_needed` never runs). The remap must still happen.
828        let config = OutputLimitConfig::none();
829        let mut result = ExecResult::success("small").with_code(0);
830        result.did_spill = true;
831
832        apply_spill_contract(&mut result, &config).await;
833
834        assert_eq!(result.code, 3, "did_spill set anywhere must remap to exit 3");
835        assert_eq!(result.original_code, Some(0));
836    }
837
838    #[tokio::test]
839    async fn apply_spill_contract_is_idempotent_and_keeps_the_first_original_code() {
840        // A result can reach this seam already remapped — backend conversions
841        // preserve `did_spill`/`original_code` (kaish-types/src/backend.rs), so
842        // an embedder's pre-remapped result is supported input. Applying the
843        // contract twice used to overwrite `Some(7)` with `Some(3)`, destroying
844        // the real exit code the caller needed to explain the failure.
845        let config = OutputLimitConfig::none();
846        let mut result = ExecResult::success("spilled").with_code(7);
847        result.did_spill = true;
848
849        apply_spill_contract(&mut result, &config).await;
850        assert_eq!(result.code, 3);
851        assert_eq!(result.original_code, Some(7), "first pass captures the real code");
852
853        // Second pass: still 3, and the ORIGINAL 7 survives.
854        apply_spill_contract(&mut result, &config).await;
855        assert_eq!(result.code, 3, "still remapped");
856        assert_eq!(
857            result.original_code,
858            Some(7),
859            "a second pass must not overwrite the captured original with 3"
860        );
861    }
862
863    #[test]
864    fn test_build_truncated_output() {
865        let config = OutputLimitConfig {
866            max_bytes: Some(100),
867            head_bytes: 5,
868            tail_bytes: 3,
869            spill_mode: SpillMode::Disk,
870        };
871        let full = "abcdefghijklmnop";
872        let path = PathBuf::from("/tmp/test-spill.txt");
873        let result = build_truncated_output(full, &config, &path, 16, false);
874        assert!(result.starts_with("abcde"));
875        assert!(result.contains("..."));
876        assert!(result.contains("nop"));
877        assert!(result.contains("[output truncated: 16 bytes total — full output at /tmp/test-spill.txt]"));
878    }
879
880    // GH #212 part 2: a spill that runs AFTER a lower-level capture-ring
881    // overflow already flipped `did_spill` must not claim its spill file
882    // holds the "full output" — it only holds the ring-capped tail.
883    #[test]
884    fn test_build_truncated_output_tunes_message_when_ring_already_overflowed() {
885        let config = OutputLimitConfig {
886            max_bytes: Some(100),
887            head_bytes: 5,
888            tail_bytes: 3,
889            spill_mode: SpillMode::Disk,
890        };
891        let full = "abcdefghijklmnop";
892        let path = PathBuf::from("/tmp/test-spill.txt");
893        let result = build_truncated_output(full, &config, &path, 16, true);
894        assert!(
895            !result.contains("full output at"),
896            "must not claim the spill file is the full output when the ring already \
897             truncated it: {result}"
898        );
899        assert!(
900            result.contains(
901                "[output truncated: 16 bytes captured — tail only at /tmp/test-spill.txt; \
902                 earlier output was dropped before the spill, so see the stderr overflow \
903                 marker for the true size]"
904            ),
905            "the count must read as captured-not-total and point at the true size: {result}"
906        );
907    }
908
909    #[tokio::test]
910    async fn test_kernel_agent_truncates_large_output() {
911        use crate::kernel::{Kernel, KernelConfig};
912
913        // agent preset has 8K limit by default — use a smaller limit for testing
914        let config = KernelConfig::agent()
915            .with_output_limit(OutputLimitConfig {
916                max_bytes: Some(200),
917                head_bytes: 50,
918                tail_bytes: 30,
919                spill_mode: SpillMode::Disk,
920            });
921        let kernel = Kernel::new(config).expect("kernel creation");
922
923        // seq 1 10000 produces lots of output
924        let result = kernel.execute("seq 1 10000").await.expect("execute");
925        assert!(result.text_out().contains("[output truncated:"));
926        assert!(result.text_out().contains("full output at"));
927        // Head should contain the first numbers
928        assert!(result.text_out().starts_with("1\n"));
929    }
930
931    /// GH #177: every other Disk-mode test in this module re-specifies
932    /// `spill_mode: SpillMode::Disk` explicitly, so none of them actually pin
933    /// that a host-backed (`Sandboxed`) kernel's *untouched* default — no
934    /// `.with_output_limit()` override at all — is `Disk`. This is the literal
935    /// "`Kernel::new` without `.in_memory()`" scenario the issue names. The
936    /// forcing logic in `Kernel::assemble` (`no_host_side_channel`) must leave
937    /// a `Sandboxed` kernel's config alone; only `NoLocal`/`with_backend`
938    /// override it to `Memory`.
939    #[tokio::test]
940    async fn test_agent_kernel_unmodified_default_spills_to_disk() {
941        use crate::kernel::{Kernel, KernelConfig};
942
943        // Untouched: OutputLimitConfig::agent() — 8K limit, SpillMode::Disk.
944        let config = KernelConfig::agent();
945        assert_eq!(config.output_limit.spill_mode(), SpillMode::Disk);
946        let kernel = Kernel::new(config).expect("kernel creation");
947
948        let big = "x".repeat(8 * 1024 + 200);
949        let result = kernel.execute(&format!("echo '{}'", big)).await.expect("execute");
950        assert_eq!(result.code, 3, "default 8K agent limit should trip the spill");
951        assert!(
952            result.text_out().contains("full output at"),
953            "an unmodified agent() default must spill to a real file, not truncate in \
954             memory: {}",
955            result.text_out()
956        );
957    }
958
959    #[tokio::test]
960    async fn test_spill_exits_3() {
961        use crate::kernel::{Kernel, KernelConfig};
962
963        let config = KernelConfig::agent()
964            .with_output_limit(OutputLimitConfig {
965                max_bytes: Some(100),
966                head_bytes: 30,
967                tail_bytes: 20,
968                spill_mode: SpillMode::Disk,
969            });
970        let kernel = Kernel::new(config).expect("kernel creation");
971
972        let big = "x".repeat(200);
973        let result = kernel.execute(&format!("echo '{}'", big)).await.expect("execute");
974        assert_eq!(result.code, 3, "spill should always exit 3");
975        assert_eq!(result.original_code, Some(0), "original command exit code preserved");
976        assert!(result.text_out().contains("[output truncated:"));
977    }
978
979    #[tokio::test]
980    async fn test_kernel_repl_no_truncation() {
981        use crate::kernel::{Kernel, KernelConfig};
982
983        // REPL has no limit
984        let config = KernelConfig::repl();
985        let kernel = Kernel::new(config).expect("kernel creation");
986
987        let result = kernel.execute("seq 1 100").await.expect("execute");
988        assert!(!result.text_out().contains("[output truncated:"));
989        assert!(result.text_out().contains("100"));
990    }
991
992    #[tokio::test]
993    async fn test_kernel_builtin_truncation() {
994        use crate::kernel::{Kernel, KernelConfig};
995
996        // Builtins go through post-hoc spill check
997        let config = KernelConfig::agent()
998            .with_output_limit(OutputLimitConfig {
999                max_bytes: Some(100),
1000                head_bytes: 30,
1001                tail_bytes: 20,
1002                spill_mode: SpillMode::Disk,
1003            });
1004        let kernel = Kernel::new(config).expect("kernel creation");
1005
1006        // echo with a large string
1007        let big = "x".repeat(200);
1008        let result = kernel.execute(&format!("echo '{}'", big)).await.expect("execute");
1009        assert!(result.text_out().contains("[output truncated:"));
1010    }
1011
1012    // ── OutputData estimation and streaming tests ──
1013
1014    #[test]
1015    fn test_estimated_byte_size_text() {
1016        use crate::interpreter::OutputData;
1017        let data = OutputData::text("hello world");
1018        assert_eq!(data.estimated_byte_size(), 11);
1019    }
1020
1021    #[test]
1022    fn test_estimated_byte_size_table() {
1023        use crate::interpreter::{OutputData, OutputNode};
1024        let data = OutputData::table(
1025            vec!["NAME".into(), "SIZE".into()],
1026            vec![
1027                OutputNode::new("foo").with_cells(vec!["123".into()]),
1028                OutputNode::new("bar").with_cells(vec!["456".into()]),
1029            ],
1030        );
1031        // "foo\t123\nbar\t456" = 3+1+3 + 1 + 3+1+3 = 15
1032        assert_eq!(data.estimated_byte_size(), 15);
1033    }
1034
1035    #[test]
1036    fn test_estimated_byte_size_tree() {
1037        use crate::interpreter::{OutputData, OutputNode};
1038        let data = OutputData::nodes(vec![
1039            OutputNode::new("src").with_children(vec![
1040                OutputNode::new("main.rs"),
1041                OutputNode::new("lib.rs"),
1042            ]),
1043        ]);
1044        // "src/{main.rs,lib.rs}" = 3 + 2 + 7 + 1 + 6 + 1 = 20
1045        assert_eq!(data.estimated_byte_size(), 20);
1046    }
1047
1048    #[test]
1049    fn test_write_canonical_matches_to_canonical_string() {
1050        use crate::interpreter::{OutputData, OutputNode};
1051
1052        let cases: Vec<OutputData> = vec![
1053            OutputData::text("hello world"),
1054            OutputData::nodes(vec![
1055                OutputNode::new("file1"),
1056                OutputNode::new("file2"),
1057            ]),
1058            OutputData::table(
1059                vec!["NAME".into(), "SIZE".into()],
1060                vec![
1061                    OutputNode::new("foo").with_cells(vec!["123".into()]),
1062                    OutputNode::new("bar").with_cells(vec!["456".into()]),
1063                ],
1064            ),
1065            OutputData::nodes(vec![
1066                OutputNode::new("src").with_children(vec![
1067                    OutputNode::new("main.rs"),
1068                    OutputNode::new("lib.rs"),
1069                ]),
1070            ]),
1071        ];
1072
1073        for data in cases {
1074            let expected = data.to_canonical_string();
1075            let mut buf = Vec::new();
1076            let written = data.write_canonical(&mut buf, None).unwrap();
1077            let got = String::from_utf8(buf).unwrap();
1078            assert_eq!(got, expected, "write_canonical mismatch for {:?}", data);
1079            assert_eq!(written, expected.len(), "byte count mismatch");
1080        }
1081    }
1082
1083    #[test]
1084    fn test_write_canonical_budget_stops_early() {
1085        use crate::interpreter::{OutputData, OutputNode};
1086
1087        let data = OutputData::nodes(
1088            (0..1000).map(|i| OutputNode::new(format!("file_{:04}", i))).collect()
1089        );
1090        let mut buf = Vec::new();
1091        let written = data.write_canonical(&mut buf, Some(100)).unwrap();
1092        // Should have stopped shortly after 100 bytes
1093        assert!(written > 100, "should exceed budget slightly");
1094        assert!(written < 500, "should stop soon after budget: got {}", written);
1095    }
1096
1097    #[tokio::test]
1098    async fn test_spill_if_needed_large_output_data_no_oom() {
1099        use crate::interpreter::{OutputData, OutputNode};
1100
1101        let config = OutputLimitConfig {
1102            max_bytes: Some(1024),
1103            head_bytes: 100,
1104            tail_bytes: 50,
1105            spill_mode: SpillMode::Disk,
1106        };
1107
1108        // 100K nodes — large enough to detect OOM if materialized carelessly,
1109        // but small enough to not slow down the test
1110        let nodes: Vec<OutputNode> = (0..100_000)
1111            .map(|i| OutputNode::new(format!("node_{:06}", i)))
1112            .collect();
1113        let data = OutputData::nodes(nodes);
1114        let mut result = ExecResult::with_output(data);
1115
1116        let spill = spill_if_needed(&mut result, &config).await;
1117        assert!(spill.is_some(), "should have spilled");
1118        assert!(result.did_spill);
1119        assert!(result.text_out().contains("[output truncated:"));
1120
1121        // Clean up
1122        if let Some(s) = spill {
1123            let _ = tokio::fs::remove_file(&s.path).await;
1124        }
1125    }
1126
1127    // ── In-memory spill mode (SpillMode::Memory) ──
1128
1129    #[test]
1130    fn test_in_memory_builder_and_default() {
1131        assert_eq!(OutputLimitConfig::agent().spill_mode(), SpillMode::Disk);
1132        assert_eq!(OutputLimitConfig::agent().in_memory().spill_mode(), SpillMode::Memory);
1133
1134        let mut config = OutputLimitConfig::none();
1135        config.set_spill_mode(SpillMode::Memory);
1136        assert_eq!(config.spill_mode(), SpillMode::Memory);
1137    }
1138
1139    #[tokio::test]
1140    async fn test_memory_mode_truncates_string_without_disk() {
1141        let config = OutputLimitConfig {
1142            max_bytes: Some(100),
1143            head_bytes: 20,
1144            tail_bytes: 10,
1145            spill_mode: SpillMode::Memory,
1146        };
1147        let mut result = ExecResult::success("x".repeat(200));
1148        let spill = spill_if_needed(&mut result, &config).await;
1149
1150        // No SpillResult (no file written) but did_spill flags the truncation.
1151        assert!(spill.is_none(), "memory mode must not write a spill file");
1152        assert!(result.did_spill, "memory truncation must set did_spill for the exit-3 remap");
1153
1154        let out = result.text_out();
1155        assert!(out.contains("truncated in memory"), "got: {}", out);
1156        assert!(out.contains("200 bytes total"), "got: {}", out);
1157        assert!(!out.contains("full output at"), "memory mode must not point at a file: {}", out);
1158        assert!(out.starts_with(&"x".repeat(20)), "head preserved");
1159    }
1160
1161    #[tokio::test]
1162    async fn test_memory_mode_under_limit_untouched() {
1163        let config = OutputLimitConfig {
1164            max_bytes: Some(100),
1165            head_bytes: 20,
1166            tail_bytes: 10,
1167            spill_mode: SpillMode::Memory,
1168        };
1169        let mut result = ExecResult::success("short");
1170        let spill = spill_if_needed(&mut result, &config).await;
1171        assert!(spill.is_none());
1172        assert!(!result.did_spill);
1173        assert_eq!(&*result.text_out(), "short");
1174    }
1175
1176    #[tokio::test]
1177    async fn test_memory_mode_large_output_data_bounded() {
1178        use crate::interpreter::{OutputData, OutputNode};
1179
1180        let config = OutputLimitConfig {
1181            max_bytes: Some(1024),
1182            head_bytes: 100,
1183            tail_bytes: 50,
1184            spill_mode: SpillMode::Memory,
1185        };
1186
1187        // 100K nodes — would be a huge String if fully materialized.
1188        let nodes: Vec<OutputNode> = (0..100_000)
1189            .map(|i| OutputNode::new(format!("node_{:06}", i)))
1190            .collect();
1191        let mut result = ExecResult::with_output(OutputData::nodes(nodes));
1192
1193        let spill = spill_if_needed(&mut result, &config).await;
1194        assert!(spill.is_none(), "memory mode writes no file");
1195        assert!(result.did_spill);
1196        let out = result.text_out();
1197        assert!(out.contains("truncated in memory"), "got: {}", out);
1198        assert!(out.starts_with("node_000000"), "head rendered: {}", out);
1199        // Head-only path for oversized structured data: no tail section echoed.
1200        assert!(out.contains("head only"), "got: {}", out);
1201    }
1202
1203    #[tokio::test]
1204    async fn test_kernel_memory_mode_exits_3_preserves_original() {
1205        use crate::kernel::{Kernel, KernelConfig};
1206
1207        let config = KernelConfig::agent().with_output_limit(OutputLimitConfig {
1208            max_bytes: Some(100),
1209            head_bytes: 30,
1210            tail_bytes: 20,
1211            spill_mode: SpillMode::Memory,
1212        });
1213        let kernel = Kernel::new(config).expect("kernel creation");
1214
1215        let big = "x".repeat(200);
1216        let result = kernel.execute(&format!("echo '{}'", big)).await.expect("execute");
1217        assert_eq!(result.code, 3, "memory truncation still signals via exit 3");
1218        assert_eq!(result.original_code, Some(0), "original exit code preserved");
1219        assert!(result.text_out().contains("truncated in memory"));
1220        assert!(!result.text_out().contains("full output at"));
1221    }
1222
1223    #[tokio::test]
1224    async fn test_nolocal_kernel_forces_memory_spill() {
1225        use crate::kernel::{Kernel, KernelConfig, VfsMountMode};
1226
1227        // NoLocal mount + an explicit Disk spill mode: the kernel must override
1228        // to Memory so nothing is written to a host spill file, even though
1229        // `localfs` is compiled in.
1230        let config = KernelConfig::agent()
1231            .with_vfs_mode(VfsMountMode::NoLocal)
1232            .with_output_limit(OutputLimitConfig {
1233                max_bytes: Some(100),
1234                head_bytes: 30,
1235                tail_bytes: 20,
1236                spill_mode: SpillMode::Disk,
1237            });
1238        let kernel = Kernel::new(config).expect("kernel creation");
1239
1240        let big = "x".repeat(200);
1241        let result = kernel.execute(&format!("echo '{}'", big)).await.expect("execute");
1242        assert_eq!(result.code, 3, "still signals truncation via exit 3");
1243        assert!(result.text_out().contains("truncated in memory"), "got: {}", result.text_out());
1244        assert!(
1245            !result.text_out().contains("full output at"),
1246            "NoLocal kernel must not write a host spill file: {}",
1247            result.text_out()
1248        );
1249    }
1250
1251}