Merge pull request #806 from vangel76/gap-fuzz-expressions

#786: fuzz expressions; fix the overflows, hangs and panic it found
This commit is contained in:
Brandon Thomas 2026-10-12 03:23:01 -04:00
commit ff0ee5a727
6 changed files with 846 additions and 30 deletions

View File

@ -22,7 +22,10 @@
//! `seedRandom`), so frames render identically on every thread and platform.
//! * **Runtime**: one boa `Context` per thread with compiled scripts cached by text. Scripts read
//! the project through memoized host requests (see [`host`]), so nothing borrowed ever enters
//! the JS heap. Expressions reading each other nest up to [`MAX_DEPTH`] levels.
//! the JS heap. Expressions reading each other nest up to [`MAX_DEPTH`] levels; one that needs
//! its own value fails at once, and one evaluation (reads included) runs at most [`MAX_RUNS`]
//! scripts for at most [`MAX_TIME`]. Text nested deeper than the parser's stack allows is refused
//! ([`rewrite::MAX_NESTING`]); a seeded fuzzer (`tests/fuzz.rs`) keeps all of this honest.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@ -38,12 +41,39 @@ use effectcraft_render::{EvalCtx, ExprHost};
use effectcraft_time::Tick;
use host::{Own, Req, Resolver, prop_dims, value_resp};
use runtime::{Begin, FRAMES, Frame, Run};
use runtime::{Begin, FRAMES, Frame, Owner, RUNS, Run};
/// Maximum nesting of expressions that read other expressions (cycles stop here).
pub const MAX_DEPTH: usize = 16;
/// Maximum script re-runs while answering host requests.
const MAX_PASSES: usize = 2000;
/// Maximum script runs for one outermost evaluation, the expressions it reads included: reads
/// that fan out (an expression reading two values whose expressions each read two more…) grow
/// exponentially with [`MAX_DEPTH`] and would hang the frame.
pub const MAX_RUNS: usize = 20_000;
/// Longest one outermost evaluation may take (its script runs and the expressions it reads),
/// checked between runs and while a script runs: endless or very long loops, and a script that
/// loops a while and then reads many values (re-running the loop once per value), stop here.
/// Native builds only (the web build has no clock here; loops stop at the loop limit there).
pub const MAX_TIME: std::time::Duration = std::time::Duration::from_secs(5);
/// Start the work budget of an outermost evaluation.
fn budget_start() {
RUNS.with(|r| r.set(0));
#[cfg(not(target_arch = "wasm32"))]
runtime::STARTED.with(|s| s.set(Some(std::time::Instant::now())));
}
/// An error once the outermost evaluation has used up its runs or its time.
fn budget_left() -> Result<(), String> {
if RUNS.with(|r| r.get()) >= MAX_RUNS {
return Err("Error: expression reads too many other expressions' values (circular or fanning-out references?)".into());
}
if runtime::out_of_time() {
return Err(runtime::TIMED_OUT.into());
}
Ok(())
}
/// The expression engine (stateless; runtimes are per thread).
#[derive(Clone, Copy, Debug, Default)]
@ -111,10 +141,23 @@ fn evaluate_out(
text_sel: [f64; 2],
selector: [f64; 3],
) -> Result<runtime::Out, String> {
let depth = FRAMES.with(|f| f.borrow().len());
let owner = Owner { comp: ctx.comp_id.0, layer: layer.id.0, uid: prop.uid, time: ctx.time, text: text.to_string() };
let (depth, cycle) = FRAMES.with(|f| {
let f = f.borrow();
(f.len(), f.iter().any(|fr| fr.owner.as_ref() == Some(&owner)))
});
if depth == 0 {
budget_start();
}
if cycle {
// Needs its own result: fails at once (the reader falls back to the keyframed value)
// instead of nesting to `MAX_DEPTH` once per read on the way.
return Err("Error: circular expression reference (the expression needs its own value)".into());
}
if depth >= MAX_DEPTH {
return Err("Error: expressions reference each other too deeply (circular reference?)".into());
}
budget_left()?;
let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host), footage: ctx.footage };
let comp = ctx.comp_id.0;
let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid));
@ -133,7 +176,7 @@ fn evaluate_out(
selector,
};
// Answer the requests nearly every expression makes up front.
let mut frame = Frame::default();
let mut frame = Frame { owner: Some(owner), ..Frame::default() };
for req in [Req::CompInfo { comp }, Req::LayerInfo { comp, layer: layer.id.0 }, Req::PropInfo { comp, layer: layer.id.0, path: path.clone() }] {
let r = resolver.resolve(&req);
frame.memo.insert(req, r);
@ -141,6 +184,8 @@ fn evaluate_out(
FRAMES.with(|f| f.borrow_mut().push(frame));
let _guard = FrameGuard;
for _ in 0..MAX_PASSES {
budget_left()?;
RUNS.with(|r| r.set(r.get().saturating_add(1)));
match runtime::run(text, &begin) {
Run::Done(r) => return r,
Run::Pending => {

View File

@ -38,7 +38,7 @@ pub fn noise1(seed: u64, x: f64) -> f64 {
let i = x.floor();
let u = fade(x - i);
let i = i as i64;
let (a, b) = (lattice(seed, i), lattice(seed, i + 1));
let (a, b) = (lattice(seed, i), lattice(seed, i.wrapping_add(1)));
a + (b - a) * u
}
@ -50,7 +50,7 @@ pub fn noise3(x: f64, y: f64, z: f64) -> f64 {
let (fx, fy, fz) = (x.floor(), y.floor(), z.floor());
let (u, v, w) = (fade(x - fx), fade(y - fy), fade(z - fz));
let (ix, iy, iz) = (fx as i64, fy as i64, fz as i64);
let g = |dx: i64, dy: i64, dz: i64| unit(mix(&[(ix + dx) as u64, (iy + dy) as u64, (iz + dz) as u64])) * 2.0 - 1.0;
let g = |dx: i64, dy: i64, dz: i64| unit(mix(&[ix.wrapping_add(dx) as u64, iy.wrapping_add(dy) as u64, iz.wrapping_add(dz) as u64])) * 2.0 - 1.0;
let l = |a: f64, b: f64, t: f64| a + (b - a) * t;
let x00 = l(g(0, 0, 0), g(1, 0, 0), u);
let x10 = l(g(0, 1, 0), g(1, 1, 0), u);
@ -99,6 +99,18 @@ mod tests {
assert!((-1.0..=1.0).contains(&noise3(0.3, 4.2, -7.5)));
}
#[test]
fn huge_inputs_do_not_overflow() {
// Found by the expression fuzzer (#786): `wiggle(9007199254740993, 30)` at a late time
// put the lattice index at `i64::MAX`, and its neighbour overflowed.
let w = wiggle(1, 2, 9_007_199_254_740_993.0, 30.0, 1.0, 0.5, 1e6);
assert!(w.iter().all(|v| v.is_finite() && v.abs() <= 30.0));
for x in [1e300, -1e300, 9.3e18, -9.3e18] {
assert!(noise1(5, x).is_finite());
assert!(noise3(x, -x, x).is_finite());
}
}
#[test]
fn wiggle_bounds() {
for i in 0..2000 {

View File

@ -548,55 +548,230 @@ fn push_piece(out: &mut String, p: Piece) {
out.push_str(&p.body);
}
/// The deepest nesting (brackets, plus chains of prefix operators) an expression may have,
/// before and after the rewrite (which nests a call per arithmetic operator). The JavaScript
/// parser recurses per level and a 2 MiB thread overflows its stack (aborting the app) at
/// about 60 levels, so deeper expressions are rejected with an error instead.
/// The deepest nesting an expression may have, in levels of brackets, before and after the
/// rewrite (which nests a call per arithmetic operator). The JavaScript parser and compiler
/// recurse per level and a 2 MiB thread overflows its stack (aborting the app) at about 75
/// nested brackets, so deeper expressions are rejected with an error instead. Constructs that
/// nest without brackets count as part of a level ([`too_nested`]).
pub const MAX_NESTING: usize = 32;
fn nesting(toks: &[Tok]) -> usize {
let (mut depth, mut prefix, mut max) = (0usize, 0usize, 0usize);
for t in toks {
if t.kind == Kind::Punct && matches!(t.text.as_str(), "(" | "[" | "{") {
depth += 1;
} else if t.kind == Kind::Punct && matches!(t.text.as_str(), ")" | "]" | "}") {
depth = depth.saturating_sub(1);
/// Stack cost of one level of brackets (also of a template substitution, a regex group and a
/// prefix operator, which cost the parser about as much or are never chained in real code).
const LEVEL: usize = 256;
/// Stack cost of a construct the parser recurses on without a bracket: `?:`, `=>`, assignments,
/// `**`, `||`, labels, `if`/`else`/`while`/`for`/`with`/`do` without a block (about 1/8 of a
/// bracket: 256 of them in a row are allowed; boa overflows from about 750).
const HEAVY: usize = 32;
/// Stack cost of a link in a left-deep chain (binary operators, member access, calls, indexing,
/// the comma operator): the tree is built in a loop but compiled recursively (overflows from
/// about 3 800 links).
const LIGHT: usize = 6;
/// One bracket level of [`too_nested`]'s walk.
#[derive(Default)]
struct Level {
/// `[`: commas separate elements (a loop in the parser), not operands.
array: bool,
/// Cost of the chains of the current statement or element.
chain: usize,
/// Comma operators so far in the current statement.
commas: usize,
/// `else` branches in a row: `if … else if … else if …` nests even across `;`.
elses: usize,
/// The next token starts a statement.
fresh: bool,
}
impl Level {
fn boundary(&mut self) {
self.chain = 0;
self.commas = 0;
self.fresh = true;
}
}
const ASSIGN: &[&str] = &["=", "+=", "-=", "*=", "/=", "%=", "**=", "<<=", ">>=", ">>>=", "&=", "|=", "^=", "&&=", "||=", "??="];
/// Can `t` end an expression statement (so a line break after it may end the statement)?
fn ends_value(t: &Tok) -> bool {
match t.kind {
Kind::Num | Kind::Str | Kind::Template | Kind::Regex => true,
Kind::Ident => !t.is_kw(DELIM_KW) && !t.is_kw(PREFIX_KW),
Kind::Punct => matches!(t.text.as_str(), ")" | "]" | "}" | "++" | "--"),
}
}
/// Can `t` only start a new statement after a value (automatic semicolon insertion)?
fn starts_statement(t: &Tok) -> bool {
match t.kind {
Kind::Num | Kind::Str => true,
Kind::Ident => !t.is_kw(&["in", "instanceof", "of"]),
Kind::Punct => matches!(t.text.as_str(), "{" | "!" | "~" | "++" | "--"),
Kind::Template | Kind::Regex => false,
}
}
/// Bracket depth inside a template literal (its `${…}` substitutions) or a regex (its groups).
fn inner_depth(text: &str) -> usize {
let (mut d, mut max) = (0usize, 0usize);
for c in text.chars() {
match c {
'(' | '[' | '{' => d += 1,
')' | ']' | '}' => d = d.saturating_sub(1),
_ => {}
}
let is_prefix = (t.kind == Kind::Punct && matches!(t.text.as_str(), "!" | "~" | "-" | "+")) || t.is_kw(&["typeof", "void", "delete"]);
prefix = if is_prefix { prefix + 1 } else { 0 };
max = max.max(depth + prefix);
max = max.max(d);
}
max
}
/// The code of a template literal's `${…}` substitutions (the outermost ones).
fn substitutions(template: &str) -> Vec<String> {
let c: Vec<char> = template.chars().collect();
let mut out = vec![];
let mut i = 1;
while i < c.len() {
match c[i] {
'\\' => i += 2,
'$' if c.get(i + 1) == Some(&'{') => {
let start = i + 2;
let (mut j, mut depth) = (start, 0usize);
while j < c.len() {
match c[j] {
'{' => depth += 1,
'}' if depth == 0 => break,
'}' => depth -= 1,
'\'' | '"' => {
j = skip_string(&c, j);
continue;
}
'`' => {
j = skip_template(&c, j);
continue;
}
_ => {}
}
j += 1;
}
out.push(c.get(start..j.min(c.len())).unwrap_or_default().iter().collect());
i = j + 1;
}
_ => i += 1,
}
}
out
}
/// Whether `toks` nest deeper than [`MAX_NESTING`] levels: an estimate of the parser's stack
/// use, summing brackets, runs of prefix operators and, per bracket level, the chains of the
/// current statement (or array element) weighted by what each link costs the parser.
fn too_nested(toks: &[Tok]) -> bool {
too_nested_from(toks, 0)
}
/// [`too_nested`] for code that already sits `base` deep (a template substitution).
fn too_nested_from(toks: &[Tok], base: usize) -> bool {
let mut levels = vec![Level::default()];
let mut prefix = 0usize;
let mut prev: Option<&Tok> = None;
for t in toks {
let p = t.kind == Kind::Punct;
if let Some(l) = levels.last_mut() {
if t.nl && prev.is_some_and(ends_value) && starts_statement(t) {
l.boundary();
}
if l.fresh {
if !t.is_kw(&["else"]) {
l.elses = 0;
}
l.fresh = false;
}
}
let mut inner = 0;
if p && matches!(t.text.as_str(), "(" | "[" | "{") {
// A call or index is a link in its chain too (`f()()()…`).
if t.text != "{"
&& let Some(l) = levels.last_mut()
{
l.chain = l.chain.saturating_add(LIGHT);
}
levels.push(Level { array: t.text == "[", ..Level::default() });
} else if p && matches!(t.text.as_str(), ")" | "]" | "}") {
if levels.len() > 1 {
levels.pop();
}
} else if let Some(l) = levels.last_mut() {
if t.is(";") {
l.boundary();
} else if t.is(",") {
l.commas += usize::from(!l.array);
l.chain = l.commas.saturating_mul(LIGHT);
} else if t.is_kw(&["else"]) {
l.elses += 1;
} else if (p && (matches!(t.text.as_str(), "?" | ":" | "=>" | "**" | "||") || ASSIGN.contains(&t.text.as_str())))
|| t.is_kw(&["if", "while", "for", "with", "do"])
{
l.chain = l.chain.saturating_add(HEAVY);
} else if p || t.is_kw(&["in", "instanceof"]) {
l.chain = l.chain.saturating_add(LIGHT);
} else if matches!(t.kind, Kind::Template | Kind::Regex) {
// A tagged template is a link like a call.
l.chain = l.chain.saturating_add(LIGHT);
inner = inner_depth(&t.text);
}
}
let is_prefix = (p && matches!(t.text.as_str(), "!" | "~" | "-" | "+" | "++" | "--")) || t.is_kw(&["typeof", "void", "delete", "new", "await"]);
prefix = if is_prefix { prefix + 1 } else { 0 };
let mut cost = (levels.len() - 1 + prefix + inner).saturating_mul(LEVEL).saturating_add(base);
for l in &levels {
cost = cost.saturating_add(l.chain).saturating_add(l.elses.saturating_mul(HEAVY));
}
if cost > MAX_NESTING * LEVEL {
return true;
}
// The code inside `${…}` is parsed like any other, one level deeper (the depth check
// above bounds this recursion).
if t.kind == Kind::Template
&& t.text.contains("${")
&& substitutions(&t.text)
.iter()
.any(|sub| too_nested_from(&tokenize(sub).0, cost.saturating_sub(inner.saturating_mul(LEVEL)).saturating_add(LEVEL)))
{
return true;
}
prev = Some(t);
}
false
}
/// An error when `src` nests deeper than [`MAX_NESTING`] levels (before or after the rewrite).
pub fn check_nesting(src: &str) -> Result<(), String> {
let (toks, tail) = tokenize(src);
if nesting(&toks) > MAX_NESTING {
if too_nested(&toks) {
return Err(too_deep());
}
let mut out = rewrite_seq(&build(toks));
out.push_str(&tail);
if nesting(&tokenize(&out).0) > MAX_NESTING {
if too_nested(&tokenize(&out).0) {
return Err(too_deep());
}
Ok(())
}
fn too_deep() -> String {
format!("Error: expression is nested too deeply (more than {MAX_NESTING} levels of brackets or chained operators)")
format!("Error: expression is nested too deeply (more than {MAX_NESTING} levels of brackets, or too long a chain of operators)")
}
/// Rewrite an expression's source for array-aware arithmetic. Line breaks are preserved.
/// An expression nested deeper than [`MAX_NESTING`] becomes a `throw` of that error.
pub fn rewrite(src: &str) -> String {
let (toks, tail) = tokenize(src);
if nesting(&toks) > MAX_NESTING {
if too_nested(&toks) {
return throw_too_deep();
}
let mut out = rewrite_seq(&build(toks));
out.push_str(&tail);
if nesting(&tokenize(&out).0) > MAX_NESTING {
if too_nested(&tokenize(&out).0) {
return throw_too_deep();
}
out
@ -673,4 +848,60 @@ mod tests {
assert!(crate::check_syntax(&ok).is_ok());
assert!(check_nesting(&vec!["x"; 20].join(" + ")).is_ok());
}
#[test]
fn long_chains_without_brackets_are_an_error_not_a_stack_overflow() {
// Found by the expression fuzzer (#786): the parser also recurses on chains that need no
// bracket, so a few hundred `?:`, `=>` or `if`, or a few thousand `.`/`<`/`()` links,
// aborted the app with a stack overflow; so did 75 nested template substitutions or
// regex groups (both hidden inside one token), and 6 000 prefix `new`/`++` the rewriter.
let chains: [(&str, &str); 19] = [
("x?x:", ""),
("()=>", ""),
("a=>", ""),
("if(x)", ""),
("if(x)x;else ", ""),
("x=", ""),
("x**", ""),
("x||", ""),
("x<", ""),
("x,", ""),
("x.y", ""),
("f()", ""),
("a:", ""),
("new ", ""),
("++", ""),
("`${", "}`"),
("s.replace(/(", ")/, '')"),
("`${x??", "}`"),
("{a:", "}"),
];
for (open, close) in chains {
for n in [2_000, 100_000] {
let src = open.repeat(n) + "1" + &close.repeat(n);
assert!(rewrite(&src).starts_with("throw new RangeError("), "{open} × {n}");
assert!(crate::check_syntax(&src).unwrap_err().contains("nested too deeply"), "{open} × {n}");
}
}
// A chain inside one template substitution (the template is one token to the rewriter).
let src = format!("`${{{}1}}`", "x??".repeat(100_000));
assert!(crate::check_syntax(&src).unwrap_err().contains("nested too deeply"));
}
#[test]
fn long_real_expressions_still_parse() {
let lines = |n: usize, line: &str| vec![line; n].join("\n");
let else_ifs = (0..200).map(|i| format!("if (time < {i}) {i}; else ")).collect::<String>() + "0";
let numbers = format!("[{}][0]", (0..5000).map(|i| i.to_string()).collect::<Vec<_>>().join(", "));
let points = format!("createPath([{}], [], [], true)", (0..500).map(|i| format!("[{i}, {}]", i * 2)).collect::<Vec<_>>().join(", "));
let object = format!("var o = {{{}}}; o.k0", (0..200).map(|i| format!("k{i}: time > {i} ? {i} : -{i}")).collect::<Vec<_>>().join(", "));
let program = "var x = 0\n".to_string() + &lines(300, "x = x + thisComp.layer(1).transform.position[0] * 2 - 1") + "\nx";
let ifs = "var y = 0\n".to_string() + &lines(300, "if (time > 1) y = y + 1") + "\ny";
let chained = vec!["thisComp.layer(1).transform.position.valueAtTime(time - 0.1)[0]"; 30].join(" + ");
let template = "`${thisLayer.name}: ${Math.floor(time * 10) / 10} s, ${`${index}`} {}`".to_string();
for src in [else_ifs, numbers, points, object, program, ifs, chained, template] {
assert!(check_nesting(&src).is_ok(), "{}", &src[..80]);
assert!(crate::check_syntax(&src).is_ok(), "{}", &src[..80]);
}
}
}

View File

@ -16,6 +16,11 @@ const PRELUDE: &str = include_str!("prelude.js");
/// Message of the error thrown when a request isn't answered yet (never shown to users).
const PENDING: &str = "\u{1}effectcraft: pending host request";
const MAX_SCRIPTS: usize = 4096;
/// Loop iterations one script run may make (per function call): a run re-executes once per
/// property it reads, so this bounds the time of every pass.
pub const LOOP_LIMIT: u64 = 1_000_000;
/// The error of an evaluation that ran out of time.
pub const TIMED_OUT: &str = "Error: expression takes too long to evaluate";
/// The part of boa's stack-size limit error the runtime recovers from (see [`run`]).
const STACK_LIMIT: &str = "maximum stack size";
@ -24,6 +29,18 @@ const STACK_LIMIT: &str = "maximum stack size";
pub struct Frame {
pub memo: HashMap<Req, Resp>,
pub misses: Vec<Req>,
/// What this frame evaluates: an evaluation that needs itself is a cycle.
pub owner: Option<Owner>,
}
/// One expression evaluation: the property (comp, layer, uid), the comp time and the text.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Owner {
pub comp: u64,
pub layer: u64,
pub uid: u64,
pub time: effectcraft_time::Tick,
pub text: String,
}
thread_local! {
@ -32,6 +49,14 @@ thread_local! {
static RUNTIME: RefCell<Option<ManuallyDrop<Result<Runtime, String>>>> = const { RefCell::new(None) };
/// Evaluation stack (nested when one expression reads another's value).
pub static FRAMES: RefCell<Vec<Frame>> = const { RefCell::new(Vec::new()) };
/// Script runs since the outermost evaluation on this thread began (nested ones included).
pub static RUNS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(not(target_arch = "wasm32"))]
thread_local! {
/// When the outermost evaluation on this thread began.
pub static STARTED: std::cell::Cell<Option<std::time::Instant>> = const { std::cell::Cell::new(None) };
}
struct Runtime {
@ -209,7 +234,7 @@ impl Runtime {
fn new() -> Result<Runtime, String> {
let mut ctx = Context::default();
let limits = ctx.runtime_limits_mut();
limits.set_loop_iteration_limit(10_000_000);
limits.set_loop_iteration_limit(LOOP_LIMIT);
limits.set_recursion_limit(400);
let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>); 4] = [
(js_string!("__h"), 6, native_host),
@ -270,7 +295,11 @@ impl Runtime {
if let Err(e) = self.begin.call(&JsValue::undefined(), &args, ctx) {
return Run::Done(Err(format_error(&e.to_string(), line_count(text))));
}
let result = script.evaluate(ctx).and_then(|v| self.finish.call(&JsValue::undefined(), &[v], ctx));
let result = match evaluate_in_time(&script, ctx) {
Some(r) => r.and_then(|v| self.finish.call(&JsValue::undefined(), &[v], ctx)),
// Abandoned mid-run: the context is unusable now (see `run`).
None => return Run::Done(Err(TIMED_OUT.into())),
};
let pending = FRAMES.with(|f| f.borrow().last().is_some_and(|fr| !fr.misses.is_empty()));
if pending {
return Run::Pending;
@ -282,6 +311,54 @@ impl Runtime {
}
}
/// Run `script`, giving up (`None`) once the outermost evaluation is out of time
/// ([`crate::MAX_TIME`]). boa's budgeted evaluation yields every few thousand instructions of
/// the script and the functions it calls (not inside callbacks of native functions such as
/// `Array.prototype.map`, which stay bounded by the loop limit per call).
fn evaluate_in_time(script: &Script, ctx: &mut Context) -> Option<JsResult<JsValue>> {
let mut run = std::pin::pin!(script.evaluate_async_with_budget(ctx, 20_000));
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
loop {
if let std::task::Poll::Ready(r) = run.as_mut().poll(&mut cx) {
return Some(r);
}
if out_of_time() {
return None;
}
}
}
#[cfg(test)]
thread_local! {
/// Test hook: this thread's time budget (tests of the run-count budget lift it so that a
/// loaded machine cannot reach the clock first).
pub static TIME_LIMIT: std::cell::Cell<std::time::Duration> = const { std::cell::Cell::new(crate::MAX_TIME) };
}
#[cfg(not(target_arch = "wasm32"))]
fn max_time() -> std::time::Duration {
#[cfg(test)]
{
TIME_LIMIT.with(|t| t.get())
}
#[cfg(not(test))]
{
crate::MAX_TIME
}
}
/// Whether the outermost evaluation on this thread has used up [`crate::MAX_TIME`].
pub fn out_of_time() -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
STARTED.with(|s| s.get()).is_some_and(|t| t.elapsed() > max_time())
}
#[cfg(target_arch = "wasm32")]
{
false
}
}
/// Copy a JS value out of the heap.
fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result<Out, String> {
if v.is_undefined() {
@ -413,6 +490,13 @@ pub fn run(text: &str, b: &Begin) -> Run {
Err(e) => return Run::Done(Err(e.clone())),
};
match out {
Run::Done(Err(e)) if e == TIMED_OUT => {
// Abandoned mid-run: start the next evaluation on a fresh context.
if let Some(old) = r.take() {
drop(ManuallyDrop::into_inner(old));
}
return Run::Done(Err(e));
}
Run::Done(Err(e)) if !fresh && e.contains(STACK_LIMIT) => {
// Dropped here, mid-thread, where boa's heap is alive (unlike at thread exit).
if let Some(old) = r.take() {
@ -500,6 +584,11 @@ pub fn to_value(out: &Out, current: &Value) -> Result<Value, String> {
Value::Str(_) => Value::Str(text(out)),
Value::Path(_) => match out {
Out::Path { points, ins, outs, closed } => {
// A vertex that isn't a number (`[[0, 0], [[1, 2], 0]]`) comes through as NaN, which
// the rasterizer must never see.
if points.iter().chain(ins).chain(outs).flatten().any(|c| !c.is_finite()) {
return Err("Error: path points must be finite numbers".into());
}
let n = points.len();
let fit = |v: &[[f64; 2]]| {
let mut v = v.to_vec();

View File

@ -1,5 +1,7 @@
use std::time::Instant;
mod fuzz;
use effectcraft_color::Label;
use effectcraft_keyframe::{Keyframe, ShapePath, TextDoc, Value};
use effectcraft_project::build::{self, Ids};
@ -383,11 +385,87 @@ fn circular_references_terminate() {
f.set_expr(f.b, OPA, "thisComp.layer('A').transform.opacity");
let start = Instant::now();
let r = eval_property(&f.p, f.cid, f.a, OPA, 0.0);
assert!(start.elapsed().as_secs_f64() < 10.0);
// The cycle bottoms out at the depth limit and falls back to keyframed values.
// Only a hang guard (the budgets stop it far sooner); generous for a loaded machine.
assert!(start.elapsed().as_secs_f64() < 100.0, "{:?}", start.elapsed());
// The cycle is caught when A needs itself again, and falls back to keyframed values.
assert!(matches!(r, Ok(Value::Scalar(_))) || r.is_err(), "{r:?}");
}
#[test]
fn an_expression_needing_its_own_value_fails_fast() {
// Found by the expression fuzzer (#786): rotation's `toWorld` reads the layer's transform,
// which evaluates rotation's expression again, twice per level (layer-to-comp and world
// matrices), so the cycle nested to `MAX_DEPTH` 2^16 times and the frame took 20 s.
let mut f = fx();
f.set_expr(f.a, ROT, "fromWorld(toWorld(anchorPoint))[0]");
let start = Instant::now();
let r = eval_property(&f.p, f.cid, f.a, ROT, 0.5);
assert!(start.elapsed().as_secs_f64() < 100.0, "{:?}", start.elapsed());
// The inner read falls back to the keyframed rotation, as in a standalone evaluation.
assert_eq!(r, f.ev(f.a, ROT, "fromWorld(toWorld(anchorPoint))[0]", 0.5));
}
#[test]
fn fanning_out_reads_stop_at_a_budget() {
// Two layers reading each other twice at other times: no cycle (the times differ), but
// 2^16 evaluations at `MAX_DEPTH`; the evaluation stops after `MAX_RUNS` script runs.
let mut f = fx();
let text = |other: &str| format!("var r = thisComp.layer('{other}').transform.rotation; r.valueAtTime(time + 0.001) + r.valueAtTime(time + 0.002)");
f.set_expr(f.a, ROT, &text("B"));
f.set_expr(f.b, ROT, &text("A"));
// Take the clock out of the race: on a loaded machine the time budget could otherwise stop
// the evaluation before the run count does, with a different error.
crate::runtime::TIME_LIMIT.with(|t| t.set(std::time::Duration::from_secs(3600)));
let start = Instant::now();
let r = eval_property(&f.p, f.cid, f.a, ROT, 0.5);
crate::runtime::TIME_LIMIT.with(|t| t.set(crate::MAX_TIME));
assert!(start.elapsed().as_secs_f64() < 300.0, "{:?}", start.elapsed());
assert!(r.unwrap_err().contains("too many"));
// The budget is per outermost evaluation: the next one starts afresh.
assert_close!(f.num("time * 2", 1.0), 2.0);
assert_close!(f.ev(f.b, OPA, "thisComp.layer('A').transform.opacity", 0.0).unwrap().as_f64(), 100.0);
}
#[test]
fn loops_stop_at_a_million_iterations() {
// Found by the expression fuzzer (#786): `for (…; i < 1e15; …)` ran ten million iterations
// per run (seconds), once more for every property the script reads after it.
let f = fx();
assert_close!(f.num("var n = 0; while (n < 900000) n++; n", 0.0), 900_000.0);
assert!(f.err("var n = 0; while (n < 2000000) n++; n").contains("iteration"));
assert!(f.err("for (;;) {}").contains("iteration"));
}
#[test]
fn a_long_running_script_stops_at_the_time_budget() {
// Found by the expression fuzzer (#786): `smooth(NaN, 1e9)` made a million host reads in
// one run (tens of seconds), and the loop limit counts per function call, so loops calling
// functions that loop run for hours. The run is now abandoned after `MAX_TIME`.
let f = fx();
let start = Instant::now();
let e = f.err("function g() { for (var i = 0; i < 900000; i++) {} }\nfor (var k = 0; k < 900000; k++) g()");
assert!(e.contains("too long"), "{e}");
assert!(start.elapsed() < crate::MAX_TIME * 12, "{:?}", start.elapsed());
// The thread's runtime starts afresh.
assert_close!(f.num("time * 2", 1.0), 2.0);
}
#[test]
fn a_path_with_non_numeric_points_is_an_error() {
// Found by the expression fuzzer (#786): an array in a vertex became NaN and tripped the
// rasterizer's clipping assertion when the mask was drawn.
let mut f = fx();
let text = "createPath([[0, 0], [[1, 2] * 2, 0], [100, 100]], [], [], true)";
assert!(f.ev(f.a, "masks/#1/path", text, 0.0).unwrap_err().contains("finite"));
assert!(f.ev(f.a, "masks/#1/path", "createPath([[0, 0], [1e400, 0], [100, 100]], [], [], true)", 0.0).is_err());
f.set_expr(f.a, "masks/#1/path", text);
let host = Expressions;
let mut r = Renderer::new(&f.p, &NoFootage, RenderOpts { motion_blur: false, ..Default::default() });
r.expr = Some(&host);
r.comp_frame(f.cid, Tick::ZERO);
assert!(f.ev(f.a, "masks/#1/path", "createPath([[0, 0], [10, 0], [10, 10]], [], [], true)", 0.0).is_ok());
}
#[test]
fn errors_and_property_reads_keep_the_runtime_usable() {
// Errors thrown inside helpers, and the aborts every property read makes until the host
@ -1002,7 +1080,7 @@ fn hundred_wiggles_are_fast() {
let per_frame = start.elapsed().as_secs_f64() * 1000.0 / n as f64;
println!("100 wiggle properties: {per_frame:.3} ms/frame (checksum {acc})");
// Generous bound so unoptimised CI builds pass; release is far below.
assert!(per_frame < 100.0, "{per_frame} ms/frame");
assert!(per_frame < 1000.0, "{per_frame} ms/frame");
}
#[test]

View File

@ -0,0 +1,361 @@
//! An expression fuzzer (gaps G3, #786): any expression text must evaluate to a value or an
//! error, never a panic, a stack overflow (which aborts the app), a hang or a runaway allocation
//! (AGENTS.md "Never crash").
//!
//! Dependency-free, deterministic and seeded: real After Effects-style expressions (`wiggle`,
//! `loopOut`, `valueAtTime`, `thisComp.layer(…)`, effects, masks, markers, text styles, paths…)
//! are mutated character-wise (flips, deletions, duplications, splices with other seeds,
//! truncation) and token-wise (punctuation, keywords and object-model names inserted, numbers
//! swapped for extreme ones, a token repeated into deep nesting or a long operator chain), then
//! syntax-checked, evaluated on a scalar, a vector, a text and a path property of the test comp,
//! read through another layer's expression and rendered in a small frame. Inputs run on a worker
//! thread with the app's default 2 MiB stack under `catch_unwind` and a deadline: a panic or a
//! hang is reported with the iteration that reproduces it instead of taking the run down, and a
//! stack overflow aborts the run, which fails it all the same (`EFFECTCRAFT_FUZZ_VERBOSE=1`
//! prints every input before it runs).
//!
//! The default run is bounded (iterations and seconds) to stay fast in `cargo test`; a longer run:
//! `EFFECTCRAFT_FUZZ_ITERS=200000 EFFECTCRAFT_FUZZ_SECS=600 cargo test -p effectcraft-expr fuzz_`
//! (`EFFECTCRAFT_FUZZ_SEED` picks another sequence, `EFFECTCRAFT_FUZZ_START` the first iteration).
//! The `mutate` + `eval_input` pair is what a cargo-fuzz target would call.
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::{Duration, Instant};
use effectcraft_render::{NoFootage, RenderOpts, Renderer};
use super::{Fx, OPA, POS, ROT, fx, secs};
use crate::{Expressions, check_syntax, eval_property, eval_standalone};
/// splitmix64: tiny, good enough for picking mutations.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// `0..n` (`0` when `n` is 0).
fn below(&mut self, n: usize) -> usize {
if n == 0 { 0 } else { (self.next() % n as u64) as usize }
}
fn chance(&mut self, one_in: usize) -> bool {
self.below(one_in) == 0
}
fn pick<'a, T>(&mut self, xs: &'a [T]) -> &'a T {
&xs[self.below(xs.len())]
}
}
/// Real expressions, in the shapes people write them (and the test comp can answer them).
#[rustfmt::skip]
const SEEDS: &[&str] = &[
"time * 360",
"value",
"wiggle(2, 50)",
"wiggle(5, 20, 3, 0.5, time * 2)",
"temporalWiggle(2, 0.1)",
"loopOut('cycle')",
"loopOut('pingpong', 1)",
"loopIn('offset')",
"loopOutDuration('continue', 0.5)",
"valueAtTime(time - 0.5)",
"thisProperty.valueAtTime(time / 2) + velocityAtTime(time)[0]",
"speedAtTime(0.5)",
"smooth(0.2, 5)",
"nearestKey(time).index",
"numKeys > 1 ? key(numKeys).value : value",
"thisComp.layer('A').transform.rotation.valueAtTime(0.25)",
"thisComp.layer(\"B\").transform.position[0] + 10",
"thisComp.layer(1).effect('Slider Control')('Slider') * 2",
"effect(1).param(1) + effect(1)(1)",
"comp('Other').layer(1).name",
"comp('Other').width / thisComp.height",
"thisLayer.index + thisComp.numLayers",
"mask('Mask 1').maskOpacity",
"mask('Mask 1').maskPath.points()[0]",
"content('Rectangle 1').content('Rectangle Path 1').size[0]",
"marker.numKeys > 0 ? marker.key(1).time : 0",
"marker.nearestKey(time).comment",
"thisComp.marker.key(1).time",
"var r = sourceRectAtTime(time, false); [r.left + r.width / 2, r.top + r.height / 2]",
"toComp([0, 0])",
"fromWorld(toWorld(anchorPoint))",
"lookAt([0, 0, 0], [100, 0, 100])",
"value + [10, -5]",
"[1, 2] * 2 - [0.5, 0.5] / 4",
"x = value; x += [1, 0]; x",
"linear(time, 0, 1, 0, 100)",
"ease(time, 0, 2, [0, 0], [100, 50])",
"easeIn(time, 0, 1, 0, 360) + easeOut(time, 1, 2, 0, 10)",
"clamp(value * 2, 0, 100)",
"length([0, 0], [3, 4]) + dot([1, 0], [0, 1]) + cross([1, 0, 0], [0, 1, 0])[2]",
"normalize([3, 4])[0] * degreesToRadians(180)",
"seedRandom(5, true); random(100)",
"seedRandom(index, false); gaussRandom([10, 20])",
"noise(time) * 100",
"posterizeTime(12); wiggle(3, 30)",
"framesToTime(timeToFrames(time) + 1)",
"rgbToHsl([1, 0, 0, 1])[0] + hslToRgb([0.5, 1, 0.5, 1])[1]",
"Math.sin(time * Math.PI * 2) * 50",
"Math.round(value / 10) * 10",
"var s = 0; for (var i = 0; i < 10; i++) { s += i; } s",
"function f(a) { return a * 2 } f(value)",
"var g = (a, b) => a - b; g(5, 3)",
"if (time > 1) { 100 } else { 0 }",
"try { thisComp.layer('Missing').name } catch (e) { 0 }",
"'Frame ' + timeToFrames(time) + ' of ' + thisComp.duration",
"text.sourceText + ' world'",
"text.sourceText.style.setText('Hi there').setFontSize(20, 3, 5).setFauxBold(true, 0, 2)",
"thisComp.layer('Title').text.sourceText.style.fontSize",
"`${thisLayer.name}: ${Math.floor(time)}`",
"createPath([[0, 0], [100, 0], [100, 100]], [], [], true)",
"var p = thisProperty; p.pointOnPath(0.5)",
"[thisComp.width, thisComp.height] / 2",
"thisComp.frameDuration * 10",
"inPoint + (outPoint - inPoint) * 0.5",
"transform.opacity * 0.5 + [0][0]",
];
/// What gets inserted: syntax, keywords, object-model names and odd characters.
#[rustfmt::skip]
const TOKENS: &[&str] = &[
"(", ")", "[", "]", "{", "}", ",", ";", ".", "?.", "?", ":", "=", "==", "=>", "+", "-", "*", "/", "%", "**", "++", "--",
"+=", "-=", "*=", "/=", "!", "~", "&&", "||", "??", "...", "'", "\"", "`", "${", "\\", "/*", "*/", "//", "\n", "\r", "\t",
"\u{0}", "\u{2028}", "\u{feff}", "é", "\u{1F600}", "\u{fffd}", " in ", " of ", " new ", " typeof ", " void ", " delete ",
"function", "return ", "var ", "let ", "const ", "if", "else", "for", "while", "do", "switch", "case ", "break", "try",
"catch", "finally", "throw ", "class ", "this", "null", "undefined", "NaN", "Infinity", "true", "-0", "0x", "1e", "1n",
"/a+/g", "time", "value", "index", "thisComp", "thisLayer", "thisProperty", "layer(", "comp(", "effect(", "mask(",
"content(", "key(", "marker", "valueAtTime(", "wiggle(", "loopOut(", "sourceRectAtTime(", "toComp(", "text.sourceText",
".style", ".setText(", "createPath(", "seedRandom(", "random(", "linear(", "__h(", "__begin", "__S", "arguments",
];
/// Numbers swapped in for numeric literals.
#[rustfmt::skip]
const NUMBERS: &[&str] = &[
"0", "-0", "1", "-1", "0.5", "2", "16", "17", "255", "256", "65536", "4294967295", "4294967296", "9007199254740993",
"1e308", "-1e308", "1e-320", "1e400", "NaN", "Infinity", "-Infinity", "1e9", "1e15", "-2147483648", "0x7fffffff",
];
/// A fragment that nests (or chains) once per repetition: what overflows a recursive parser.
#[rustfmt::skip]
const NESTS: &[(&str, &str)] = &[
("(", ")"), ("[", "]"), ("{", "}"), ("-", ""), ("!", ""), ("~", ""), ("+", ""), ("++", ""), ("--", ""), ("typeof ", ""),
("new ", ""), ("await ", ""), ("void ", ""), ("delete ", ""), ("x**", ""), ("x+", ""), ("x*", ""), ("x=", ""),
("x?x:", ""), ("x&&", ""), ("x||", ""), ("x??", ""), ("x<", ""), ("x==", ""), ("x,", ""), ("()=>", ""), ("a=>", ""),
("x.", "y"), ("f(", ")"), ("x[", "]"), ("`${", "}`"), ("function(){return ", "}"), ("{a:", "}"), ("if(x)", ""),
("x.y", ""), ("f()", ""), ("x[0]", ""),
];
/// One mutated expression from the seeds, decided by the iteration's own generator.
fn mutate(r: &mut Rng) -> String {
let mut s: Vec<char> = r.pick(SEEDS).chars().collect();
if r.chance(4) {
// Splice two expressions together.
let other: Vec<char> = r.pick(SEEDS).chars().collect();
let at = r.below(s.len() + 1);
let glue: Vec<char> = r.pick(&[";\n", " + ", " * ", ", ", "(", "."]).chars().collect();
s.splice(at..at, glue.into_iter().chain(other));
}
for _ in 0..1 + r.below(4) {
let len = s.len();
match r.below(10) {
0 if len > 0 => {
// A bit flip in the UTF-8 bytes (invalid sequences become U+FFFD).
let mut b = s.iter().collect::<String>().into_bytes();
let i = r.below(b.len());
b[i] ^= 1 << r.below(8);
s = String::from_utf8_lossy(&b).chars().collect();
}
1 if len > 0 => {
let a = r.below(len);
let e = (a + 1 + r.below(16)).min(len);
s.drain(a..e);
}
2 if len > 0 => {
let a = r.below(len);
let e = (a + 1 + r.below(32)).min(len);
let chunk = s[a..e].to_vec();
let at = r.below(len + 1);
s.splice(at..at, chunk);
}
3 | 4 => {
let t: Vec<char> = r.pick(TOKENS).chars().collect();
let at = r.below(len + 1);
s.splice(at..at, t);
}
5 => {
// An extreme number in place of a numeric literal (or anywhere).
let starts: Vec<usize> = (0..len).filter(|&i| s[i].is_ascii_digit() && (i == 0 || !s[i - 1].is_ascii_alphanumeric())).collect();
let n: Vec<char> = r.pick(NUMBERS).chars().collect();
if starts.is_empty() {
let at = r.below(len + 1);
s.splice(at..at, n);
} else {
let a = *r.pick(&starts);
let e = (a..len).find(|&i| !(s[i].is_ascii_alphanumeric() || s[i] == '.')).unwrap_or(len);
s.splice(a..e, n);
}
}
6 => {
// Deep nesting or a long chain, closed or not.
let (open, close) = *r.pick(NESTS);
let depth = *r.pick(&[8, 40, 300, 5_000, 100_000]);
let at = r.below(len + 1);
let mut nest: Vec<char> = open.repeat(depth).chars().collect();
nest.push('1');
if !r.chance(4) {
nest.extend(close.repeat(depth).chars());
}
s.splice(at..at, nest);
}
7 if len > 0 => s.truncate(r.below(len)),
8 => {
// A whole statement in front or behind.
let other: Vec<char> = r.pick(SEEDS).chars().collect();
if r.chance(2) {
s.extend(";\n".chars().chain(other));
} else {
s.splice(0..0, other.into_iter().chain(";\n".chars()));
}
}
_ => {}
}
}
s.into_iter().collect()
}
/// Where an input is evaluated: a scalar, a vector, a text and a path property.
const TARGETS: &[(usize, &str)] = &[(0, ROT), (0, POS), (2, "text/sourceText"), (0, "masks/#1/path"), (3, ROT), (1, OPA)];
const TIMES: &[f64] = &[0.0, 0.5, 1.25, 4.0, -1.0, 1e6];
/// Syntax-check `text`, evaluate it on one property, read it from another layer's expression
/// and render a small frame with it. `Err` only for what must never happen: an `internal error`
/// (a panic caught by a guard).
fn eval_input(f: &mut Fx, text: &str, target: usize, t: f64) -> Result<(), String> {
internal(&check_syntax(text))?;
let layers = [f.a, f.b, f.title, f.shape];
let (li, path) = TARGETS[target % TARGETS.len()];
let layer = layers[li];
internal(&eval_standalone(&f.p, f.cid, layer, path, text, t))?;
// As the property's own expression, read by B's rotation and drawn by the renderer.
f.set_expr(layer, path, text);
f.set_expr(f.b, ROT, &format!("var v = thisComp.layer({}).transform.rotation; v + 1", li + 1));
internal(&eval_property(&f.p, f.cid, layer, path, t))?;
internal(&eval_property(&f.p, f.cid, f.b, ROT, t))?;
let host = Expressions;
let mut r = Renderer::new(&f.p, &NoFootage, RenderOpts { motion_blur: false, scale: 0.25, ..Default::default() });
r.expr = Some(&host);
r.comp_frame(f.cid, secs(t));
// Leave the fixture as it was.
let l = f.layer_mut(layer);
if let Some(p) = l.props.prop_mut(path) {
p.expr = None;
}
if let Some(p) = f.layer_mut(f.b).props.prop_mut(ROT) {
p.expr = None;
}
Ok(())
}
fn internal<T>(r: &Result<T, String>) -> Result<(), String> {
match r {
Err(e) if e.contains("internal error") => Err(e.clone()),
_ => Ok(()),
}
}
struct Job {
text: String,
target: usize,
t: f64,
}
/// A worker thread that keeps its expression runtime between inputs, like a render thread.
struct Worker {
jobs: Sender<Job>,
results: Receiver<Result<(), String>>,
}
impl Worker {
fn start() -> Result<Worker, String> {
let (jobs, rx) = channel::<Job>();
let (tx, results) = channel();
std::thread::Builder::new()
.name("fuzz-expr".into())
.stack_size(2 << 20)
.spawn(move || {
let mut f = fx();
for job in rx {
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| eval_input(&mut f, &job.text, job.target, job.t)));
let r = r.unwrap_or_else(|p| {
Err(format!(
"panic: {}",
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default()
))
});
let failed = r.is_err();
if tx.send(r).is_err() || failed {
break; // after a panic the fixture and runtime may be half-updated: start over
}
}
})
.map_err(|e| format!("couldn't start the fuzz thread: {e}"))?;
Ok(Worker { jobs, results })
}
/// Run one input: a panic becomes its message, a hang a timeout (the worker is then dropped).
fn run(&self, job: Job, deadline: Duration) -> Result<(), String> {
self.jobs.send(job).map_err(|_| "the fuzz worker exited".to_string())?;
self.results.recv_timeout(deadline).unwrap_or_else(|_| Err(format!("hang: no result within {deadline:?}")))
}
}
fn env_u64(name: &str, default: u64) -> u64 {
std::env::var(name).ok().and_then(|v| v.trim().parse().ok()).unwrap_or(default)
}
#[test]
fn fuzz_expressions_never_crash() {
let seed = env_u64("EFFECTCRAFT_FUZZ_SEED", 0x00EC_E8E2);
let start = env_u64("EFFECTCRAFT_FUZZ_START", 0);
let iters = env_u64("EFFECTCRAFT_FUZZ_ITERS", 1_500);
let budget = Duration::from_secs(env_u64("EFFECTCRAFT_FUZZ_SECS", 5));
let verbose = env_u64("EFFECTCRAFT_FUZZ_VERBOSE", 0) != 0;
// Every evaluation gives up after `MAX_TIME`, and an input is evaluated a handful of times.
let deadline = crate::MAX_TIME * 24;
let mut worker = Worker::start().unwrap();
// The unmutated seeds evaluate everywhere without crashing.
for (i, s) in SEEDS.iter().enumerate() {
assert_eq!(worker.run(Job { text: s.to_string(), target: i, t: 0.5 }, deadline), Ok(()), "{s}");
}
let began = Instant::now();
let mut failures = vec![];
let mut ran = 0;
for i in start..start + iters {
if began.elapsed() > budget {
break;
}
let mut r = Rng(seed ^ i.wrapping_mul(0xD6E8_FEB8_6659_FD93));
let text = mutate(&mut r);
let target = r.below(TARGETS.len());
let t = *r.pick(TIMES);
if verbose {
eprintln!("iteration {i}: target {target}, t {t}, {} chars: {:?}", text.len(), text.chars().take(300).collect::<String>());
}
ran += 1;
if let Err(e) = worker.run(Job { text: text.clone(), target, t }, deadline) {
let head: String = text.chars().take(200).collect();
failures.push(format!("iteration {i} (seed {seed:#x}, target {target}, t {t}, {} chars): {e}\n input starts: {head:?}", text.len()));
if failures.len() >= 5 {
break;
}
worker = Worker::start().unwrap();
}
}
assert!(failures.is_empty(), "expressions crashed on {} of {ran} fuzz inputs:\n{}", failures.len(), failures.join("\n"));
eprintln!("fuzz_expressions: {ran} inputs in {:?}, no crashes", began.elapsed());
}