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
//! The evaluation deadline must actually stop evaluation.
//!
//! Finding 6a closed the Agent API's missing request deadline, but only at the
//! HTTP layer: the connection was freed and the caller got a 408 while the
//! evaluation kept running on a blocking-pool thread, because dropping a
//! `spawn_blocking` handle does not cancel the closure. `safety::enter_deadline`
//! plus a check in `eval_expr` is what closes that.
//!
//! The reason these tests exist rather than a code review: the previous attempt
//! at this — mounting a `TimeoutLayer` — read as obviously correct and did
//! nothing at all, and only an assertion written *before* the fix revealed it.
//! So the claim being tested here is not "a deadline is configured" but "work
//! stops, and it stops near the deadline rather than after finishing".
use aethershell::{env::Env, eval, parser, safety, value::Value};
use std::time::{Duration, Instant};
/// Parse and evaluate `code`, propagating either failure.
fn eval(code: &str) -> anyhow::Result<Value> {
let stmts = parser::parse_program(code)?;
let mut env = Env::default();
eval::eval_program(&stmts, &mut env)
}
/// A workload that is *interpreter-bound*, not allocation-bound.
///
/// The obvious version — one enormous `range` — is the wrong test. `range`
/// materialises its array inside the builtin, and the deadline cannot interrupt
/// a builtin that never returns to the interpreter, so such a test measures
/// allocation and hangs rather than demonstrating anything. (Learned the hard
/// way: `range(0, 40000000)` wedged the suite.)
///
/// Nesting keeps the data small while re-entering `eval_expr` millions of
/// times, which is the thing being tested.
const LONG_RUNNING: &str =
"range(0, 400000) | map(fn(x) => range(0, 50) | map(fn(y) => y + x) | length()) | length()";
#[test]
fn a_deadline_stops_a_long_evaluation() {
let started = Instant::now();
let result = {
let _guard = safety::enter_deadline(Duration::from_millis(300));
eval(LONG_RUNNING)
};
let elapsed = started.elapsed();
assert!(
result.is_err(),
"evaluation ran to completion despite an expired deadline — the \
interpreter is not checking it (took {elapsed:?})"
);
assert!(
elapsed < Duration::from_secs(20),
"evaluation stopped only after finishing its work, not at the \
deadline — took {elapsed:?}"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("time limit"),
"the error must say the work was cancelled for time, so a caller can \
tell it apart from a genuine failure; got {msg:?}"
);
}
/// The far more important half. A deadline that fires on *correct* programs is
/// worse than none, because it turns a working shell into an unreliable one.
#[test]
fn ordinary_evaluation_is_unaffected() {
let _guard = safety::enter_deadline(Duration::from_secs(60));
let out = eval("[1, 2, 3] | map(fn(x) => x * 2)").expect("ordinary work must not be cancelled");
assert!(format!("{out:?}").contains('6'), "got {out:?}");
}
/// With no deadline set — the REPL, scripts, every other test — the check must
/// be inert. This is the default path, so a regression here would be felt
/// everywhere.
#[test]
fn evaluation_without_a_deadline_is_never_interrupted() {
// Sized to comfortably exceed DEADLINE_CHECK_INTERVAL (1024 steps) so the
// clock-sampling branch is actually exercised, while staying quick in a
// debug build — the claim is "the check is inert without a deadline", which
// scale does not make more true.
let out = eval("range(0, 20000) | map(fn(x) => x + 1) | length()")
.expect("with no deadline set, nothing may be cancelled");
assert!(format!("{out:?}").contains("20000"), "got {out:?}");
}
/// These threads are pooled and reused. A deadline left set would make the next
/// request on that thread fail instantly for no reason, which would be a much
/// worse bug than the one being fixed.
#[test]
fn a_deadline_does_not_leak_to_later_work_on_the_same_thread() {
{
let _guard = safety::enter_deadline(Duration::from_millis(1));
std::thread::sleep(Duration::from_millis(20));
let _ = eval(LONG_RUNNING); // expected to be cancelled
}
// Same thread, no deadline in scope: must behave normally.
eval("[1, 2, 3] | map(fn(x) => x * 2)")
.expect("a dropped deadline must not poison later work on this thread");
}
// ---------------------------------------------------------------------------
// Finding 13: recursion depth
// ---------------------------------------------------------------------------
//
// These go through `safety::with_eval_stack`, which is not ceremony — it is the
// supported way to evaluate, and running them without it demonstrated why.
//
// `MAX_CALL_DEPTH` is 2000, and a debug frame measured at roughly 30 KB, so the
// limit needs ~60 MB of stack to be reachable. A default test thread has about
// 2 MB, and the first version of `legitimately_deep_recursion_still_works` duly
// overflowed at depth 1500 — the stack lost the race to the limit, which is the
// precise failure the large stack exists to prevent.
//
// **This is a real constraint on the fix, not a test detail.** A library
// consumer calling `eval::eval_program` on an ordinary thread gets the depth
// limit but not the stack, so deep recursion still aborts before the limit can
// refuse it. `with_eval_stack` (or an equivalently large `stack_size`) is
// required, which is why `main` and the Agent API's tokio runtime both use it.
/// Unbounded recursion used to abort the process, which no error handling could
/// catch because a stack overflow does not unwind.
#[test]
fn unbounded_recursion_is_refused_rather_than_fatal() {
let err = safety::with_eval_stack(|| eval("let f = fn(x) => f(x)\nf(1)"))
.expect_err("unbounded recursion must not be allowed to run");
assert!(
err.to_string().contains("recursion too deep"),
"it must fail for depth, not incidentally; got {err}"
);
}
/// The half that keeps this from being a regression dressed as a fix. Before
/// the large stack, depth 40 aborted the process — so a depth limit alone would
/// have had to sit below 40 to fire at all, rejecting ordinary programs.
#[test]
fn legitimately_deep_recursion_still_works() {
let out = safety::with_eval_stack(|| {
eval("let f = fn(n) => match n { 0 => 0, _ => f(n - 1) }\nf(1500)")
})
.expect("1500 levels is a reasonable program and must not be refused");
assert!(format!("{out:?}").contains('0'), "got {out:?}");
}