keep evaluateDocument's error boundary around tokenize/parse, not just evaluate

tokenize() and parseLine() ran outside the try/catch, and the parser
has no recursion-depth guard for nested parens. A pathologically
nested line overflowed the stack with an uncaught RangeError, killing
the whole document instead of nulling just that line — the exact
thing evaluateDocument is supposed to prevent.
This commit is contained in:
Horizontal42 2026-08-12 20:02:09 +03:00
parent aa29c12b37
commit 741ea16e40
2 changed files with 42 additions and 29 deletions

View file

@ -282,3 +282,13 @@ describe("review fixes", () => {
}); });
}); });
}); });
describe("regression 2026-08-12", () => {
it("a deeply nested-parens line nulls out just that line, not the whole document", () => {
const deep = "(".repeat(50000) + "1" + ")".repeat(50000);
const r = eng.evaluateDocument(`1+1\n${deep}\n2+2`).map((x) => x.text);
expect(r[0]).toBe("2");
expect(r[1]).toBeNull();
expect(r[2]).toBe("4");
});
});

View file

@ -3,7 +3,7 @@
import { DateOrder, Decimal, EngineSettings, EvalError, Quantity, Unit, Value, XRefError, defaultSettings, qty } from "./types"; import { DateOrder, Decimal, EngineSettings, EvalError, Quantity, Unit, Value, XRefError, defaultSettings, qty } from "./types";
import { Registry, buildRegistry, Completion } from "./registry"; import { Registry, buildRegistry, Completion } from "./registry";
import { tokenize, Token } from "./tokenizer"; import { tokenize, Token } from "./tokenizer";
import { parseLine } from "./parser"; import { parseLine, ParsedLine } from "./parser";
import { evaluate, EvalCtx, XRefResolver, toBase, fromBase } from "./evaluator"; import { evaluate, EvalCtx, XRefResolver, toBase, fromBase } from "./evaluator";
import { formatValue } from "./formatter"; import { formatValue } from "./formatter";
import { detectDateOrder } from "./datetime"; import { detectDateOrder } from "./datetime";
@ -118,12 +118,17 @@ export class SumEngine {
continue; continue;
} }
const tokens = tokenize(line, this.reg, this.dateOrder); // tokenize/parseLine/evaluate all run under one try/catch: pathological
const knownVars = new Set(vars.keys()); // input (e.g. stack-overflowing nested parens in the parser) must null
const parsed = parseLine(tokens, knownVars, line); // out this one line, not throw past evaluateDocument and kill the rest.
let tokens: Token[] = [];
let parsed: ParsedLine = { expr: null };
let value: Value | null = null; let value: Value | null = null;
let lineError: string | undefined; let lineError: string | undefined;
try {
tokens = tokenize(line, this.reg, this.dateOrder);
const knownVars = new Set(vars.keys());
parsed = parseLine(tokens, knownVars, line);
if (parsed.expr) { if (parsed.expr) {
const ctx: EvalCtx = { const ctx: EvalCtx = {
reg: this.reg, reg: this.reg,
@ -132,17 +137,16 @@ export class SumEngine {
historicalRates: this.historicalRates, historicalRates: this.historicalRates,
resolveXRef, resolveXRef,
}; };
try {
value = evaluate(parsed.expr, ctx);
// a line without a result beats a dead sheet. // a line without a result beats a dead sheet.
// surface unexpected bugs so the user knows why it failed value = evaluate(parsed.expr, ctx);
}
} catch (e) { } catch (e) {
if (e instanceof XRefError) { if (e instanceof XRefError) {
lineError = e.message; lineError = e.message;
} else if (e instanceof EvalError) { } else if (e instanceof EvalError) {
// Expected evaluation failure; remain silent // Expected evaluation failure; remain silent
} else { } else {
// Surface unexpected bugs/BigInt errors and log them // Surface unexpected bugs/BigInt errors/RangeErrors and log them
lineError = e instanceof Error ? e.message : String(e); lineError = e instanceof Error ? e.message : String(e);
console.warn("evaluate failed:", e); console.warn("evaluate failed:", e);
} }
@ -150,7 +154,6 @@ export class SumEngine {
} }
if (value?.kind === "quantity" && !value.value.isFinite()) value = null; if (value?.kind === "quantity" && !value.value.isFinite()) value = null;
if (value && parsed.assign) vars.set(parsed.assign, value); if (value && parsed.assign) vars.set(parsed.assign, value);
}
lineValues.push(value); lineValues.push(value);
lineKinds.push("normal"); lineKinds.push("normal");