fix(hooks): remove speculative regex runtime

This commit is contained in:
Tianyi Cui
2026-07-29 23:14:31 +08:00
parent cfe77b3a35
commit 99daef69ef
31 changed files with 174 additions and 633 deletions

View File

@@ -1,121 +0,0 @@
import { createRequire } from 'node:module'
import { describe, expect, it, vi } from 'vitest'
import type { RRegex as RustRegex } from 'rregex'
const POOL_KEY = Symbol.for('@deepseek-ai/dsh-hook-protocol/rregex-pool/v1')
interface PoolEntry {
regex?: RustRegex
}
type RRegexModule = {
RRegex: new(pattern: string) => RustRegex
__wbindgen_memory(): WebAssembly.Memory
} & Record<symbol, unknown>
function restorePool(rregex: RRegexModule, original: unknown): void {
Reflect.deleteProperty(rregex, POOL_KEY)
if (original !== undefined) rregex[POOL_KEY] = original
}
describe('Codex regex intern lifecycle', () => {
it('keeps 100,000 same-pattern reloads bounded and reuses across module reload', async () => {
const require = createRequire(import.meta.url)
const rregex = require('rregex') as RRegexModule
const OriginalRRegex = rregex.RRegex
const originalPool = rregex[POOL_KEY]
const construct = vi.fn<(pattern: string) => void>()
const free = vi.fn<() => void>()
class CountingRRegex extends OriginalRRegex {
constructor(pattern: string) {
super(pattern)
construct(pattern)
}
override free(): void {
free()
super.free()
}
}
Reflect.deleteProperty(rregex, POOL_KEY)
rregex.RRegex = CountingRRegex
vi.resetModules()
const before = rregex.__wbindgen_memory().buffer.byteLength
try {
const first = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts')
for (let i = 0; i < 100_000; i++) {
first.compileMatchers(['(?i)^bash$'], 'codex').dispose()
}
expect(construct).toHaveBeenCalledExactlyOnceWith('(?i)^bash$')
expect(free).not.toHaveBeenCalled()
expect(rregex.__wbindgen_memory().buffer.byteLength - before).toBeLessThanOrEqual(4 * 1024 * 1024)
vi.resetModules()
const reloaded = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts')
expect(reloaded.matcherDiagnostic('(?i)^bash$', 'codex')).toBeUndefined()
expect(reloaded.matchesMatcher('(?i)^bash$', 'BASH', 'codex')).toBe(true)
expect(construct).toHaveBeenCalledTimes(1)
expect(free).not.toHaveBeenCalled()
} finally {
const temporaryPool = rregex[POOL_KEY]
if (temporaryPool instanceof Map) {
for (const entry of temporaryPool.values() as Iterable<PoolEntry>) entry.regex?.free()
}
rregex.RRegex = OriginalRRegex
restorePool(rregex, originalPool)
vi.resetModules()
}
})
it('memoizes failures and rejects a new pattern before construction at the hard cap', async () => {
const require = createRequire(import.meta.url)
const rregex = require('rregex') as RRegexModule
const OriginalRRegex = rregex.RRegex
const originalPool = rregex[POOL_KEY]
const construct = vi.fn<(pattern: string) => void>()
class FakeRRegex {
constructor(pattern: string) {
construct(pattern)
if (pattern === 'invalid(') throw new SyntaxError('invalid test pattern')
}
isMatch(): boolean {
return true
}
}
Reflect.deleteProperty(rregex, POOL_KEY)
rregex.RRegex = FakeRRegex as unknown as typeof rregex.RRegex
vi.resetModules()
try {
const matcher = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts')
expect(matcher.matcherDiagnostic('invalid(', 'codex')).toBe('invalid codex regex matcher "invalid("')
expect(matcher.matcherDiagnostic('invalid(', 'codex')).toBe('invalid codex regex matcher "invalid("')
expect(construct).toHaveBeenCalledTimes(1)
for (let i = 0; i < matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS - 1; i++) {
expect(matcher.matcherDiagnostic(`^value-${i}$`, 'codex')).toBeUndefined()
}
expect(construct).toHaveBeenCalledTimes(matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS)
expect(matcher.matcherDiagnostic('^overflow$', 'codex')).toBe(
`codex regex matcher capacity exceeded (${matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS} distinct patterns per process) for "^overflow$"`,
)
expect(matcher.matchesMatcher('^overflow$', 'overflow', 'codex')).toBe(false)
expect(construct).toHaveBeenCalledTimes(matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS)
vi.resetModules()
const reloaded = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts')
expect(reloaded.matchesMatcher('^value-0$', 'anything', 'codex')).toBe(true)
expect(reloaded.matcherDiagnostic('invalid(', 'codex')).toBe('invalid codex regex matcher "invalid("')
expect(construct).toHaveBeenCalledTimes(matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS)
} finally {
rregex.RRegex = OriginalRRegex
restorePool(rregex, originalPool)
vi.resetModules()
}
})
})

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { compileMatchers, matcherDiagnostic, matchesMatcher } from '@deepseek-ai/dsh-hook-protocol'
import { matcherDiagnostic, matchesMatcher } from '@deepseek-ai/dsh-hook-protocol'
describe('matchesMatcher — match-all sentinels (both dialects)', () => {
for (const mode of ['claude', 'codex'] as const) {
@@ -34,10 +34,11 @@ describe('matchesMatcher — claude dialect (literal-or-regex)', () => {
})
})
describe('matchesMatcher — codex dialect (literal-or-Rust-regex)', () => {
it('a word pattern uses Codex exact-match semantics', () => {
describe('matchesMatcher — codex dialect (always regex)', () => {
it('a word pattern is an unanchored regex (substring matches, unlike claude literal)', () => {
expect(matchesMatcher('Bash', 'Bash', 'codex')).toBe(true)
expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(false)
// codex has NO literal fast path: "Bash" is /Bash/, so it DOES match a substring
expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(true)
})
it('regex alternation and anchors work', () => {
@@ -45,14 +46,6 @@ describe('matchesMatcher — codex dialect (literal-or-Rust-regex)', () => {
expect(matchesMatcher('^Bash$', 'Bash', 'codex')).toBe(true)
expect(matchesMatcher('^Bash$', 'BashOutput', 'codex')).toBe(false)
})
it('uses Rust regex syntax and matching semantics', () => {
expect(matchesMatcher('(?i)bash', 'xxBASHyy', 'codex')).toBe(true)
expect(matchesMatcher('(?x)^ b a s h $ # policy matcher', 'bash', 'codex')).toBe(true)
expect(matchesMatcher('^\\p{Greek}+$', 'αβ', 'codex')).toBe(true)
// JavaScript accepts look-around, but Rust regex deliberately does not.
expect(matchesMatcher('(?=Bash)', 'Bash', 'codex')).toBe(false)
})
})
describe('matchesMatcher — invalid regex is a non-match (never throws)', () => {
@@ -72,42 +65,10 @@ describe('matcherDiagnostic — parse-time diagnostics', () => {
expect(matcherDiagnostic('Edit|Write', 'claude')).toBeUndefined()
expect(matcherDiagnostic('^Bash$', 'claude')).toBeUndefined()
expect(matcherDiagnostic('Edit|Write', 'codex')).toBeUndefined()
expect(matcherDiagnostic('(?i)bash', 'codex')).toBeUndefined()
expect(matcherDiagnostic('(?x)^ b a s h $ # policy matcher', 'codex')).toBeUndefined()
})
it('returns a stable diagnostic for invalid regexes in either dialect', () => {
expect(matcherDiagnostic('(', 'claude')).toBe('invalid claude regex matcher "("')
expect(matcherDiagnostic('[', 'codex')).toBe('invalid codex regex matcher "["')
expect(matcherDiagnostic('(?=Bash)', 'codex')).toBe('invalid codex regex matcher "(?=Bash)"')
})
})
describe('compileMatchers — config-lifetime reuse', () => {
it('compiles a finite set, contains unknown patterns, and stops after disposal', () => {
const matchers = compileMatchers([undefined, 'Edit|Write', '(?i)^bash$', '['], 'codex')
expect(matchers.matches(undefined, 'anything')).toBe(true)
expect(matchers.matches('Edit|Write', 'Write')).toBe(true)
expect(matchers.matches('Edit|Write', 'WriteFile')).toBe(false)
expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(true)
expect(matchers.matches('[', 'anything')).toBe(false)
expect(matchers.matches('not-compiled', 'not-compiled')).toBe(false)
expect(matchers.diagnostic('(?i)^bash$')).toBeUndefined()
expect(matchers.diagnostic('[')).toBe('invalid codex regex matcher "["')
expect(matchers.diagnostic('not-compiled')).toBeUndefined()
matchers.dispose()
expect(matchers.matches(undefined, 'anything')).toBe(false)
expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(false)
expect(matchers.diagnostic('[')).toBeUndefined()
expect(() => { matchers.dispose() }).not.toThrow()
})
it('reuses JavaScript regexes too', () => {
const matchers = compileMatchers(['^Bash$', '^Bash$'], 'claude')
expect(matchers.matches('^Bash$', 'Bash')).toBe(true)
expect(matchers.matches('^Bash$', 'BashOutput')).toBe(false)
matchers.dispose()
})
})