From 672c102eb2eb375b3b2752f1e446147aebd522dc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 15:12:02 -0700 Subject: [PATCH 1/4] Calc: a LAMBDA that calls itself without end answers #NUM! instead of overflowing the stack The evaluator stopped at MAX_DEPTH (400) nested eval calls. A LAMBDA call takes one of those levels but several stack frames (eval, eval_inner, call, builtin, apply_lambda, call_lambda_values), so on the 1 MiB main thread of the Windows build `=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))` overflowed the stack before it reached the limit, and the process ended. eval now also stops once a formula has used 512 KiB of stack below its first eval. Both limits answer #NUM!, the error Excel documents for too many recursive calls, and a defined name that holds the recursion passes that error on instead of answering #NAME?. Fixes #1662 --- crates/calc/src/eval.rs | 37 +++++++++++++++++++++++++++++-------- crates/calc/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 0f9cfb03d..2bbedd551 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -49,6 +49,18 @@ fn err(e: CellError) -> Ev { Ev::V(Value::Error(e)) } +/// 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; + +/// The address of a local variable, which shows how far down the native stack this call is. +#[inline(always)] +fn stack_position() -> usize { + let marker = 0u8; + std::hint::black_box(&marker) as *const u8 as usize +} + /// What the evaluator needs from its surroundings. pub trait Host { fn workbook(&self) -> &Workbook; @@ -84,6 +96,8 @@ pub struct Evaluator<'h> { pub at: CellRef, env: Vec<(String, Ev)>, depth: usize, + /// `stack_position()` at the outermost `eval`. + stack_top: usize, } struct FnCtx<'a, 'h> { @@ -121,14 +135,14 @@ impl Ctx for FnCtx<'_, '_> { _ => None, }) .collect(); - let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth }; + let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth, 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 } + Evaluator { host, sheet, at, env: Vec::new(), depth: 0, stack_top: 0 } } /// Evaluates to a value (references are dereferenced; multi-cell → array). @@ -205,10 +219,16 @@ impl<'h> Evaluator<'h> { } pub fn eval(&mut self, e: &Expr) -> Ev { + let here = stack_position(); + if self.depth == 0 { + self.stack_top = here; + } else if self.stack_top.abs_diff(here) > STACK_BUDGET { + return err(CellError::Num); + } self.depth += 1; if self.depth > MAX_DEPTH { self.depth -= 1; - return err(CellError::Calc); + return err(CellError::Num); } let r = self.eval_inner(e); self.depth -= 1; @@ -492,7 +512,7 @@ impl<'h> Evaluator<'h> { } _ => { // A long chain such as A1+A2+…+A500 nests to the left: fold it in a loop instead - // of one level of recursion per operator (past the depth limit it was #CALC!). + // of one level of recursion per operator (past the depth limit it was an error). let mut rest = vec![(op, b)]; let mut left = a; while let Expr::Binary(o, l, r) = left @@ -801,10 +821,11 @@ impl<'h> Evaluator<'h> { fn builtin(&mut self, name: &str, args: &[Expr]) -> Ev { let Some(spec) = gridcraft_functions::lookup(name) else { // A defined name holding a LAMBDA, or a LET-bound lambda. - if let Ev::L(l) = self.name(name) { - return self.apply_lambda(&l, args); - } - return err(CellError::Name); + return match self.name(name) { + Ev::L(l) => self.apply_lambda(&l, args), + Ev::V(Value::Error(x)) => err(x), + _ => err(CellError::Name), + }; }; if args.len() < spec.min_args || args.len() > spec.max_args { return err(CellError::Value); diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 196c57de7..5cbbd5509 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -643,6 +643,38 @@ fn lambda_argument_count() { assert_eq!(t.get("A9"), Value::Bool(true)); } +#[test] +fn a_lambda_that_calls_itself_without_end_is_num_on_a_small_stack() { + // 1 MiB is the main thread's stack on Windows, where commands, recalculation and file open run. + let handle = std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(|| { + let mut t = T::new(); + t.wb.names.push(gridcraft_model::DefinedName { + name: "LOOP".into(), + scope: None, + formula: "LAMBDA(n,LOOP(n))".into(), + comment: String::new(), + hidden: false, + }); + t.calc.rebuild(&t.wb); + let endless = "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))"; + 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))"); + let evaluated = crate::evaluate(&t.wb, 0, c("B1"), endless); + [t.get("A1"), t.get("A2"), t.get("A3"), evaluated, t.get("A4")] + }) + .expect("spawn"); + let [endless, endless_name, through_map, evaluated, countdown] = handle.join().expect("recursion must not overflow the stack"); + assert_eq!(endless, Value::Error(CellError::Num)); + 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)); +} + #[test] fn names_and_sheets() { let mut t = T::new(); From 09973e7831ecdb03f8ee71ef9a3841cd26c6547f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 15:12:19 -0700 Subject: [PATCH 2/4] Contributors: add dotCooCoo (Robert Lee) --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index d522b84e0..96bead93e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -65,3 +65,7 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true From d0fdf948db4ad425b3fe19ec8ad8ee035d792059 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 18 Oct 2026 13:09:44 -0700 Subject: [PATCH 3/4] 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] From 450f457535e3c8537e45986deff5b602cff00e76 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 22 Oct 2026 11:06:01 -0700 Subject: [PATCH 4/4] Calc: a formula calculated inside another one shares its stack budget A cell that another cell's formula calculates on demand got its own evaluator, which started a new 896 KiB stack budget wherever it was called. Two cells that each count down from 131 with a LAMBDA, one read at the deepest call of the other, overflowed a 1 MiB thread. The budget of a formula calculated on demand is now counted from where the outermost formula started. When it runs out there, the formula is calculated again from the top, as a formula needed at the nesting limit is, so its value does not depend on the order cells are calculated in. --- crates/calc/src/eval.rs | 19 ++++++++++++++----- crates/calc/src/recalc.rs | 31 ++++++++++++++++++++++++++++++- crates/calc/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 1d39fd818..f2f5f5ef4 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -60,13 +60,13 @@ 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!`. 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. +/// above the formula. A cell that another cell's formula computes on demand shares the budget of the +/// outermost formula ([`Host::stack_top`]). 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)] -fn stack_position() -> usize { +pub(crate) fn stack_position() -> usize { let marker = 0u8; std::hint::black_box(&marker) as *const u8 as usize } @@ -98,6 +98,13 @@ pub trait Host { fn visit_range(&mut self, _sheet: usize, _range: RangeRef, _f: &mut dyn FnMut(&Value) -> bool) -> bool { false } + /// While a formula is calculated on demand inside another one: `stack_position()` where the + /// outermost one started, which the stack budget is counted from. + fn stack_top(&self) -> Option { + None + } + /// Called when a formula's evaluation runs past the stack budget. + fn stack_exhausted(&mut self) {} } pub struct Evaluator<'h> { @@ -265,8 +272,10 @@ impl<'h> Evaluator<'h> { pub fn eval(&mut self, e: &Expr) -> Ev { let here = stack_position(); if self.depth == 0 { - self.stack_top = here; - } else if self.stack_top.abs_diff(here) > STACK_BUDGET { + self.stack_top = self.host.stack_top().unwrap_or(here); + } + if self.stack_top.abs_diff(here) > STACK_BUDGET { + self.host.stack_exhausted(); return err(CellError::Num); } self.depth += 1; diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 66374bed2..eabc29314 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -44,7 +44,7 @@ use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value}; use gridcraft_formula::Expr; use gridcraft_model::{CalcMode, Workbook}; -use crate::eval::{Area, Evaluator, Host, precedents}; +use crate::eval::{Area, Evaluator, Host, precedents, stack_position}; pub type Key = (usize, CellRef); @@ -432,6 +432,10 @@ struct PassHost<'a> { tainted: HashSet, /// Whether the formula being evaluated has read a previous value. taint: bool, + /// `stack_position()` where the outermost formula being calculated started. + stack_top: usize, + /// Whether an evaluation ran past the stack budget since the innermost formula started. + out_of_stack: bool, } impl<'a> PassHost<'a> { @@ -462,6 +466,8 @@ impl<'a> PassHost<'a> { members: HashSet::default(), tainted: HashSet::default(), taint: false, + stack_top: 0, + out_of_stack: false, } } } @@ -580,6 +586,9 @@ impl PassHost<'_> { self.suspend = Some(k); return Value::Error(CellError::Calc); } + if self.depth == 0 { + self.stack_top = stack_position(); + } self.in_progress.insert(k); self.depth += 1; let outer_taint = std::mem::replace(&mut self.taint, false); @@ -587,10 +596,24 @@ impl PassHost<'_> { self.stack.push(k); } let expr = std::sync::Arc::clone(expr); + let outer_out_of_stack = std::mem::replace(&mut self.out_of_stack, false); let v = { let mut ev = Evaluator::new(self, k.0, k.1); ev.value(&expr) }; + let out_of_stack = std::mem::replace(&mut self.out_of_stack, outer_out_of_stack); + if out_of_stack && self.depth > 1 && self.suspend.is_none() { + // It ran out of the stack left below the formulas that read it: it is calculated + // from the top instead, as a formula needed at `MAX_NESTED` levels deep is. + self.suspend = Some(k); + self.depth -= 1; + self.in_progress.remove(&k); + if self.iterate { + self.stack.pop(); + } + self.taint = outer_taint; + return Value::Error(CellError::Calc); + } if self.suspend.is_some() { // Abandoned: `calculate` evaluates this formula again once the one it needs is done. self.depth -= 1; @@ -745,6 +768,12 @@ impl Host for PassHost<'_> { fn workbook(&self) -> &Workbook { self.wb } + fn stack_top(&self) -> Option { + (self.depth > 1).then_some(self.stack_top) + } + fn stack_exhausted(&mut self) { + self.out_of_stack = true; + } fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option { let k = (sheet, anchor); if !(self.pending.contains(&k) || self.results.contains_key(&k)) { diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 608af9320..6106f1bfc 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -834,6 +834,40 @@ fn a_recursive_lambda_stops_at_the_same_call_on_any_stack_size() { assert_eq!(run(64 << 20), small); } +#[test] +fn a_recursive_lambda_that_reads_another_cells_recursive_lambda_shares_the_stack() { + let run = |stack: usize| { + std::thread::Builder::new() + .stack_size(stack) + .spawn(|| { + // Row-major order calculates A1 first, so each cell below is calculated on demand + // at the deepest call of the one above it. + let countdown = |then: &str| format!("=LET(F,LAMBDA(me,n,IF(n<=0,{then},1+me(me,n-1))),F(F,131))"); + let mut wb = Workbook::new(); + let sheet = wb.sheet_mut(0).expect("sheet"); + for (at, f) in [ + ("A1", countdown("A2")), + ("A2", countdown("A3")), + ("A3", countdown("0")), + ("B1", countdown("B2")), + ("B2", "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))".to_string()), + ] { + sheet.cells.set(c(at), Cell::formula(Formula::new(&f))); + } + let mut calc = Calc::new(); + calc.recalc_all(&mut wb); + ["A1", "A2", "A3", "B1", "B2"].map(|at| wb.sheet(0).map(|s| s.value(c(at))).unwrap_or_default()) + }) + .expect("spawn") + .join() + .expect("recursion must not overflow the stack") + }; + let small = run(1 << 20); + let num = Value::Error(CellError::Num); + assert_eq!(small, [Value::Number(393.0), Value::Number(262.0), Value::Number(131.0), num.clone(), num]); + assert_eq!(run(64 << 20), small); +} + #[test] fn names_and_sheets() { let mut t = T::new();