Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions JSTests/BUN-TEST-DIFFERENCES.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Where this fork's tests differ from upstream's, and why

`JSTests/`, `LayoutTests/js` and the PerformanceTests collections are upstream's tests. This fork changes 120 of them and
`JSTests/`, `LayoutTests/js` and the PerformanceTests collections are upstream's tests. This fork changes 123 of them and
three runner scripts. Every change is listed here by the reason for it, so that after an upstream sync a failing test can
be sorted into one of three piles:

1. **The fork behaves differently on purpose.** Update the expectation. Sections A1–A9 list every such behavior, how to
1. **The fork behaves differently on purpose.** Update the expectation. Sections A1–A10 list every such behavior, how to
Comment thread
coderabbitai[bot] marked this conversation as resolved.
recognize it in a failure, and the fork code that causes it. If a failure matches one of these and the fork code is
still there, the test is the thing to change.
2. **The test cannot pass in a mode or build we run.** Skip it in that mode only, with the reason. Section B.
Expand Down Expand Up @@ -113,6 +113,22 @@ Rules for an edit to an upstream test:
- Fork code: `DecoderStringTable::jsStringFor` (`runtime/CachedTypes.h:132`): loading a cache interns nothing.
- File: `stress/jsstring-definitely-atom-bit.js`: `$skipModes << "bytecode-cache"`. The other modes run it unchanged.

### A10. A RegExp search that is abandoned throws a `RangeError`, it is not a failed match

- Upstream: a search that uses up its step limit or its backtracking memory returns what a failed match returns
(`false`, `null`, `-1`).
- Fork code: `Yarr::interpret()` returns the `JSRegExpResult` (`yarr/YarrInterpreter.cpp`) and JIT code returns
`ErrorHitLimit` (`generateJITFailReturn`, `yarr/YarrJIT.cpp`). `RegExp::matchInlineOnce` (`runtime/RegExpInlines.h`)
throws through `RegExp::throwMatchAbandoned` (`runtime/RegExp.cpp`), and `RegExp::matchConcurrently` does not let the
DFG fold such a search. The step limit is `Options::regExpMatchLimit`.
- Recognize: `RangeError: Regular expression backtracking limit exceeded` (the step limit) or
`RangeError: Maximum call stack size exceeded.` (the backtracking memory) from a pattern that backtracks
exponentially, where upstream expects `false` or `null`.
- Files (3): `stress/regexp-fixedcount-zero-length-content-backtrack.js` and `LayoutTests/fast/regex/` `slow` (upstream
expects `false`; the pattern does match, at index 14), `LayoutTests/fast/regex/` `pcre-test-1` (`regex632`: upstream
expects `null`, which is the true answer). For the two layout tests: the script under `script-tests/` and the
`-expected.txt`.

---

## B. The test cannot pass in a mode or build we run: narrowed, not changed
Expand Down Expand Up @@ -154,7 +170,7 @@ Rules for an edit to an upstream test:

## E. After a sync: a failing test, step by step

1. Does the difference match A1–A9 exactly (same text, same shape)? Check that the fork code named there is still in the
1. Does the difference match A1–A10 exactly (same text, same shape)? Check that the fork code named there is still in the
tree, then update the expectation with a `// Bun:` comment and add the file to its list here.
2. Does it fail only in one mode or only with ASan, for a reason in the test and not the engine (it counts JIT compiles,
depends on allocation never failing, needs more than 300 s)? Narrow it as in B.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//@ runDefault("--maxRegExpStackSize=65536", "--useRegExpJIT=0")

// The interpreter takes one backtracking context for the pattern's body before it starts. 5000
// optional terms need two frame slots each, 80000 bytes, which Options::maxRegExpStackSize does not
// hold here. The search is abandoned before it starts, and throws like one abandoned later on.

function shouldBe(actual, expected, what) {
if (actual !== expected)
throw new Error(what + ": expected " + JSON.stringify(expected) + " but got " + JSON.stringify(actual));
}

