fix(r): make the export-pattern nesting limit independent of JIT warm-up

MAX_NESTING was 2048, beyond what a cold process can recurse: a deeply nested
pattern failed with a stack overflow in a fresh process and compiled after a
warm-up, so whether a NAMESPACE pattern was in effect depended on the machine
and on what ran before it, and the reason given was misleading.

The limit is now 256, which still compiles on a quarter of the default stack,
so the verdict is the same cold or warm and carries the right reason.
This commit is contained in:
Mick Cooney 2026-10-17 17:25:25 +01:00
parent 13cdf4347a
commit 7808a1bdb4
3 changed files with 36 additions and 2 deletions

View file

@ -97,6 +97,7 @@ Not supported:
- Matching work and NFA states are counted in deterministic work units, never wall-clock time, so the outcome is the same on every machine. A NAMESPACE file is still read in full.
- Caps, in `linear-regex.ts`: `MAX_TOTAL_WORK` (40,000,000 state visits per matcher), `MAX_SHARED_WORK` (100,000,000 matching visits per load), `MAX_PACKAGE_WORK` (30,000,000 per package), `MAX_SHARED_STATES` (500,000 NFA states per load) and `MAX_PACKAGE_STATES` (100,000 per package); a single pattern is also capped by `MAX_PATTERN_LENGTH` (16,384 characters) and `MAX_STATES` (16,384).
- Groups nested deeper than 256 (`MAX_NESTING`) are refused with the reason "groups are nested too deeply". The limit sits well inside the call stack (a quarter of the default stack still compiles 256 levels), so the verdict is the same cold or warm and on every machine.
- A pattern that does not fit is dropped, exports nothing and is warned about: at most 50 patterns are named per package (`MAX_LISTED_DROPPED_PATTERNS`), followed by a summary line counting the rest.
- The first pattern refused for lack of states closes that package's allowance, so later patterns in the package are refused without being parsed.
- One hostile package cannot exhaust the load budget alone (it is limited to its own share), but several can (three, for matching work); packages processed after that degrade, with warnings. Which package degrades follows graph node order.

View file

@ -70,8 +70,14 @@ export const MAX_PATTERN_LENGTH = 16384;
*/
export const MAX_STATES = 16384;
/** Deepest group nesting accepted; bounds the parser's and the builder's recursion. */
const MAX_NESTING = 2048;
/**
* Deepest group nesting accepted; bounds the parser's and the builder's recursion. It is set
* well inside what the call stack allows, so that the verdict does not depend on the machine:
* a cold process (no JIT warm-up, so the biggest frames) compiled 1,500 levels in the
* measurements and ran out of stack somewhere before 2,048, and 256 levels still compile on a
* quarter of the default stack (`node --stack-size=250`). No NAMESPACE nests groups this deep.
*/
export const MAX_NESTING = 256;
/**
* Most `build` calls for one pattern. States are capped separately, but a body that

View file

@ -0,0 +1,27 @@
/**
* The group nesting limit of the export-pattern engine must lie inside what the call stack
* allows, or whether a deep pattern compiles depends on how warm the process is.
*/
import { describe, it, expect } from 'vitest';
import {
compileLinearRegex,
compileLinearRegexDetailed,
MAX_NESTING,
} from '../../src/core/ingestion/languages/r/linear-regex.js';
const reasonOf = (r: ReturnType<typeof compileLinearRegexDetailed>): string =>
'reason' in r ? r.reason : '';
describe('MAX_NESTING does not depend on how warm the process is', () => {
const nested = (depth: number): string => '('.repeat(depth) + 'a' + ')'.repeat(depth);
it('is 256, a quarter-stack-safe depth', () => {
expect(MAX_NESTING).toBe(256);
});
it('accepts exactly MAX_NESTING levels and refuses one more with the right reason', () => {
expect(compileLinearRegex(nested(256))).not.toBeNull();
const refused = compileLinearRegexDetailed(nested(257));
expect(reasonOf(refused)).toBe('groups are nested too deeply');
});
});