1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
//! Integration tests for the `ilo run` production-safety guards.
//!
//! Origin: mandelbrot persona (2026-05-20) ran an infinite loop and produced
//! 165 MB of stdout before the harness killed it. These tests pin the
//! `--max-runtime` (`ILO-R016`) and `--max-output-bytes` (`ILO-R017`) guards
//! across every engine the bare-positional dispatch can pick.
//!
//! Each test uses a subprocess because the guard calls `process::exit(1)`
//! after writing a structured diagnostic to stderr - there is no graceful
//! return path, by design (the alternative is to thread a cancellation
//! token through every engine's eval loop, which is a much bigger change
//! for a guard that only fires on already-broken programs).
use std::process::Command;
use std::time::{Duration, Instant};
fn ilo() -> Command {
Command::new(env!("CARGO_BIN_EXE_ilo"))
}
/// Run ilo with the given args; return (success, stdout, stderr, wall-clock).
fn run_args(args: &[&str]) -> (bool, String, String, Duration) {
let start = Instant::now();
let out = ilo()
.args(args)
.output()
.unwrap_or_else(|e| panic!("failed to spawn ilo: {e}"));
let elapsed = start.elapsed();
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
(out.status.success(), stdout, stderr, elapsed)
}
// ── --max-runtime ─────────────────────────────────────────────────────────────
/// Default engine (VM): infinite loop without output aborts on the runtime
/// budget with ILO-R016. We pass `--max-runtime 1` so the test finishes
/// quickly; the watchdog polls at 100 ms granularity, so the real wall
/// clock should be under ~1.5 s with plenty of slack for CI.
#[test]
fn infinite_loop_default_engine_aborts_on_max_runtime() {
let (ok, _stdout, stderr, elapsed) = run_args(&[
"--max-runtime",
"1",
"--json",
"main>n;n=0;wh true{n=+n 1};n",
]);
assert!(!ok, "should abort with non-zero exit");
assert!(
stderr.contains("ILO-R016"),
"expected ILO-R016 in stderr, got: {stderr}"
);
assert!(
stderr.contains("--max-runtime"),
"diagnostic should name the override flag, got: {stderr}"
);
assert!(
elapsed < Duration::from_secs(5),
"watchdog should kill within ~1.5 s, took {elapsed:?}"
);
}
/// JIT engine path. Without explicit cap on this build, infinite loops
/// would otherwise spin forever. Note we pin to the cranelift feature so
/// this only runs on feature-enabled builds (the default).
#[cfg(feature = "cranelift")]
#[test]
fn infinite_loop_jit_engine_aborts_on_max_runtime() {
let (ok, _stdout, stderr, elapsed) = run_args(&[
"--max-runtime",
"1",
"--json",
"main>n;n=0;wh true{n=+n 1};n",
"--jit",
]);
assert!(!ok);
assert!(stderr.contains("ILO-R016"), "stderr: {stderr}");
assert!(elapsed < Duration::from_secs(5), "elapsed={elapsed:?}");
}
// ── --max-output-bytes ────────────────────────────────────────────────────────
/// Default engine (VM): a loop printing without termination aborts with
/// ILO-R017 once the byte budget is exhausted. Using 200 bytes so the
/// test takes microseconds.
///
/// The body returns `0` after the (unreachable) loop so `>n` typechecks -
/// the loop itself spins forever in practice, but the verifier doesn't
/// know that.
#[test]
fn runaway_prnt_loop_default_engine_aborts_on_max_output_bytes() {
let (ok, stdout, stderr, _elapsed) = run_args(&[
"--max-output-bytes",
"200",
"--json",
"main>n;n=0;wh true{prnt n;n=+n 1};0",
]);
assert!(!ok, "should abort with non-zero exit");
assert!(
stderr.contains("ILO-R017"),
"expected ILO-R017 in stderr, got: {stderr}"
);
assert!(
stderr.contains("--max-output-bytes"),
"diagnostic should name the override flag, got: {stderr}"
);
// stdout should have a bit of content (the partial print before the
// overflow) but be capped well below the runaway baseline. The exact
// amount depends on the timing of the overflow check; we just assert
// it's bounded.
assert!(
stdout.len() < 4096,
"stdout should be capped, got {} bytes",
stdout.len()
);
}
/// JIT engine path. Calls go through `jit_prt` which also accounts.
#[cfg(feature = "cranelift")]
#[test]
fn runaway_prnt_loop_jit_engine_aborts_on_max_output_bytes() {
let (ok, _stdout, stderr, _elapsed) = run_args(&[
"--max-output-bytes",
"200",
"--json",
"main>n;n=0;wh true{prnt n;n=+n 1};0",
"--jit",
]);
assert!(!ok);
assert!(stderr.contains("ILO-R017"), "stderr: {stderr}");
}
// ── happy path: well-behaved programs are unaffected ──────────────────────────
/// A well-behaved program well under both budgets runs to completion.
/// Guards a future change from accidentally tightening the cap for normal
/// use.
#[test]
fn well_behaved_program_unaffected_by_default_guards() {
let (ok, stdout, stderr, _elapsed) = run_args(&["main>n;prnt 42;0"]);
assert!(ok, "stderr: {stderr}");
assert!(stdout.contains("42"), "stdout: {stdout}");
}
/// Setting `--max-runtime 0` disables the wall-clock cap entirely. Useful
/// for batch/training runs that the operator already knows are long. We
/// can't actually verify "ran forever" so we settle for "ran a tight loop
/// 100k times under 10 s with no abort".
#[test]
fn max_runtime_zero_disables_guard() {
let (ok, _stdout, stderr, _elapsed) =
run_args(&["--max-runtime", "0", "main>n;n=0;wh <n 100000{n=+n 1};n"]);
assert!(ok, "stderr: {stderr}");
}