fix: prevent CPU spinning from regex backtracking and TTSR throttling (#468)

Replace [\s\S]*? regex patterns with indexOf-based string parsing in
boundary map, preferences, and skill-discovery frontmatter parsers to
eliminate catastrophic backtracking on content containing code fences.

Add 50ms throttle to TTSR JS-fallback regex path to prevent CPU spinning
when token deltas arrive faster than regex evaluation on growing buffers.

Closes #468
This commit is contained in:
Flux Labs 2026-03-15 12:41:28 -05:00
parent e147b2dfdf
commit fa4dae7e08
5 changed files with 90 additions and 12 deletions

View file

@ -261,14 +261,30 @@ function _parseRoadmapImpl(content: string): Roadmap {
let produces = '';
let consumes = '';
const prodMatch = sectionContent.match(/^Produces:\s*\n([\s\S]*?)(?=^Consumes|$)/m);
if (prodMatch) produces = prodMatch[1].trim();
// Use indexOf-based parsing instead of [\s\S]*? regex to avoid
// catastrophic backtracking on content with code fences (#468).
const prodIdx = sectionContent.search(/^Produces:\s*$/m);
if (prodIdx !== -1) {
const afterProd = sectionContent.indexOf('\n', prodIdx);
if (afterProd !== -1) {
const consIdx = sectionContent.search(/^Consumes/m);
const endIdx = consIdx !== -1 && consIdx > afterProd ? consIdx : sectionContent.length;
produces = sectionContent.slice(afterProd + 1, endIdx).trim();
}
}
const consMatch = sectionContent.match(/^Consumes[^:]*:\s*\n?([\s\S]*?)$/m);
if (consMatch) consumes = consMatch[1].trim();
const consLineMatch = sectionContent.match(/^Consumes[^:]*:\s*(.+)$/m);
if (consLineMatch) {
consumes = consLineMatch[1].trim();
}
if (!consumes) {
const singleCons = sectionContent.match(/^Consumes[^:]*:\s*(.+)$/m);
if (singleCons) consumes = singleCons[1].trim();
const consIdx = sectionContent.search(/^Consumes[^:]*:\s*$/m);
if (consIdx !== -1) {
const afterCons = sectionContent.indexOf('\n', consIdx);
if (afterCons !== -1) {
consumes = sectionContent.slice(afterCons + 1).trim();
}
}
}
boundaryMap.push({ fromSlice, toSlice, produces, consumes });

View file

@ -369,9 +369,11 @@ function loadPreferencesFile(path: string, scope: "global" | "project"): LoadedG
}
function parsePreferencesMarkdown(content: string): GSDPreferences | null {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return null;
return parseFrontmatterBlock(match[1]);
// Use indexOf instead of [\s\S]*? regex to avoid backtracking (#468)
if (!content.startsWith('---\n')) return null;
const endIdx = content.indexOf('\n---', 4);
if (endIdx === -1) return null;
return parseFrontmatterBlock(content.slice(4, endIdx));
}
function parseFrontmatterBlock(frontmatter: string): GSDPreferences {

View file

@ -110,10 +110,12 @@ function listSkillDirs(): string[] {
function parseSkillFrontmatter(path: string): { name?: string; description?: string } | null {
try {
const content = readFileSync(path, "utf-8");
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return null;
// Use indexOf instead of [\s\S]*? regex to avoid backtracking (#468)
if (!content.startsWith('---\n')) return null;
const endIdx = content.indexOf('\n---', 4);
if (endIdx === -1) return null;
const fm = match[1];
const fm = content.slice(4, endIdx);
const result: { name?: string; description?: string } = {};
const nameMatch = fm.match(/^name:\s*(.+)$/m);

View file

@ -1661,4 +1661,44 @@ console.log('\n=== LLM round-trip: extra blank lines ===');
assertTrue(consecutiveBlanks === null, 'blank-lines: formatted output has no 4+ consecutive newlines');
}
// ═══════════════════════════════════════════════════════════════════════════
// parseRoadmap: boundary map with embedded code fences (#468)
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== parseRoadmap: boundary map with code fences (#468) ===');
{
const content = `# M001: Test
**Vision:** Test
## Slices
- [ ] **S01: Core** \`risk:low\` \`depends:[]\`
- [ ] **S02: API** \`risk:low\` \`depends:[S01]\`
## Boundary Map
### S01 S02
Produces:
types.ts all types
\`\`\`
const x = 1;
\`\`\`
Consumes: nothing
`;
// This test ensures the boundary map parser does not hang or
// catastrophically backtrack when content contains code fences.
const start = Date.now();
const r = parseRoadmap(content);
const elapsed = Date.now() - start;
assertTrue(elapsed < 1000, `boundary map with code fences parsed in ${elapsed}ms (should be < 1s)`);
assertEq(r.slices.length, 2, 'code-fence roadmap: slice count');
// Boundary map should still parse (may not capture perfectly with code fences, but must not hang)
assertTrue(r.boundaryMap.length >= 0, 'code-fence roadmap: boundary map parsed without hanging');
}
report();

View file

@ -98,6 +98,12 @@ const DEFAULT_SETTINGS: Required<TtsrSettings> = {
/** Cap per-stream buffer at 512KB to prevent unbounded memory growth. */
const MAX_BUFFER_BYTES = 512 * 1024;
/**
* Minimum interval (ms) between JS-fallback regex checks on the same buffer.
* Prevents CPU spinning when deltas arrive faster than regex evaluation (#468).
*/
const JS_FALLBACK_CHECK_INTERVAL_MS = 50;
const DEFAULT_SCOPE: TtsrScope = {
allowText: true,
allowThinking: false,
@ -110,6 +116,8 @@ export class TtsrManager {
readonly #rules = new Map<string, TtsrEntry>();
readonly #injectionRecords = new Map<string, InjectionRecord>();
readonly #buffers = new Map<string, string>();
/** Tracks last JS-fallback check time per buffer key to throttle CPU (#468). */
readonly #lastJsCheckAt = new Map<string, number>();
#messageCount = 0;
#nativeHandle: number | null = null;
#nativeDirty = false;
@ -361,6 +369,15 @@ export class TtsrManager {
}
// ── JS fallback: per-rule regex iteration ─────────────────────────
// Throttle JS regex checks to prevent CPU spinning on fast token
// streams — regex on a growing buffer is O(rules × buffer_size) (#468).
const now = Date.now();
const lastCheck = this.#lastJsCheckAt.get(bufferKey) ?? 0;
if (now - lastCheck < JS_FALLBACK_CHECK_INTERVAL_MS) {
return [];
}
this.#lastJsCheckAt.set(bufferKey, now);
const matches: Rule[] = [];
for (const [name, entry] of this.#rules) {
if (!this.#canTrigger(name)) continue;
@ -406,6 +423,7 @@ export class TtsrManager {
/** Reset stream buffers (called on new turn). */
resetBuffer(): void {
this.#buffers.clear();
this.#lastJsCheckAt.clear();
}
/** Check if any TTSR rules are registered. */