Repository navigation
A CEL rule whose compile fails for want of stack stays cached, so every later validation of its type fails #204
Description
Versions: @bufbuild/protovalidate 1.3.0 (the latest release; main has the same code), @bufbuild/protobuf 2.16.0, Node 26.11.1.
What happens. CelManager.compileRule (packages/protovalidate/src/cel.ts) catches any error that parse or plan throws and returns it as a compilation_error result. Planner.plan (planner.ts) caches the evaluator it builds for a message type in messageCache. Its try/catch drops that entry only when planning throws, but a rule that failed to compile is returned, not thrown, so the evaluator holding the failed rule stays cached.
So when the first validation of a type runs with the JavaScript stack nearly out, as when a deep caller validates, compiling one of its rules can fail for want of stack, and that failure is kept as the rule's CompilationError. Every later validate() of the type on that validator returns the same error, though the stack is free by then. In V8 the failure isn't even a RangeError: building a regular expression with the stack nearly out throws SyntaxError: Invalid regular expression: /^[rR]/g: Stack overflow.
Predefined rules, compiled through compileRules and cached in rulesCache, look to be kept the same way.
Repro. Use a message type with several CEL rules. Whether a given depth fails in compiling rather than elsewhere depends on the type's rules, so this tries a new validator at each depth as the stack unwinds, and stops at the first whose compile fails.
import { create } from "@bufbuild/protobuf";
import { CompilationError, createValidator, type Validator } from "@bufbuild/protovalidate";
import { ExampleSchema } from "./gen/example_pb"; // a message type with several CEL rules
const message = create(ExampleSchema, {});
let found: { validator: Validator; first: unknown } | undefined;
const deep = (): void => {
try {
deep();
} catch (error) {
if (found === undefined) {
const validator = createValidator();
try {
const result = validator.validate(ExampleSchema, message);
if (result.kind === "error" && result.error instanceof CompilationError) found = { validator, first: result };
} catch {
// not enough stack even to start; a shallower frame tries
}
}
throw error;
}
};
try {
deep();
} catch {
// the recursion's own RangeError
}
console.log(found?.first); // { kind: "error", error: CompilationError: failed to compile …: SyntaxError: Invalid regular expression: /^[rR]/g: Stack overflow }
console.log(found?.validator.validate(ExampleSchema, message)); // the same error, with the stack free
console.log(createValidator().validate(ExampleSchema, message)); // a new validator compiles it: { kind: "invalid", … }With one of our types, a record with five message-level CEL rules and standard rules on many of its fields, that prints:
first: CompilationError: failed to compile change.follows: SyntaxError: Invalid regular expression: /^[rR]/g: Stack overflow
later: CompilationError: failed to compile change.follows: SyntaxError: Invalid regular expression: /^[rR]/g: Stack overflow
fresh: invalid
Expected. A failure that doesn't come from the rule itself, such as running out of stack, isn't kept as the rule's compile result, so the next validation compiles the rule again. For instance, plan could keep a type's evaluator only once all of its rules compiled, or compileRule could rethrow what isn't about the expression, so that plan's catch drops the entry.
Workaround. When a validation fails with a CompilationError, make a new validator and validate again.
Activity