A nil exec was called: generic RegExp replace and split in goja
An empty object as this made RegExp.prototype[Symbol.replace] panic with a nil pointer in goja, a JavaScript engine in Go. So did a non-global replace on a regexp with exec undefined, and a split whose splitter had no exec. PR #807 fixed all three and was merged about 13 minutes after I opened it.
When it happens
RegExp.prototype[Symbol.replace].call({}, "a", "b"); // Node: TypeError
var r = /a/; r.exec = undefined;
"bab".replace(r, "X"); // Node: "bXb"
r = /a/;
r.constructor = {[Symbol.species]: function() { return {}; }};
"ab".split(r); // Node: TypeError
How RegExp replace and split work
RegExpExec (22.2.7.1) calls exec if callable, else needs a built-in matcher or throws TypeError. A non-global replace calls it in step 12.a of 22.2.6.11. split (22.2.6.14, steps 15.a and 19.b) calls it on a splitter built by the species constructor, not on the receiver.
Why the gap existed
The generic paths, regexpproto_stdReplacerGeneric and regexpproto_stdSplitterGeneric, read exec with toMethod and called the result: split since February 2020, replace since August 2020. toMethod returns nil for undefined, so a missing exec was called as nil. A regExpExec that follows the spec has existed since January 2021.
The fix
The whole diff to builtin_regexp.go, trimmed to changed lines and without function names after @@:
@@ -996 +995,0 @@
- execFn := toMethod(splitter.ToObject(r).self.getStr("exec", nil)) // must be non-nil
@@ -999 +998 @@
- if r.regExpExec(execFn, splitter, s) == _null {
+ if regExpExec(splitter, s) == _null {
@@ -1008 +1007 @@
- z := r.regExpExec(execFn, splitter, s)
+ z := regExpExec(splitter, s)
@@ -1182,2 +1181 @@
- execFn := toMethod(rxObj.self.getStr("exec", nil)) // must be non-nil
- result := r.regExpExec(execFn, rxObj, s)
+ result := regExpExec(rxObj, s)
A non-regexp this with no callable exec now throws a TypeError with no RegExpMatcher internal slot. A non-global replace on a regexp with exec undefined or not callable, and a split whose splitter is one, use the built-in matcher.
The test
TestRegExpReplaceSplitExec has 9 assertions: replace on plain objects and on a regexp with exec undefined or 1, split on a splitter with no exec, and a getter that counts reads. Three runs without the fix each hit the panic; -count=3 with it passed.
What the maintainer did
@dop251 merged it with a merge commit and no comments. Both files match my commit.
The lesson
- Count the reads of a property the spec fetches on every call. The old
splitreadexeconce before its loop. A fix that kept the single read passed eight rows and failed the ninth, a getter counting reads (3 for “bab”):Expected SameValue(«1», «3»). - In
splitthe receiver is not the object whoseexecruns. Withr.exec = undefined,RegExp.prototype[Symbol.split].call(r, "a")returns["", ""], with or without the fix. Set the property on the object the code reads.
If an engine of yours calls a method it never checked for, reply on X.