workflow: linear meta-prefix scan (the regex backtracked exponentially)
Review finding, measured: the leading-trivia prefix regex (`^\s*(?:comment|comment|\s+)*export …`) partitions a whitespace run ambiguously between its outer `\s*` and the starred `\s+` alternative, so a script that ultimately FAILS the match backtracks exponentially — ~19 ms at 35 leading whitespace characters, ~174 ms at 38, ×2.2 per character; a realistic near-miss (a comment header, blank indented lines, then `const meta` missing its `export`) did not finish in 10 seconds. The regex ran on the HOST stack inside the synchronous `start()`, where no vm timeout applies and no abort can interleave — a benign one-token typo, exactly what SCRIPT_PARSE exists to bounce back to the model, hung the whole process instead of reaching that designed recovery. Replaced with a hand-rolled linear trivia scan (whitespace + `//` and `/* */` comments — the module already scans characters for the literal) followed by an anchored `^export\s+const\s+meta\s*=\s*` on the remainder, whose quantifiers cannot backtrack ambiguously. An unterminated block comment before the statement now gets its own SCRIPT_PARSE message. Regressions: the near-miss shape must reject in under a second (the old regex would trip the suite timeout), plus the unterminated-leading-comment and comment-to-EOF edges.
This commit is contained in:
@@ -29,8 +29,6 @@ export interface ExtractedScript {
|
|||||||
body: string
|
body: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const META_PREFIX = /^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*|\s+)*export\s+const\s+meta\s*=\s*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan `source` from `start` (an opening `{`) to its matching `}`, aware of
|
* Scan `source` from `start` (an opening `{`) to its matching `}`, aware of
|
||||||
* string literals (`'`/`"`/backtick, with escapes) and comments. Returns the
|
* string literals (`'`/`"`/backtick, with escapes) and comments. Returns the
|
||||||
@@ -145,6 +143,44 @@ function validateMetaShape(meta: unknown): { meta?: WorkflowMeta; violations: st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `export const meta =`, anchored AFTER {@link skipLeadingTrivia} — its quantifiers cannot backtrack ambiguously. */
|
||||||
|
const META_HEAD = /^export\s+const\s+meta\s*=\s*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index just past the leading trivia: whitespace and `//` / `/*`-style
|
||||||
|
* comments. A hand-rolled character scan, NOT a prefix regex — an
|
||||||
|
* all-alternation prefix (`\s*(?:comment|\s+)*`) partitions a whitespace run
|
||||||
|
* ambiguously and backtracks EXPONENTIALLY when the match ultimately fails,
|
||||||
|
* so a near-miss script (a comment header, then a forgotten `export`) would
|
||||||
|
* spin the host synchronously inside `start()`, where no vm timeout applies.
|
||||||
|
* The near-miss must fail fast into `SCRIPT_PARSE` instead — that error is
|
||||||
|
* the model's retry signal.
|
||||||
|
*/
|
||||||
|
function skipLeadingTrivia(source: string): number {
|
||||||
|
let index = 0
|
||||||
|
while (index < source.length) {
|
||||||
|
const ch = source.charAt(index)
|
||||||
|
if (/\s/.test(ch)) {
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ch === '/' && source[index + 1] === '/') {
|
||||||
|
const end = source.indexOf('\n', index)
|
||||||
|
if (end === -1) return source.length
|
||||||
|
index = end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ch === '/' && source[index + 1] === '*') {
|
||||||
|
const end = source.indexOf('*/', index + 2)
|
||||||
|
if (end === -1) throw new WorkflowError('script has an unterminated comment before the meta block', 'SCRIPT_PARSE')
|
||||||
|
index = end + 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract and validate the leading `export const meta = {...}` statement.
|
* Extract and validate the leading `export const meta = {...}` statement.
|
||||||
* Throws {@link WorkflowError} — `SCRIPT_PARSE` when the statement is missing
|
* Throws {@link WorkflowError} — `SCRIPT_PARSE` when the statement is missing
|
||||||
@@ -155,11 +191,12 @@ function validateMetaShape(meta: unknown): { meta?: WorkflowMeta; violations: st
|
|||||||
* @returns the validated meta and the line-preservingly blanked body.
|
* @returns the validated meta and the line-preservingly blanked body.
|
||||||
*/
|
*/
|
||||||
export function extractMeta(script: string, evalTimeoutMs: number): ExtractedScript {
|
export function extractMeta(script: string, evalTimeoutMs: number): ExtractedScript {
|
||||||
const match = META_PREFIX.exec(script)
|
const triviaEnd = skipLeadingTrivia(script)
|
||||||
|
const match = META_HEAD.exec(script.slice(triviaEnd))
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new WorkflowError('script must begin with `export const meta = {...}` (leading comments allowed)', 'SCRIPT_PARSE')
|
throw new WorkflowError('script must begin with `export const meta = {...}` (leading comments allowed)', 'SCRIPT_PARSE')
|
||||||
}
|
}
|
||||||
const literalStart = match[0].length
|
const literalStart = triviaEnd + match[0].length
|
||||||
if (script[literalStart] !== '{') {
|
if (script[literalStart] !== '{') {
|
||||||
throw new WorkflowError('`export const meta =` must be followed by an object literal', 'SCRIPT_PARSE')
|
throw new WorkflowError('`export const meta =` must be followed by an object literal', 'SCRIPT_PARSE')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,27 @@ return 2`
|
|||||||
expect(bad('export const meta = [1]').code).toBe('SCRIPT_PARSE')
|
expect(bad('export const meta = [1]').code).toBe('SCRIPT_PARSE')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('a near-miss prefix (comment header + whitespace, then no `export`) fails FAST as SCRIPT_PARSE', () => {
|
||||||
|
// Regression: the previous all-alternation prefix regex backtracked
|
||||||
|
// exponentially on exactly this shape (~×2 per extra whitespace char once
|
||||||
|
// the match fails), spinning the host synchronously inside start(). The
|
||||||
|
// linear trivia scan must reject it in effectively zero time.
|
||||||
|
const nearMiss = `// deep-audit workflow: reviews every route handler\n${' \n'.repeat(40)}/* second header block */\n${' '.repeat(200)}\nconst meta = { name: 'x', description: 'y' }\n`
|
||||||
|
const started = Date.now()
|
||||||
|
expect(bad(nearMiss).code).toBe('SCRIPT_PARSE')
|
||||||
|
expect(Date.now() - started).toBeLessThan(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('an unterminated block comment BEFORE the meta statement is SCRIPT_PARSE', () => {
|
||||||
|
const error = bad('/* never closed\nexport const meta = { name: "x", description: "y" }')
|
||||||
|
expect(error.code).toBe('SCRIPT_PARSE')
|
||||||
|
expect(error.message).toContain('unterminated comment')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a line comment running to EOF leaves no meta statement (SCRIPT_PARSE)', () => {
|
||||||
|
expect(bad('// only a comment, no newline').code).toBe('SCRIPT_PARSE')
|
||||||
|
})
|
||||||
|
|
||||||
it('rejects template interpolation in the meta block as impure (SCRIPT_PARSE)', () => {
|
it('rejects template interpolation in the meta block as impure (SCRIPT_PARSE)', () => {
|
||||||
const error = bad('export const meta = { name: `w-${1}`, description: "d" }\nreturn 1')
|
const error = bad('export const meta = { name: `w-${1}`, description: "d" }\nreturn 1')
|
||||||
expect(error.code).toBe('SCRIPT_PARSE')
|
expect(error.code).toBe('SCRIPT_PARSE')
|
||||||
|
|||||||
Reference in New Issue
Block a user