const tooWide = new RegExp("^" + "a?".repeat(5000) + "$");
for (let i = 0; i < 3; i++) {
let error = null;
try {
tooWide.test("a");
} catch (e) {
error = e;
}
shouldBe(error instanceof RangeError, true, "a body that does not fit, use " + i);
shouldBe(error.message, "Maximum call stack size exceeded.", "a body that does not fit, use " + i);
}

// The pool is usable after the throw.
shouldBe(new RegExp("^" + "a?".repeat(2000) + "$").test("a"), true, "a body that fits");
shouldBe(/^(?:a|b)+$/.test("abab"), true, "a small pattern");
46 changes: 46 additions & 0 deletions JSTests/stress/regexp-abandoned-match-is-not-folded.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//@ runDefault("--regExpMatchLimit=100", "--useConcurrentJIT=0", "--useFTLJIT=0")
//@ runDefault("--regExpMatchLimit=100", "--useConcurrentJIT=0", "--useFTLJIT=0", "--useRegExpJIT=0")

// DFG strength reduction runs a constant RegExp on a constant string while it compiles, and puts
// the result in place of the search. A search that is abandoned has no result: the compiled code
// must run it, and throw, like the lower tiers. Folded, each function below would return.
//
// The result of each search is not used, or the call is a tail call. A call whose result is used
// and that has never returned has no value profile, and the DFG leaves compiled code before it:
// such a call never reaches a folded search.

const limitMessage = "Regular expression backtracking limit exceeded";
const regExp = /(?:a|aa)+b|c/;

function test() { regExp.test("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaac"); return "returned"; }
function testLiteral() { /(?:a|aa)+b|c/.test("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaac"); return "returned"; }
function exec() { regExp.exec("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaac"); return "returned"; }
function search() { "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaac".search(regExp); return "returned"; }
function match() { "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaac".match(regExp); return "returned"; }
function replace() { "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaac".replace(regExp, "X"); return "returned"; }
function testTailCall() { "use strict"; return regExp.test("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaac"); }
function execTailCall() { "use strict"; return regExp.exec("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaac"); }
// A validator that throws for what it matches, called as a statement after it is warm.
function deny(s) { if (regExp.test(s)) throw new TypeError("denied"); }
function validator() { deny("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaac"); return "returned"; }
// Enough calls for the DFG to compile each function, with calls after that.
const loopCount = Math.min(testLoopCount, 3000);
for (let i = 0; i < loopCount; i++)
deny("xyz");

for (const run of [test, testLiteral, exec, search, match, replace, testTailCall, execTailCall, validator]) {
noInline(run);
for (let i = 0; i < loopCount; i++) {
let result;
let error = null;
try {
result = run();
} catch (e) {
error = e;
}
if (!(error instanceof RangeError) || error.message !== limitMessage)
throw new Error(run.name + " at iteration " + i + ": expected the RangeError, got " + (error || JSON.stringify(result)));
}
if (!numberOfDFGCompiles(run))
throw new Error(run.name + " was not compiled");
}
24 changes: 24 additions & 0 deletions JSTests/stress/regexp-abandoned-match-limit-of-zero.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//@ runDefault("--regExpMatchLimit=0")
//@ runDefault("--regExpMatchLimit=0", "--useRegExpJIT=0")

// Both RegExp engines count the steps of a search down to zero. A limit of 0 is taken as 1, the
// lowest limit there is, so the first step abandons the search. Counted down from 0, the steps
// would wrap around and the search would have 2^32 of them.

function shouldBe(actual, expected, what) {
if (actual !== expected)
throw new Error(what + ": expected " + JSON.stringify(expected) + " but got " + JSON.stringify(actual));
}

