Skip to content

fix(runtime): an Error subclass has a .stack and an [object Error] tag (from #9432) - #9443

Merged
proggeramlug merged 4 commits into
mainfrom
fix/9432-error-subclass-stack
Sep 1, 2026
Merged

fix(runtime): an Error subclass has a .stack and an [object Error] tag (from #9432)#9443
proggeramlug merged 4 commits into
mainfrom
fix/9432-error-subclass-stack

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Lands #9432's remaining commit — #9410, an Error subclass getting a .stack and an [object Error] tag. Its other two commits (#9412 ticks ordering, #9411 tests) are already on main. 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.rs is a NEW module (split out of error.rs for 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-vs passed.

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:

callee can it collect? combinator
js_object_set_field_by_name_nonenum yes — generic setter routing can allocate and run JS across_*
ensure_key_in_keys_array yes — array creation/growth allocates across_const
own_key_present no — probes object metadata/registries only with_const_ptr
js_closure_set_capture_bits no — capture-slot write plus barrier bookkeeping with_const_ptr

Debt returns to 963/963 with the bare check passing, and no ceiling was added for the new module.

Also pairs every is_valid_obj_ptr in the module with is_above_handle_band. A bare is_valid_obj_ptr admits 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 (4 lone-valid-obj-ptr sites).

What I verified myself

  • No baseline, allowlist, ceiling or snapshot file in the diff — checked the changed-file list explicitly, since editing one is the obvious way to make this gate pass.
  • No cosmetic conversions — grepped for empty-closure forms (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 the across_* forms return re-read values.
  • Gates re-run by me in that worktree: raw-handle (bare, the one that was failing), addr-class, file-size, root-holder, shape-census, fmt.
  • test_gap_9410_error_subclass_stack.ts byte-identical to the pinned Node 26.5.1 oracle using the agent's own build.

The agent additionally reported perry-runtime 2,927 passed / 0 failed serially and doc-tests clean.

Summary by CodeRabbit

  • Bug Fixes

    • Error subclasses now provide a usable .stack and display as [object Error].
    • Preserved Error.prepareStackTrace, custom names/messages, and stack assignment behavior.
    • Corrected process.nextTick ordering after requiring built-in modules in CommonJS entries.
    • Maintained expected promise-first ordering for genuine ES module entries.
  • Tests

    • Added coverage for Error subclass variations, private brand checks, and module scheduling behavior.

Ralph Küpper 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).
@proggeramlug
proggeramlug merged commit 43ef982 into main Sep 1, 2026
19 of 20 checks passed
@proggeramlug
proggeramlug deleted the fix/9432-error-subclass-stack branch September 1, 2026 21:32
@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 9906b86d-560c-420d-b906-3524c54776a8

📥 Commits

Reviewing files that changed from the base of the PR and between 55b6ff1 and 50a4777.

📒 Files selected for processing (20)
  • changelog.d/9410-error-subclass-stack.md
  • changelog.d/9412-cjs-entry-next-tick-order.md
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/collectors/cjs_scaffolding.rs
  • crates/perry-codegen/src/collectors/mod.rs
  • crates/perry-codegen/src/expr/this_super_call.rs
  • crates/perry-codegen/src/lib.rs
  • crates/perry-codegen/src/lower_call/mod.rs
  • crates/perry-codegen/src/lower_call/new.rs
  • crates/perry-codegen/src/lower_call/new_error_init.rs
  • crates/perry-codegen/src/runtime_decls/objects.rs
  • crates/perry-runtime/src/error.rs
  • crates/perry-runtime/src/error_subclass_stack.rs
  • crates/perry-runtime/src/object/class_constructors.rs
  • crates/perry-runtime/src/object/to_string_tag.rs
  • crates/perry/src/commands/compile/cjs_wrap/preamble_canary_tests.rs
  • test-files/test_gap_9410_error_subclass_stack.ts
  • test-files/test_gap_9411_private_brand_in.ts
  • test-files/test_gap_9412_entry_tick_order.ts
  • test-files/test_gap_9412_require_builtin_tick_order.cts

📝 Walkthrough

Walkthrough

This change fixes .stack and [object Error] behavior for Error subclasses, preserves CommonJS nextTick ordering after builtin require(), and adds coverage for private brand checks in static contexts, inheritance, and separate class evaluations.

Changes

Error subclass stack support

Layer / File(s) Summary
Runtime stack accessor
crates/perry-runtime/src/error_subclass_stack.rs, crates/perry-runtime/src/error.rs, crates/perry-runtime/src/object/class_constructors.rs, crates/perry-codegen/src/runtime_decls/objects.rs
Error subclass instances now receive a lazy, own, non-enumerable, configurable stack accessor. The accessor formats name and message on read, supports assignment, handles moving GC, and has relocation tests.
Error subclass initialization wiring
crates/perry-codegen/src/lower_call/*, crates/perry-codegen/src/expr/this_super_call.rs
Error-like subclass construction uses shared initialization logic and captures the construction stack after reloading this.
Error tag and validation
crates/perry-runtime/src/object/to_string_tag.rs, test-files/test_gap_9410_error_subclass_stack.ts, changelog.d/9410-error-subclass-stack.md
Registered Error subclasses now report "Error" from Object.prototype.toString. Tests cover subclass shapes, stack formatting, descriptors, enumeration, throwing, and non-Error controls.

CommonJS entry tick ordering

Layer / File(s) Summary
CJS wrapper recognition
crates/perry-codegen/src/collectors/*, crates/perry-codegen/src/lib.rs, crates/perry/src/commands/compile/cjs_wrap/preamble_canary_tests.rs
Codegen now recognizes the synthetic __perry_cjs_create_require binding and exposes the recognition helpers. Canary tests cover wrapped modules and handwritten ESM negative controls.
Entry checkpoint selection
crates/perry-codegen/src/codegen/entry.rs, test-files/test_gap_9412_require_builtin_tick_order.cts, test-files/test_gap_9412_entry_tick_order.ts, changelog.d/9412-cjs-entry-next-tick-order.md
CJS-wrapped entries no longer receive ESM microtask ordering. ESM entries retain the ESM checkpoint. Fixtures validate both ordering modes and builtin imports.

Private brand check validation

Layer / File(s) Summary
Private brand check coverage
test-files/test_gap_9411_private_brand_in.ts
The fixture covers private fields, methods, accessors, static members, subclasses, negative cases, inherited brands, and separate class evaluations.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: thehypnoo

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/9432-error-subclass-stack

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant