From d0fdf948db4ad425b3fe19ec8ad8ee035d792059 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 18 Oct 2026 13:09:44 -0700 Subject: [PATCH] Calc: a recursive LAMBDA stops at a fixed count of 140 calls A LAMBDA recursion answered #NUM! once the formula had used 512 KiB of native stack, so the depth it reached depended on frame sizes. On a Windows release build the deepest countdown was F(F,94), where 0.7.0 reached F(F,131). The evaluator now counts the LAMBDA calls a formula has in progress, in call_lambda_values, which direct calls, defined names, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY all go through. The 141st call answers #NUM!. The countdown F(F,131), the deepest that the 400-level MAX_DEPTH allows, has 132 calls in progress and still runs. The stack check stays as a guard. Its budget rises to 896 KiB, which leaves 128 KiB of a 1 MiB thread for the frames above the formula. a_recursive_lambda_stops_at_the_same_call_on_any_stack_size runs recursions on each side of the limit on a 1 MiB and a 64 MiB thread and expects the same results. The 1 MiB test now runs the countdown F(F,131). --- crates/calc/src/eval.rs | 31 ++++++++++++++++++++++++++----- crates/calc/src/tests.rs | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 2bbedd551..bc60c5b7a 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -49,10 +49,16 @@ fn err(e: CellError) -> Ev { Ev::V(Value::Error(e)) } +/// LAMBDA calls that one formula may have in progress at once, counted the same way on every build. +/// The next call answers `#NUM!`. A countdown from 131, the deepest that `MAX_DEPTH` allows, has 132 +/// calls in progress, so it still runs. +const MAX_LAMBDA_CALLS: usize = 140; + /// Native stack, in bytes, that one formula's evaluation may use below its first `eval`. Past it, -/// `eval` answers `#NUM!`. One level of `MAX_DEPTH` can take several stack frames. The Windows main -/// thread and the web build have 1 MiB of stack. -const STACK_BUDGET: usize = 512 * 1024; +/// `eval` answers `#NUM!`. It guards the stack and leaves 128 KiB of a 1 MiB thread for the frames +/// above the formula. A cell that another cell's formula computes on demand gets its own `Evaluator`, +/// which starts its own budget. +const STACK_BUDGET: usize = 896 * 1024; /// The address of a local variable, which shows how far down the native stack this call is. #[inline(always)] @@ -96,6 +102,8 @@ pub struct Evaluator<'h> { pub at: CellRef, env: Vec<(String, Ev)>, depth: usize, + /// LAMBDA calls in progress. + calls: usize, /// `stack_position()` at the outermost `eval`. stack_top: usize, } @@ -135,14 +143,22 @@ impl Ctx for FnCtx<'_, '_> { _ => None, }) .collect(); - let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth, stack_top: self.ev.stack_top }; + let mut ev = Evaluator { + host: &mut *self.ev.host, + sheet: area.sheet, + at, + env, + depth: self.ev.depth, + calls: self.ev.calls, + stack_top: self.ev.stack_top, + }; Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true))) } } impl<'h> Evaluator<'h> { pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self { - Evaluator { host, sheet, at, env: Vec::new(), depth: 0, stack_top: 0 } + Evaluator { host, sheet, at, env: Vec::new(), depth: 0, calls: 0, stack_top: 0 } } /// Evaluates to a value (references are dereferenced; multi-cell → array). @@ -963,10 +979,15 @@ impl<'h> Evaluator<'h> { } fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev { + if self.calls >= MAX_LAMBDA_CALLS { + return err(CellError::Num); + } + self.calls += 1; let saved = std::mem::replace(&mut self.env, l.env.clone()); self.env.extend(bound); let r = self.eval(&l.body); self.env = saved; + self.calls -= 1; r } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 99ef117ce..d56bf0c13 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -662,7 +662,7 @@ fn a_lambda_that_calls_itself_without_end_is_num_on_a_small_stack() { t.set("A1", endless); t.set("A2", "=LOOP(1)"); t.set("A3", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+SUM(MAP({1},LAMBDA(v,me(me,n-1)))))),F(F,100000))"); - t.set("A4", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+me(me,n-1))),F(F,50))"); + t.set("A4", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+me(me,n-1))),F(F,131))"); let evaluated = crate::evaluate(&t.wb, 0, c("B1"), endless); [t.get("A1"), t.get("A2"), t.get("A3"), evaluated, t.get("A4")] }) @@ -672,7 +672,35 @@ fn a_lambda_that_calls_itself_without_end_is_num_on_a_small_stack() { assert_eq!(endless_name, Value::Error(CellError::Num)); assert_eq!(through_map, Value::Error(CellError::Num)); assert_eq!(evaluated, Value::Error(CellError::Num)); - assert_eq!(countdown, Value::Number(50.0)); + assert_eq!(countdown, Value::Number(131.0)); +} + +#[test] +fn a_recursive_lambda_stops_at_the_same_call_on_any_stack_size() { + let run = |stack: usize| { + std::thread::Builder::new() + .stack_size(stack) + .spawn(|| { + let mut wb = Workbook::new(); + wb.names.push(gridcraft_model::DefinedName { + name: "CNT".into(), + scope: None, + formula: "LAMBDA(n,IF(n=0,0,1+CNT(n-1)))".into(), + comment: String::new(), + hidden: false, + }); + // F(F,n) has n + 1 calls in progress at its deepest; W adds one more call per step. + let tail = |n: u32| format!("=LET(F,LAMBDA(me,n,IF(n=0,0,me(me,n-1))),F(F,{n}))"); + let wrapped = |n: u32| format!("=LET(W,LAMBDA(f,k,f(f,k)),F,LAMBDA(me,n,IF(n<=0,0,1+W(me,n-1))),F(F,{n}))"); + [tail(139), tail(140), wrapped(69), wrapped(70), "=CNT(131)".to_string()].map(|f| crate::evaluate(&wb, 0, c("B1"), &f)) + }) + .expect("spawn") + .join() + .expect("recursion must not overflow the stack") + }; + let small = run(1 << 20); + assert_eq!(small, [Value::Number(0.0), Value::Error(CellError::Num), Value::Number(69.0), Value::Error(CellError::Num), Value::Number(131.0)]); + assert_eq!(run(64 << 20), small); } #[test]