// The first iteration of the group is a step in JIT code and in the interpreter.
const regExp = /(?:a|b)+c/;
for (let i = 0; i < 3; i++) {
let error = null;
try {
regExp.test("abc");
} catch (e) {
error = e;
}
shouldBe(error instanceof RangeError, true, "use " + i);
shouldBe(error.message, "Regular expression backtracking limit exceeded", "use " + i);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//@ runDefault("--maxRegExpStackSize=1048576", "--useRegExpJIT=0")
//@ runDefault("--maxRegExpStackSize=1048576")

// Each iteration of a quantified group keeps a backtracking context until the search ends. A search
// that needs more of them than Options::maxRegExpStackSize holds is abandoned, and throws the stack
// overflow RangeError instead of reporting no match. With the RegExp JIT the contexts go on the
// machine stack first: when they do not fit there the search starts again in the interpreter, so
// both runs end in the interpreter's pool.

const overflowMessage = "Maximum call stack size exceeded.";

function shouldBe(actual, expected, what) {
if (actual !== expected)
throw new Error(what + ": expected " + JSON.stringify(expected) + " but got " + JSON.stringify(actual));
}

function shouldAbandon(run, what) {
let result;
try {
result = run();
} catch (error) {
if (!(error instanceof RangeError) || error.message !== overflowMessage)
throw new Error(what + ": expected the RangeError, got " + error);
return;
}
throw new Error(what + ": expected the RangeError, got the result " + JSON.stringify(result && result.length));
}

const pattern = "^(?:a|b)+$";
const big = "a".repeat(2000000);
const small = "a".repeat(5000);

shouldAbandon(() => new RegExp(pattern).test(big), "test");
shouldAbandon(() => new RegExp(pattern).exec(big), "exec");
shouldAbandon(() => big.match(new RegExp(pattern)), "match");
shouldAbandon(() => [...big.matchAll(new RegExp(pattern, "g"))], "matchAll");
shouldAbandon(() => big.search(new RegExp(pattern)), "search");
shouldAbandon(() => big.replace(new RegExp(pattern), "X"), "replace");
shouldAbandon(() => big.split(new RegExp(pattern)), "split");

shouldBe(new RegExp(pattern).test(small), true, "small test");
shouldBe(new RegExp(pattern).exec(small)[0].length, 5000, "small exec");

// No group, no contexts: the length of the subject alone does not abandon a search.
shouldBe(/^[ab]+$/.test(big), true, "character class");

// A throw is not a failed match: lastIndex stays where it was, and the RegExp still works.
{
const regExp = /(?:a|b)+$/g;
regExp.lastIndex = 3;
shouldAbandon(() => regExp.test(big), "test /g");
shouldBe(regExp.lastIndex, 3, "lastIndex after the throw");
shouldBe(regExp.test(small), true, "reuse after the throw");
shouldBe(regExp.lastIndex, 5000, "lastIndex after the match");
}
81 changes: 81 additions & 0 deletions JSTests/stress/regexp-abandoned-match-throws-in-compiled-code.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//@ runDefault("--regExpMatchLimit=100", "--useConcurrentJIT=0", "--useFTLJIT=0")
//@ runDefault("--regExpMatchLimit=100", "--useConcurrentJIT=0", "--useFTLJIT=0", "--useRegExpJIT=0")
//@ runDefault("--regExpMatchLimit=100", "--useConcurrentJIT=0", "--testTheFTL=1") if $isFTLPlatform

// DFG and FTL code run a search through their own operations (operationRegExpTestString and the
// like). Each function here is compiled on a subject that matches, so that its RegExp node is
// live, and then gets a subject whose search is abandoned at Options::regExpMatchLimit: every call
// throws, from the compiled code. With --testTheFTL, numberOfDFGCompiles() counts FTL compiles.

const limitMessage = "Regular expression backtracking limit exceeded";
const regExp = /(?:a|aa)+b|c/;
const globalRegExp = /(?:a|aa)+b|c/g;
const stickyRegExp = /(?:a|aa)+b|a+c/y;

function testLiteral(s) { return /(?:a|aa)+b|c/.test(s); }
function test(s) { return regExp.test(s); }
function exec(s) { return regExp.exec(s)[0]; }
function execSticky(s) {
stickyRegExp.lastIndex = 1;
return stickyRegExp.exec(s)[0];
}
function match(s) { return s.match(regExp)[0]; }
function matchGlobal(s) { return s.match(globalRegExp).length; }
function matchAll(s) { return [...s.matchAll(globalRegExp)].length; }
function search(s) { return s.search(regExp); }
function replace(s) { return s.replace(regExp, "X"); }
function replaceGlobal(s) { return s.replace(globalRegExp, ""); }
function replaceFunction(s) { return s.replace(globalRegExp, () => "Y"); }
function split(s) { return s.split(regExp).length; }
function caught(s) {
try {
return regExp.test(s);
} catch (error) {
return error.message;
}
}

const small = "aaac";
const abandoned = "a".repeat(35) + "c";
const sites = [
[testLiteral, true], [test, true], [exec, "c"], [execSticky, "aac"], [match, "c"], [matchGlobal, 1], [matchAll, 1],
[search, 3], [replace, "aaaX"], [replaceGlobal, "aaa"], [replaceFunction, "aaaY"], [split, 2], [caught, true],
];

for (const [run, expected] of sites) {
noInline(run);

// Until the optimizing compiler has compiled it. numberOfDFGCompiles() is large when that compiler is off.
for (let i = 0; i < 10000000 && !numberOfDFGCompiles(run); i++) {
const result = run(small);
if (result !== expected)
throw new Error(run.name + " on the small subject: expected " + JSON.stringify(expected) + " but got " + JSON.stringify(result));
}
const compiles = numberOfDFGCompiles(run);
if (!compiles)
throw new Error(run.name + " was not compiled");

for (let i = 0; i < 200; i++) {
let result;
let error = null;
try {
result = run(abandoned);
} catch (e) {
error = e;
}
if (run === caught) {
if (result !== limitMessage)
throw new Error("caught at iteration " + i + ": expected the message, got " + (error || JSON.stringify(result)));
} else if (!(error instanceof RangeError) || error.message !== limitMessage)
throw new Error(run.name + " at iteration " + i + ": expected the RangeError, got " + (error || JSON.stringify(result)));
// A throw is not a failed match: lastIndex stays where the search started.
if (run === execSticky && stickyRegExp.lastIndex !== 1)
throw new Error("execSticky at iteration " + i + ": lastIndex is " + stickyRegExp.lastIndex);
}

// Code that left for a lower tier at each of the 200 calls is thrown away and compiled again.
if (numberOfDFGCompiles(run) !== compiles)
throw new Error(run.name + " was compiled again: " + compiles + " then " + numberOfDFGCompiles(run));
if (run(small) !== expected)
throw new Error(run.name + " does not answer after the throws");
}
111 changes: 111 additions & 0 deletions JSTests/stress/regexp-abandoned-match-throws.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//@ runDefault("--regExpMatchLimit=10000")
//@ runDefault("--regExpMatchLimit=10000", "--useRegExpJIT=0")

// A search that uses up Options::regExpMatchLimit is abandoned. It has no answer, so every RegExp
// entry point throws a RangeError for it instead of reporting no match. The limit is lowered so
// that the interpreter and JIT code both reach it at once; each engine counts its own steps.

const limitMessage = "Regular expression backtracking limit exceeded";

function shouldBe(actual, expected, what) {
if (actual !== expected)
throw new Error(what + ": expected " + JSON.stringify(expected) + " but got " + JSON.stringify(actual));
}

function shouldAbandon(run, what) {
let result;
try {
result = run();
} catch (error) {
if (!(error instanceof RangeError) || error.message !== limitMessage)
throw new Error(what + ": expected the RangeError, got " + error);
return;
}
throw new Error(what + ": expected the RangeError, got the result " + JSON.stringify(result));
}

// 35 a's then "c" matches, at the "c". Finding that takes about 1e8 steps: the first alternative
// splits the a's into runs of one and two in every possible way at each of 35 start positions.
const pattern = "(?:a|aa)+b|c";
const subject = "a".repeat(35) + "c";
const small = "aaac";

shouldAbandon(() => new RegExp(pattern).test(subject), "test");
shouldAbandon(() => new RegExp(pattern).exec(subject), "exec");
shouldAbandon(() => subject.match(new RegExp(pattern)), "match");
shouldAbandon(() => subject.match(new RegExp(pattern, "g")), "match /g");
shouldAbandon(() => [...subject.matchAll(new RegExp(pattern, "g"))], "matchAll");
shouldAbandon(() => subject.search(new RegExp(pattern)), "search");
shouldAbandon(() => subject.replace(new RegExp(pattern), "X"), "replace");
shouldAbandon(() => subject.replace(new RegExp(pattern, "g"), "X"), "replace /g");
shouldAbandon(() => subject.replaceAll(new RegExp(pattern, "g"), "X"), "replaceAll");
shouldAbandon(() => subject.split(new RegExp(pattern)), "split");

// The same pattern under the limit.
shouldBe(new RegExp(pattern).test(small), true, "small test");
shouldBe(new RegExp(pattern).exec(small)[0], "c", "small exec");
shouldBe(small.search(new RegExp(pattern)), 3, "small search");
shouldBe(small.replace(new RegExp(pattern), "X"), "aaaX", "small replace");
shouldBe(small.split(new RegExp(pattern)).length, 2, "small split");

// A throw is not a failed match: lastIndex stays where it was.
for (const flags of ["g", "y", "gy"]) {
const regExp = new RegExp(pattern, flags);
regExp.lastIndex = 3;
shouldAbandon(() => regExp.test(subject), "test /" + flags);
shouldBe(regExp.lastIndex, 3, "lastIndex after test /" + flags);
shouldAbandon(() => regExp.exec(subject), "exec /" + flags);
shouldBe(regExp.lastIndex, 3, "lastIndex after exec /" + flags);
}

// The RegExp is not left in an error state.
{
const regExp = new RegExp(pattern);
shouldAbandon(() => regExp.test(subject), "first use");
shouldBe(regExp.test(small), true, "reuse after the throw");
shouldAbandon(() => regExp.test(subject), "second use");
}

// The legacy static properties keep the last match that succeeded.
{
shouldBe(/b(c)d/.exec("abcde")[0], "bcd", "legacy statics setup");
shouldAbandon(() => new RegExp(pattern).exec(subject), "exec before reading the statics");
shouldBe(RegExp.lastMatch, "bcd", "RegExp.lastMatch");
shouldBe(RegExp.$1, "c", "RegExp.$1");
shouldBe(RegExp.leftContext, "a", "RegExp.leftContext");
shouldBe(RegExp.rightContext, "e", "RegExp.rightContext");
}

// The iterator matchAll returns throws from next(), and again when it is asked again.
{
const iterator = subject.matchAll(new RegExp(pattern, "g"));
shouldAbandon(() => iterator.next(), "matchAll next");
shouldAbandon(() => iterator.next(), "matchAll next again");
}

// A /g operation throws when one of its searches is abandoned: there is no partial result.
{
const late = "c" + subject;
shouldAbandon(() => late.replace(new RegExp(pattern, "g"), "X"), "replace /g, second search");
shouldAbandon(() => late.match(new RegExp(pattern, "g")), "match /g, second search");
shouldAbandon(() => late.split(new RegExp(pattern)), "split, second search");
}

// The limit is for one search. A /g replace that runs 20000 cheap searches takes more steps than
// the limit in total, and is not stopped.
{
const many = "ab".repeat(20000);
shouldBe(many.replace(/(?:a)+b/g, "").length, 0, "replace /g over many matches");
shouldBe(many.match(/(?:a)+b/g).length, 20000, "match /g over many matches");
}

// An error in the pattern is still a SyntaxError.
{
let error;
try {
new RegExp("(");
} catch (e) {
error = e;
}
shouldBe(error instanceof SyntaxError, true, "invalid pattern");
}
Loading
Loading