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:
parent
e147b2dfdf
commit
fa4dae7e08
5 changed files with 90 additions and 12 deletions
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue