fix(runtime): an Error subclass has a .stack and an [object Error] tag (from #9432) - #9443
Merged
Conversation
added 4 commits
September 1, 2026 20:13
…object Error] tag
`class A extends Error {}` produced instances whose `.stack` was `undefined`
and whose `Object.prototype.toString` tag was `[object Object]`. The base
class was always fine, so only subclasses were affected — and the claude-code
bundle has 93 of them and 106 `.stack` reads, which is why `claude doctor`
prints ` - at <anonymous>` (120 bytes) under perry where node prints ~10
real frames (14,573 bytes). No error, just a missing trace.
One root cause behind both symptoms: an Error subclass instance is
deliberately an ordinary GC_TYPE_OBJECT class instance rather than a
GC_TYPE_ERROR ErrorHeader (so the subclass's own fields have somewhere to
live). `alloc_error` — the only site that fills `ErrorHeader.stack` — is
therefore never reached, `Error.prototype` carries no `stack` to inherit, and
`js_object_to_string`'s `[object Error]` branch keys on the same GC header
byte.
The registry that answers "does this class_id extend a builtin Error?" already
existed and was already consulted by `instanceof Error`,
`util.types.isNativeError`, `Error.prototype.toString`'s subclass arm and
prototype-chain resolution. Neither the tag nor the stack asked it.
- to_string_tag.rs: tag an `extends_builtin_error` instance "Error", set
before the `Symbol.toStringTag` hook so a subclass's own tag still wins
(§20.1.3.6 consults the tag property last).
- error.rs: `js_error_subclass_capture_stack` installs the own,
non-enumerable, configurable `stack` accessor node installs. The FRAME is
captured at the construction site; the `name: message` head is formatted on
read, because `constructor(m) { super(m); this.name = "X" }` assigns after
`super()` returns and node reports the assigned name. `prepareStackTrace`
still wins; the setter redefines `stack` as a data property so
`err.stack = ""` keeps working.
- class_constructors.rs, this_super_call.rs, new.rs: call it from the four
sites that already stamped `message`/`name` and stopped there. In the
dynamic-`new` replay it moves above the message guard, which returns early
for `new X()` with no argument — exactly the instances that would otherwise
still have no trace.
test-files/test_gap_9410_error_subclass_stack.ts byte-matches node across nine
subclass shapes plus controls. Demonstrated failing on a compiler built from
unfixed origin/main.
require("path"); // delete this line and perry matched node
const o = [];
process.nextTick(() => o.push("nextTick"));
Promise.resolve().then(() => o.push("p1"));
(async () => { await null; o.push("await"); })();
setTimeout(() => console.log(JSON.stringify(o)), 20);
// node: ["nextTick","p1","await"]
// perry: ["p1","await","nextTick"] (5/5 deterministic)
The deferral itself is right, and measurement says so: node 26 runs the same
file as .cjs -> ["nextTick","p1","await"], as .mjs -> ["p1","await","nextTick"].
An ES module evaluates inside its module job's promise chain, so its first tick
drain lands after the promise queue — which is what `js_mark_entry_module_esm`
(#788) models. It was being applied to the wrong module kind.
Entry codegen asked "is this an ES module?" as `imports or exports or
top-level await`. A bare `require(` with no top-level `import` classifies the
entry as CommonJS, and `cjs_wrap` then rewrites it to ESM — injecting
`import { createRequire as __perry_cjs_create_require } from 'node:module'`
and `export default _cjs`. Both halves became true for every CommonJS program.
The `require("path")` itself contributes no import; it folds to a
native-module reference. Every real bundle requires a builtin and every
minimal fixture doesn't, so the ordering was right in exactly the programs a
test suite contains.
- collectors/cjs_scaffolding.rs: `is_cjs_wrapped_module`, keyed on the local
name the wrap's synthetic `createRequire` import binds — recognised from the
HIR, so a template change degrades to "not wrapped" rather than to a wrong
answer, and a user's own `import { createRequire } from 'node:module'` is
not mistaken for it (the match is on the alias, not the specifier).
- codegen/entry.rs: gate only the `js_mark_entry_module_esm` call on it. The
`is_esm_entry` below keeps its meaning for GlobalDeclarationInstantiation —
a CommonJS module's top-level functions are not global-object properties
either — and that predicate is mirrored in perry-hir's `lower_module_fn`,
which runs before the wrap flag is knowable here.
- cjs_wrap/preamble_canary_tests.rs: a template canary in the #7139/#7152
family, plus a negative control so the fix cannot drift the other way.
test-parity/node-suite/globals/process-next-tick-require-order.ts byte-matches
node as a .cts CommonJS copy (the runner's existing retry);
test-files/test_gap_9412_entry_tick_order.ts pins the ESM side so the fix
cannot become "stop deferring, always". Both demonstrated failing / passing as
appropriate on a compiler built from unfixed origin/main.
…arrow #9411 reports `class A { #x = 1; static has(o) { return #x in o } }` answering `false` for `A.has(new A())`. It does not reproduce on origin/main (367f9aa, x86_64 Linux) in any of ~25 shapes: the exact snippet, .ts/.js/ .mjs/.cjs, a CJS-wrapped entry, `perry compile` / bare `perry` / `perry run`, with and without the on-disk cache, duplicate class names in sibling scopes / blocks / IIFE module wrappers, a cross-module import, `export default`, a namespace, a conditional class expression, private methods/getters/setters, static private fields, subclass instances, a field with no initializer, a field assigned only in the constructor, a static arrow field, a map callback / async / generator static method, and a frozen, sealed or bulk-allocated receiver. See the issue for the full matrix. What the existing fixtures did NOT cover is the shape the issue names — the brand check evaluated from a STATIC method — so this adds it. Both test_private_name_brand_check.ts and test_issue_5893_private_brand_freshness.ts only exercise `#x in o` from an instance method (or a static field's brand from a static method), and neither covers `#method` / accessor brands from a static method, a static block, a subclass instance, or a superclass brand seen through a subclass instance. Byte-matches node 26 today; it is coverage, not a regression test for a fix. The two asymmetries between the brand check and the private-field READ that would produce exactly the reported `false` are noted on the issue: `js_private_brand_check` returns false for `declaring_class_id == 0` where `js_private_guard` is permissive, and a `Some(false)` evaluation-brand verdict short-circuits the per-field marker fallback.
…combinators Each site classified by whether its callee can collect: js_object_set_field_by_name_nonenum and ensure_key_in_keys_array can allocate or run JS, so they use across_*; own_key_present and js_closure_set_capture_bits cannot, so with_const_ptr. Also pairs every is_valid_obj_ptr with is_above_handle_band (#9219).
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (20)
📝 WalkthroughWalkthroughThis change fixes ChangesError subclass stack support
CommonJS entry tick ordering
Private brand check validation
Estimated code review effort: 4 (Complex) | ~45 minutes Suggested reviewers: ✨ Finishing Touches📝 Generate docstrings
🧪 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 |
This was referenced Sep 1, 2026
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Lands #9432's remaining commit —
#9410, an Error subclass getting a.stackand an[object Error]tag. Its other two commits (#9412 ticks ordering, #9411 tests) are already onmain. Author's commit preserved; the rooting conversion came from a codex agent I ran on it, and I verified the result rather than accepting its report.What was blocking it
error_subclass_stack.rsis a NEW module (split out oferror.rsfor the 2000-line cap) carrying 4 raw-handle debt sites.raw_handle_debt.py's bare check locks a module absent at the merge base to zero, so it failed even though--no-raise-vspassed.The conversion, per site
The point of this gate is that a raw pointer must not outlive what can move it, so each site needed its callee classified rather than a blanket wrapper:
js_object_set_field_by_name_nonenumacross_*ensure_key_in_keys_arrayacross_constown_key_presentwith_const_ptrjs_closure_set_capture_bitswith_const_ptrDebt returns to 963/963 with the bare check passing, and no ceiling was added for the new module.
Also pairs every
is_valid_obj_ptrin the module withis_above_handle_band. A bareis_valid_obj_ptradmits the fetch/zlib/proxy handle bands, and dereferencing one segfaults on Linux while macOS hides it — the #9219 shape, which the addr-class ratchet was independently flagging here (4lone-valid-obj-ptrsites).What I verified myself
with_const_ptr(|p| p),across_x(|| ())) that would satisfy the scanner while executing the same read. None; the closures carry the real work and theacross_*forms return re-read values.test_gap_9410_error_subclass_stack.tsbyte-identical to the pinned Node 26.5.1 oracle using the agent's own build.The agent additionally reported
perry-runtime2,927 passed / 0 failed serially and doc-tests clean.Summary by CodeRabbit
Bug Fixes
.stackand display as[object Error].Error.prepareStackTrace, custom names/messages, and stack assignment behavior.process.nextTickordering after requiring built-in modules in CommonJS entries.Tests