Repository navigation
[JSC] A RegExp search that Yarr abandons throws a RangeError #343
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
robobun
wants to merge
6
commits into
main
Choose a base branch
from
robobun/regexp-throw-on-match-limit
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
bb49094
[JSC] A RegExp search that Yarr abandons throws a RangeError
robobun 98da23f
[JSC] A regExpMatchLimit of 0 is the lowest limit, and TestJavaScript…
robobun f99200b
JSTests/BUN-TEST-DIFFERENCES.md: section E names all ten sections of A
robobun 4491b75
[JSC] A legacy RegExp static whose search does not match again throws
robobun 341cab5
[JSC] The tests for an abandoned RegExp search run it in compiled code
robobun 8eade9e
[JSC] RegExp::match() has a throw scope, and only a compiler thread g…
robobun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
26 changes: 26 additions & 0 deletions
26
JSTests/stress/regexp-abandoned-match-first-context-does-not-fit-throws.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
55 changes: 55 additions & 0 deletions
55
JSTests/stress/regexp-abandoned-match-out-of-backtracking-memory-throws.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
81
JSTests/stress/regexp-abandoned-match-throws-in-compiled-code.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.