Skip to content

Commit f30c050

Browse files
kckernclaude
andcommitted
test(web): the backtracking audit measures work, not wall time
"no pattern backtracks badly on 500 characters" failed under full-suite load: it required every pattern to run in under 25 ms of wall time. Each pattern's cost is now the fastest of three runs, compared with a linear scan of the same string timed the same way in the same run; it fails only when it is both over 10 ms and over 500 linear scans. The table's worst is about 1.3 ms / 110 scans. A self-test, and a temporary \b3.*ne.*ne.*?acts added to the table locally (about 29 ms / 2,300 scans, failed as BOOKS 108; reverted), show it still catches a pattern that backtracks badly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c26dc42 commit f30c050

1 file changed

Lines changed: 28 additions & 4 deletions

File tree

‎apps/web/test/unit/cleanReference.test.js‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,38 @@ describe('cleanReference: cost of adversarial input', () => {
100100
});
101101
});
102102

103+
// Work, not wall time: each pattern's cost on 500 characters (the most cleanReference ever
104+
// reads) is compared with a linear scan of the same string, timed the same way in the same run,
105+
// so a busy machine slows both. The fastest of a few runs is each cost (load only adds time).
106+
// A pattern fails only when it is both slow (over 10 ms) and over 500 linear scans' worth. The
107+
// table's worst costs about 1.3 ms, 110 scans; one more .* in \b3.*ne...*?acts (\b3.*ne.*ne.*?acts)
108+
// costs about 30 ms, 2500 scans, and fails.
109+
function cost(fn, runs) {
110+
let best = Infinity;
111+
for (let i = 0; i < runs; i++) {
112+
const t = performance.now();
113+
fn();
114+
best = Math.min(best, performance.now() - t);
115+
}
116+
return best;
117+
}
118+
function backtrackCost(re, frag, len = 500) {
119+
const s = frag.repeat(Math.ceil(len / frag.length)).slice(0, len);
120+
const linear = cost(() => s.replace(/[^]/g, 'X'), 5);
121+
const pattern = cost(() => s.replace(re, 'X'), 3);
122+
return { pattern, linear, ratio: pattern / Math.max(linear, 0.001) };
123+
}
124+
const backtracksBadly = ({ pattern, ratio }) => pattern > 10 && ratio > 500;
125+
103126
describe('regex tables: no pattern backtracks badly on 500 characters', () => {
104127
const FRAGMENTS = ['3ne ', '3ne 3ne ', 'the the ', 's ', 'of a ', ',', ',,', '0', '\t. ', ' ', 'jst ', 'al-', 'w o ', 'd c a ', 'surah a ', 'qur ', 'l o '];
105128
test.each(Object.entries(REGEX_TABLES).flatMap(([t, list]) => list.map(([re], i) => [`${t} ${i}`, re])))('%s', (name, re) => {
106129
for (const frag of FRAGMENTS) {
107-
const s = frag.repeat(Math.ceil(500 / frag.length)).slice(0, 500);
108-
const t = performance.now();
109-
s.replace(re, 'X');
110-
expect(performance.now() - t).toBeLessThan(25);
130+
const c = backtrackCost(re, frag);
131+
expect(backtracksBadly(c), `${name} on ${JSON.stringify(frag)}: ${c.pattern.toFixed(2)} ms, ${Math.round(c.ratio)} linear scans`).toBe(false);
111132
}
112133
});
134+
test('the measure catches a pattern that backtracks badly', () => {
135+
expect(backtracksBadly(backtrackCost(/\b3.*ne.*ne.*?acts/gi, '3ne '))).toBe(true);
136+
});
113137
});

0 commit comments

Comments
 (0)