暴走した正規表現はterminateでしか止まらない
入力欄に打った正規表現をその場で走らせるツールを置いている。正規表現テスト。
new RegExpしてexecを回すだけ、のつもりだった。
ところが入力欄に来るのは常に書きかけの正規表現で、途中の状態がいくらでも重い。(a+)+$のような形は入力次第で戻り読みが爆発する。手元のNode24で^(a+)+$をa30個と!に当てたら、10秒返ってこなかった。
2文字増えるごとに4倍
^(a+)+$を'a'.repeat(n) + '!'に当てたときの時間。
| nの値 | 時間 |
|---|---|
| 20 | 10.5ms |
| 22 | 83.1ms |
| 24 | 171.7ms |
| 26 | 564.5ms |
| 28 | 2335.9ms |
| 30 | 10117.7ms |
2文字増えるごとにおよそ4倍。この伸び方をそのまま延ばすと32文字で40秒、36文字で10分になる。
(a+)+なんて誰も書かないと思うかもしれないけど、(\s*\w+)*$や(.*,)*は普通に書く形で、骨格は同じ。書きかけの(\w+)+が一瞬できるだけでも踏む。
途中で止める手段が無い
正規表現の実行はJavaScriptから中断できない。タイマーも動かない。
const re = /^(a+)+$/
const t0 = performance.now()
setTimeout(() => console.log('タイマー', performance.now() - t0), 100)
re.test('a'.repeat(30) + '!')
console.log('test', performance.now() - t0)
実測。
test が返ったのは 9240ms
100ms のタイマーが実際に動いたのは 9244ms
100msで仕掛けたタイマーが9.2秒後に動いている。エンジンに制御が渡ったきり返ってこないので、フラグを立てて抜けるような自前の中断も書けない。AbortSignalを渡す口も無い。
止める手段はスレッドごと殺すことしかない。つまりWorker#terminate()。
Workerは使い捨てにする
だから正規表現の実行はWorkerに追い出して、時間が掛かったらWorkerごと捨てて次から作り直す。
const stopWorker = useCallback(() => {
workerRef.current?.terminate()
workerRef.current = null
runningRef.current = false
if (timerRef.current !== null) clearTimeout(timerRef.current)
timerRef.current = null
}, [])
呼ぶ側はpostMessageと同時に打ち切りのタイマーを仕掛けておいて、先に鳴ったらstopWorkerする。150msデバウンスして300msで打ち切る値にした。
terminate()が効くことは測れる。NodeのWorkerで同じ形を組んで、暴走する正規表現を投げてから300msでterminate()する。
300ms 時点の呼ぶ側の tick 回数: 29
terminate 完了 305ms / tick 回数: 29
呼ぶ側は10msおきのタイマーを29回回せている。40秒掛かるはずの実行が305msで畳めた。
Workerを再利用しないのは、殺した以上そこに残っているものが無いから。次の入力で新しくnew Workerする。作り直しのコストは、固まったタブを見せるコストよりずっと安い。
ちなみに件数の上限(このツールは1000件で打ち切る)は時間の上限にならない。上限が効くのはマッチが積み上がるときだけで、(a+)+$は1回目のexecが返ってこない。同じ入力で測ると20秒だった。件数を絞る話と時間を絞る話は別々にやる必要がある。
番号と名前の対応表は結果から作れない
ここからは正規表現API側の穴。
マッチの表に「1番のグループはこれ」「yearという名前のグループはこれ」を並べたい。ところがexecの戻り値は、番号と名前の対応を持っていない。
array: ["2026-08","2026","08"]
groups: {"year":"2026"}
indices: [[0,7],[0,4],[5,7]]
indices.groups: {"year":[0,4]}
配列は番号順、groupsは名前引き。名前が何番かはどこにも書かれていない。
dフラグを付ければindicesが出るので、範囲を突き合わせれば対応が取れそうに見える。取れない。
/((?<dup>a))/d を 'a' に当てる
indices: [[0,1],[0,1],[0,1]]
indices.groups: {"dup":[0,1]}
入れ子のグループは範囲が一致するので、どちらがdupなのか決まらない。捕獲されなかったグループはnullになって範囲すら無い。
なのでパターンの文字列を自分で走査して、番号順の名前表を作った。
if (char === '\\') { index += 2; continue } // \( を括弧と読まない
if (inClass) { if (char === ']') inClass = false; index += 1; continue }
if (char === '[') { inClass = true; index += 1; continue }
if (char !== '(') { index += 1; continue }
if (pattern[index + 1] !== '?') { names.push(null); index += 1; continue }
// (?<name> だけが捕獲する。(?<= と (?<! は後読みで番号を持たない
const named = /^\(\?<(?![=!])([^>]*)>/.exec(pattern.slice(index))
読み分けが要るのは3つ。エスケープした\(、文字クラスの中の[(]、それと後読みの(?<=と(?<!。最後のやつは(?<で始まるので、名前付きグループと形が被る。
実物に当てるとこうなる。
| パターン | 名前表 |
|---|---|
(?<year>\d{4})-(\d{2}) | ["year", null] |
(?<=\$)(\d+) | [null] |
[(](a)[)] | [null] |
(a)(?<b>c)(?:d)(?=e)(?<!f) | [null, "b"] |
groupsオブジェクトから名前の一覧だけは取れる(捕獲されなくてもキーは生える。プロトタイプはnull)けど、それが何番かは結局分からないので、自前走査は避けられなかった。
0幅マッチでlastIndexを1足すと止まらなくなる
gフラグで全部のマッチを拾うとき、0幅のマッチはlastIndexが動かないので手で送る必要がある。素朴に書くとこう。
if (found[0] === '') regex.lastIndex += 1
uフラグが付くと壊れる。a*をguで'🐤🐤'に当てて、この送り方をすると無限に回る。5000回で打ち切ってもまだ回っていた。
しかも全部インデックス0のマッチ。理由を測るとこう。
lastIndex=1 で exec → index = 0 / 実行後の lastIndex = 0
lastIndex=3 で exec → index = 2 / 実行後の lastIndex = 2
u 無し lastIndex=1 → index = 1
u付きのexecは、サロゲートペアの途中を指すlastIndexを文字の先頭まで巻き戻す。0で0幅マッチ、lastIndexが0に戻る、1に送る、また巻き戻って0。永久に進まない。
仕様側のAdvanceStringIndexはペアをまたぐようになっていて、matchAllやreplaceは最初からこちらを使っている。
export function nextIndex(text: string, index: number, unicode: boolean): number {
if (!unicode || index + 1 >= text.length) return index + 1
const code = text.charCodeAt(index)
const paired = code >= 0xd800 && code <= 0xdbff
const low = text.charCodeAt(index + 1)
return paired && low >= 0xdc00 && low <= 0xdfff ? index + 2 : index + 1
}
これを噛ませると0 / 2 / 4になる。[...'🐤🐤'.matchAll(/a*/gu)]のインデックスと一致するし、'🐤🐤'.replace(/a*/gu, '-')が-🐤-🐤-になるのとも合う。u無しなら0 / 1 / 2 / 3 / 4で、これはこれで正しい(コードユニット単位で数えるので)。
Viteは静的な文字列しか見ていない
Worker本体の指定はこれ。
new Worker(new URL('../../lib/tools/regex-worker.ts', import.meta.url), { type: 'module' })
'@/lib/tools/regex-worker.ts'と書きたくなるところ。Vite8.2.1で試したら、エイリアスは普通に解決された。ビルド後のチャンクはnew URL('/_astro/regex-worker-xIhl8RXH.js', ...)になっていた。昔は通らなかった気がするので、いつか直ったんだと思う。
通らないのは文字列リテラルでなくなったとき。定数に切り出しただけで壊れる。
const WORKER_PATH = '../../lib/tools/regex-worker.ts'
new Worker(new URL(WORKER_PATH, import.meta.url), { type: 'module' })
これでもビルドは成功する。警告も出ない。ただしworkerのチャンクが生成されず、バンドルの中に../../lib/tools/regex-worker.tsという文字列がそのまま残る。存在しない.tsを実行時に取りに行くので、ブラウザで初めて404になる。
静的解析で拾えないと黙って落ちる形なので、リテラルのまま置いておくのが安全。
あとSSRを通るページなので、new WorkerはuseEffectの中に置く。モジュールのトップレベルに書くと、ビルド時のNodeで評価されてWorker is not definedでビルドが落ちる(試した)。
selfはWindowのまま
Workerのファイル冒頭に/// <reference lib="webworker" />を足しても、selfの型は変わらない。DOMのlibが同居していると、そちらが勝つ。
型を名指しで確かめるとこう。
error ts(2322): Type 'Window & typeof globalThis' is not assignable to type
'DedicatedWorkerGlobalScope'.
厄介なのは、この状態でもastro checkが通ってしまうこと。referenceで読み込んだworker側のグローバルはglobalThisのほうにも生えるので、self.importScriptsもself.postMessage(buffer, [buffer])も素通りする。型は間違っているのに何も言われない。
逆向きに触ると露見する。declare無しだとこれが通る。
void self.alert
void self.document
Worker実行時、self.documentはundefinedでself.alertは関数ですらない。型検査は何も言わずに通す。
declareを足すと落ちる。
error ts(2339): Property 'document' does not exist on type 'DedicatedWorkerGlobalScope'.
error ts(2339): Property 'alert' does not exist on type 'DedicatedWorkerGlobalScope'.
なので参照だけでなく、グローバルを受け直すところまでが1セット。
/// <reference lib="webworker" />
declare const self: DedicatedWorkerGlobalScope
実行時の挙動は何も変わらない。型の話だけ。
結果には「その実行に使った入力」を添える
最後にデバウンスの罠。
Workerから返ってきたマッチをハイライトするとき、当てる先を最新の入力にすると位置がずれる。デバウンス中は「まだ走らせていない入力」が画面にあるから。
\d{4}を2026-08-26に当てて、返ってくる前に先頭の2を消したとき。
走らせた入力 : 2026-08-26 → [2026]-08-26
最新入力に古い結果 : 026-08-26 → [026-]08-26
走らせ直した結果 : 026-08-26 → (マッチ無し)
026-が4桁の数字として光る。マッチしていないものをマッチしたことにして見せている。
なので結果は入力と対で持つ。
const [run, setRun] = useState<{ text: string; result: RegexResult } | null>(null)
onmessageの閉包が捕まえているtextが、その実行に渡したもの。描画もrun.textに対して行う。1タイミング古い表示になるけど、嘘は出ない。
ユーザーが打った正規表現を走らせるというのは、ユーザーが書いたコードを走らせるのとだいたい同じことだった。中断できない以上、Workerに追い出してterminate()で捨てるしかなく、捨てた以上は作り直すしかない。件数の上限は時間の上限にならないので、そこは別々に要る。
残りは正規表現API側の穴で、グループの番号と名前の対応が結果から取れないこと、0幅マッチの送りがuフラグではペアをまたぐこと、Viteのworker指定は文字列リテラルでないと黙って壊れること。どれも「動いているように見える」側へ倒れるので、気づくのはたいてい後になる。