From fa4dae7e08f1ed18b2b2d573334c3ad719b7e5f8 Mon Sep 17 00:00:00 2001 From: Flux Labs Date: Sun, 15 Mar 2026 12:41:28 -0500 Subject: [PATCH] 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 --- src/resources/extensions/gsd/files.ts | 28 ++++++++++--- src/resources/extensions/gsd/preferences.ts | 8 ++-- .../extensions/gsd/skill-discovery.ts | 8 ++-- .../extensions/gsd/tests/parsers.test.ts | 40 +++++++++++++++++++ src/resources/extensions/ttsr/ttsr-manager.ts | 18 +++++++++ 5 files changed, 90 insertions(+), 12 deletions(-) diff --git a/src/resources/extensions/gsd/files.ts b/src/resources/extensions/gsd/files.ts index 7e4c135e1..76606e325 100644 --- a/src/resources/extensions/gsd/files.ts +++ b/src/resources/extensions/gsd/files.ts @@ -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 }); diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 2f06c7154..52cb43e19 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -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 { diff --git a/src/resources/extensions/gsd/skill-discovery.ts b/src/resources/extensions/gsd/skill-discovery.ts index 8d4c2b76d..f623c1a21 100644 --- a/src/resources/extensions/gsd/skill-discovery.ts +++ b/src/resources/extensions/gsd/skill-discovery.ts @@ -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); diff --git a/src/resources/extensions/gsd/tests/parsers.test.ts b/src/resources/extensions/gsd/tests/parsers.test.ts index ca2de071a..9f99ef38e 100644 --- a/src/resources/extensions/gsd/tests/parsers.test.ts +++ b/src/resources/extensions/gsd/tests/parsers.test.ts @@ -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(); diff --git a/src/resources/extensions/ttsr/ttsr-manager.ts b/src/resources/extensions/ttsr/ttsr-manager.ts index b44eead88..96e756cf0 100644 --- a/src/resources/extensions/ttsr/ttsr-manager.ts +++ b/src/resources/extensions/ttsr/ttsr-manager.ts @@ -98,6 +98,12 @@ const DEFAULT_SETTINGS: Required = { /** 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(); readonly #injectionRecords = new Map(); readonly #buffers = new Map(); + /** Tracks last JS-fallback check time per buffer key to throttle CPU (#468). */ + readonly #lastJsCheckAt = new Map(); #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. */