Skip to content

RegExp modifier groups (?i:…) / (?-i:…) can get the wrong case sensitivity (fixed in V8 15.4, please cherry-pick 4cc98031) #66277

Description

@TomStrepsil

Version

v24.20.0 and v26.10.0. Also reproduced on v24.7.0, v24.9.0, v24.14.1, and on v22.14.0 with --js-regexp-modifiers.

Platform

Darwin 24.6.0 arm64

Subsystem

deps/v8 (irregexp)

What steps will reproduce the bug?

Save as modifier-case.js and run node modifier-case.js. No dependencies; it also runs under d8.

const log = typeof print === "function" ? print : console.log;
const first = (source, flags, input) => new RegExp(source, flags).exec(input)?.[0] ?? null;
const show = (label, got, want) => log(`${got === want ? "ok  " : "FAIL"} ${label}: got ${JSON.stringify(got)}, expected ${JSON.stringify(want)}`);

// 1. Fresh process, short pattern. The failing atom count depends on the V8 version.
for (const n of [24, 49]) {
  const x = "x?".repeat(n);
  show(`${n} x? then (?i:a?) on "A"`, first(x + "(?i:a?)", "", "A"), "A");
  show(`${n} x? then (?-i:a?) with /i on "A"`, first(x + "(?-i:a?)", "i", "A"), "");
}

// 2. Any pattern whose source is longer than 20,480 characters.
show(`x?(?i:a?) padded to 20,481 chars on "A"`, first("x?(?i:a?)" + "(?:)".repeat(5118), "", "A"), "A");

// 3. A long-running process: once enough regex code has been generated,
//    a modifier pattern compiled for the first time is wrong from then on.
show(`/x?(?i:a?)/ before the workload`, first("x?(?i:a?)(?:q{0,0})", "", "A"), "A");
const filler = Array.from({ length: 32 }, (_, i) => "abcdefgh"[i % 8] + "{0,3}").join("");
for (let k = 0; k < 2000; k++) {
  const re = new RegExp(filler + "z{0," + k + "}");
  re.exec("ab");
  re.exec("ab");
}
show(`/x?(?i:a?)/ after 2,000 unrelated regexes`, first("x?(?i:a?)(?:q{0,1})", "", "A"), "A");

How often does it reproduce? Is there a required condition?

Every time. It needs a letter inside a (?i:…) or (?-i:…) group, plus one of three conditions:

  • a certain number of preceding optional atoms: 49–51, 102–104 … on V8 13.6, and 24–26, 52–54 … on V8 14.6;
  • a source longer than 20,480 characters;
  • a process that has already generated enough regex code.

The third makes it a practical problem. In a long-running server or test run, any modifier pattern first compiled after that point matches wrongly for the rest of the process, while the same pattern is correct in a fresh process.

What is the expected behavior? Why is that the expected behavior?

Every line prints ok, as it does on d8 15.6.62. Per the RegExp modifiers semantics (ES2025), (?i:a?) matches "A", and (?-i:a?) inside /…/i does not.

What do you see instead?

v24.20.0 (V8 13.6.233.17-node.53):

ok   24 x? then (?i:a?) on "A": got "A", expected "A"
ok   24 x? then (?-i:a?) with /i on "A": got "", expected ""
FAIL 49 x? then (?i:a?) on "A": got "", expected "A"
FAIL 49 x? then (?-i:a?) with /i on "A": got "A", expected ""
FAIL x?(?i:a?) padded to 20,481 chars on "A": got "", expected "A"
ok   /x?(?i:a?)/ before the workload: got "A", expected "A"
FAIL /x?(?i:a?)/ after 2,000 unrelated regexes: got "", expected "A"

v26.10.0 (V8 14.6.202.34-node.34):

FAIL 24 x? then (?i:a?) on "A": got "", expected "A"
FAIL 24 x? then (?-i:a?) with /i on "A": got "A", expected ""
ok   49 x? then (?i:a?) on "A": got "A", expected "A"
ok   49 x? then (?-i:a?) with /i on "A": got "", expected ""
FAIL x?(?i:a?) padded to 20,481 chars on "A": got "", expected "A"
ok   /x?(?i:a?)/ before the workload: got "A", expected "A"
FAIL /x?(?i:a?)/ after 2,000 unrelated regexes: got "", expected "A"

d8 15.6.62 (via jsvu): all seven lines ok.

Additional information

Upstream fix. V8 4cc98031 (CL 8260876, "[regexp] Store flags on RegExpNode and remove ActionNode::MODIFY_FLAGS", 2026-08-28) landed during V8 15.4 development. Its message describes this mechanism: the old design stored modifier flags as mutable state on the compiler, and "out-of-order emission via worklists" let that state leak across branches. The three conditions above all force that kind of emission.

I haven't bisected to that exact commit. What I have verified is that V8 15.2 (Chromium 152) fails and d8 15.6.62 passes. The CL is a redesign across src/regexp/ (10 files, including tests), so it may not apply cleanly to V8 13.6 or 14.6. It builds on the earlier, narrower d315af3d (CL 8257209, "[regexp] Reset compiler flags between word boundary branches"), which a backport may also need.

Related. Chromium issue 560182133 reports the long-running case with /(?i:(a)|(b))\2/d on "BB" against Node v26.5.1. It was closed as obsolete on the assumption that V8 main was already fixed. That pattern also returns null on v24.20.0 and v26.10.0 after the 2,000-regex workload above, and for a source over 20,480 characters.

Workarounds. Both of these are correct on the affected versions:

  • put i on the whole pattern rather than using a modifier group;
  • spell the letter as a character class, such as [aA] or (?i:[a]).

How it was found. Differential fuzzing of regex-partial-match, which uses the running V8 as its oracle. The results drifted part way through long runs.

Request. Please cherry-pick 4cc98031 (with d315af3d if needed) into v24.x and v26.x. v22.x is affected too, but only with --js-regexp-modifiers.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions