fix(regex): RegExp.prototype.compile error semantics (Annex B) — #5910 - #6695
Conversation
`RegExp.prototype.compile` was installed as a no-op prototype thunk and its
coercion path was lenient, so several Annex-B error cases were missed:
- A non-RegExp receiver (`compile.call(undefined|null|23|{}|[]|sym)`) returned
`undefined` instead of throwing `TypeError`. Replace the no-op with a real
brand-checking `regex_proto_compile_thunk` (reuses `regex_instance_or_throw`)
that routes a valid receiver to `js_regexp_compile_value`. The no-op is kept
only for builds without the `regex-engine` feature.
- A Symbol pattern/flags coerced to `"Symbol(desc)"` instead of throwing.
Reject symbols via `reject_symbol_to_string` before `ToString`, matching the
abstract ToString operation (§7.1.17).
- `Set(obj, "lastIndex", 0)` (RegExpInitialize step 12) ignored a non-writable
`lastIndex`. Move the reset after the source/flags update and route it through
`set_last_index_throwing`, so a frozen `lastIndex` throws `TypeError` while the
receiver's `.source`/`.flags` are still updated.
Fixes the following annexB/built-ins/RegExp/prototype/compile test262 cases:
this-not-object, this-obj-not-regexp, flags-to-string-err, pattern-to-string-err,
pattern-regexp-immutable-lastindex.
Refs PerryTS#5910
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughRegExp compilation now rejects Symbol coercions, updates compiled state before resetting ChangesRegExp compile support
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant RegExpPrototype
participant regex_proto_compile_thunk
participant RegExpInstanceResolver
participant js_regexp_compile_value
RegExpPrototype->>regex_proto_compile_thunk: compile(pattern, flags)
regex_proto_compile_thunk->>RegExpInstanceResolver: resolve receiver
RegExpInstanceResolver-->>regex_proto_compile_thunk: registered RegExp instance
regex_proto_compile_thunk->>js_regexp_compile_value: compile pattern and flags
js_regexp_compile_value-->>RegExpPrototype: return compilation result
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/perry-runtime/src/regex/compile.rs (1)
67-78: 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy liftStale pointer usage due to unrooted values across potential GC.
The string coercion
js_string_coerce(pattern_val)can invoke user code (e.g., customtoString/valueOfmethods) and trigger garbage collection. Becauseflags_valis an unrootedf64stack local andreis an unrooted raw pointer, they will not be updated if their underlying objects are evacuated, leading to dangling pointer dereferences in subsequent steps. Based on learnings, values representing objects must be rooted viacrate::gc::RuntimeHandleScopebefore any allocating operations.
crates/perry-runtime/src/regex/compile.rs#L67-L78: Rootpattern_valandflags_valwithRuntimeHandleScopeand reload them before passing toreject_symbol_to_stringandjs_string_coerce.crates/perry-runtime/src/object/regex_proto_thunks.rs#L274-L275: Changejs_regexp_compile_valueto accept the receiver as anf64(so it can be safely rooted inside) or re-resolve the raw pointer fromIMPLICIT_THISafter the coercions, rather than extractingreas a raw pointer across the call.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/perry-runtime/src/regex/compile.rs` around lines 67 - 78, The regex compilation flow must root object-valued inputs before coercions that may allocate or trigger GC. In crates/perry-runtime/src/regex/compile.rs lines 67-78, use RuntimeHandleScope to root pattern_val and flags_val, then reload both rooted values before reject_symbol_to_string and js_string_coerce. In crates/perry-runtime/src/object/regex_proto_thunks.rs lines 274-275, update js_regexp_compile_value to receive the receiver as an f64 and root it internally, or re-resolve the receiver from IMPLICIT_THIS after coercions; do not retain an unrooted raw re pointer across the call.Source: Learnings
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@crates/perry-runtime/src/regex/compile.rs`:
- Around line 67-78: The regex compilation flow must root object-valued inputs
before coercions that may allocate or trigger GC. In
crates/perry-runtime/src/regex/compile.rs lines 67-78, use RuntimeHandleScope to
root pattern_val and flags_val, then reload both rooted values before
reject_symbol_to_string and js_string_coerce. In
crates/perry-runtime/src/object/regex_proto_thunks.rs lines 274-275, update
js_regexp_compile_value to receive the receiver as an f64 and root it
internally, or re-resolve the receiver from IMPLICIT_THIS after coercions; do
not retain an unrooted raw re pointer across the call.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: d7311b3d-fa8d-4e31-a2e5-f4feb68dafa7
📒 Files selected for processing (3)
crates/perry-runtime/src/object/global_this/proto_methods.rscrates/perry-runtime/src/object/regex_proto_thunks.rscrates/perry-runtime/src/regex/compile.rs
Summary
Fixes a coherent single-root subcluster of the #5910 annexB worklist: the
RegExp.prototype.compile(Annex B §B.2.5.1) error-handling cases.compilewas installed as a no-op prototype thunk and its coercion path was lenient,
so several required error behaviours were missed.
Fixes these 5
annexB/built-ins/RegExp/prototype/compile/*cases (all listed in #5910):this-not-object.jsundefinedTypeErrorthis-obj-not-regexp.jsundefinedTypeErrorflags-to-string-err.jsSymbol→"Symbol()"TypeErrorpattern-to-string-err.jsSymbol→"Symbol()"TypeErrorpattern-regexp-immutable-lastindex.jsTypeError, source/flags still updatedRoot cause & fix (3 small runtime edits, all in
perry-runtime)Missing brand check.
RegExp.prototype.compilewas a no-op thunk, socompile.call(<non-regexp>)returnedundefinedinstead of throwing. Replacedit with a real
regex_proto_compile_thunkthat reuses the existingregex_instance_or_throwbrand check (same oneexec/testuse) and routes avalid receiver to
js_regexp_compile_value. The Annex-B no-op is retained onlyfor builds compiled without the
regex-enginefeature.Lenient argument coercion. A
Symbolpattern/flags stringified to"Symbol(desc)". Addedreject_symbol_to_stringbeforeToString, matchingthe abstract ToString operation (§7.1.17) — same guard already used by
String.prototype.pad*,String.raw, etc.lastIndexreset ignored writability. RegExpInitialize step 12 isSet(obj, "lastIndex", 0, true)— a throwing set. The reset is now movedafter the source/flags update and routed through the existing
set_last_index_throwing, so a frozenlastIndexthrowsTypeErrorwhile.source/.flagsare still updated (exactly what the immutable-lastindex caseasserts).
Descriptors are preserved:
compile.length === 2,compile.name === "compile",and the
{writable, !enumerable, configurable}method descriptor are unchanged(the real thunk goes through the same
install_proto_methodpath as the old no-op).Validation
Measured with
scripts/test262_subset.py --all-featuresagainst a from-scratchbase-
mainbuild (A/B), test262 pinned SHA4249661…:annexB/built-ins/RegExp+built-ins/RegExp(1915 judged): base 1524 pass→ patched 1528 pass (net +4; the exact 5 target cases flip base-fail → patched-pass,
no other case regresses).
cargo fmtclean on all touched files;scripts/check_file_size.shunaffected(largest touched file 387 lines).
Note on
this-subclass-instance.js(out of scope, disclosed for transparency)Under
--all-featuresthe differential radar shows one case shiftingbase-pass → patched-"runtime-fail":
.../compile/this-subclass-instance.js(
features: [legacy-regexp, class], not in the #5910 list). This is adifferential/oracle artifact, not a correctness regression:
subclassInstance.compile()throwsTypeError.new (class extends RegExp {})("")does not produce a functioning[[RegExpMatcher]](its.sourceisundefined— a pre-existing RegExp-subclassing gap, unrelated to
compile). Given no matcher slot, throwingTypeErroris the consistent brand-check result — patched Perry nowsatisfies the case's own
assert.throws(Perry exit 0).has a real matcher) and so fails the assert — which is why the differential
flags the divergence. Base "passed" only because its no-op
compilefailed theassert the same way Node does.
i.e. patched Perry is more conformant to what this case asserts; fully fixing it
belongs to the separate RegExp-subclassing gap.
Per the external-contributor guidance, no version bump / CHANGELOG / CLAUDE.md
edits are included.
Refs #5910
Summary by CodeRabbit
New Features
RegExp.prototype.compile()when the regex engine is enabled.Bug Fixes
lastIndexand respects observable property behavior.