本文へ移動
myowchick

暴走した正規表現はterminateでしか止まらない

入力欄に打った正規表現をその場で走らせるツールを置いている。正規表現テスト。

new RegExpしてexecを回すだけ、のつもりだった。

ところが入力欄に来るのは常に書きかけの正規表現で、途中の状態がいくらでも重い。(a+)+$のような形は入力次第で戻り読みが爆発する。手元のNode24で^(a+)+$をa30個と!に当てたら、10秒返ってこなかった。

2文字増えるごとに4倍

^(a+)+$を'a'.repeat(n) + '!'に当てたときの時間。

nの値時間
2010.5ms
2283.1ms
24171.7ms
26564.5ms
282335.9ms
3010117.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指定は文字列リテラルでないと黙って壊れること。どれも「動いているように見える」側へ倒れるので、気づくのはたいてい後になる。

← 一覧へ戻る