Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions changelog.d/9410-error-subclass-stack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
### Fixed

- **An `Error` subclass now has a `.stack` and reports `[object Error]`.**
`class A extends Error {}` produced instances whose `.stack` was `undefined`
and whose `Object.prototype.toString` tag was `"[object Object]"`. The base
class was fine — `new Error("x").stack` has always been a string — so only
subclasses were affected, and the claude-code bundle has **93** of them and
**106** `.stack` reads. `claude doctor` printed ~10 real frames and 14,573
bytes of stderr under node; under perry it printed ` - at <anonymous>`
and 120 bytes. Silent: no error, just a missing trace.

One root cause behind both symptoms. `class A extends Error {}` deliberately
produces an ordinary `GC_TYPE_OBJECT` class instance rather than a
`GC_TYPE_ERROR` `ErrorHeader`, so that the subclass's own fields have
somewhere to live. `alloc_error` — the only place that fills
`ErrorHeader.stack` — is therefore never reached, and neither is any
`stack` on `Error.prototype`, which carries only `name` and `message`. The
`[object Error]` branch of `js_object_to_string` is keyed on that same GC
header byte, so a subclass fell through to the `class_id` block and out the
`"[object Object]"` default.

The class-id registry that answers this question already existed and was
wired at four other sites — `instanceof Error`, `util.types.isNativeError`,
`Error.prototype.toString`'s subclass arm, and prototype-chain resolution
all consult `extends_builtin_error(class_id)`. Neither the tag nor the stack
did.

- `crates/perry-runtime/src/object/to_string_tag.rs` — tag a
`extends_builtin_error` class 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).
- `crates/perry-runtime/src/error_subclass_stack.rs` (new; `error.rs` was
within 90 lines of the 2,000-line CI cap) — `js_error_subclass_capture_stack`
installs the own, non-enumerable, configurable `stack` accessor node
installs, capturing the FRAME at the construction site. The head
(`"name: message"`) is formatted on read, not at capture, because that is
what V8 does and what the ubiquitous
`constructor(m) { super(m); this.name = "X" }` shape needs: node reports
`"X: m"`, and the assignment happens after `super()` returns. A user
`Error.prepareStackTrace` still wins, as it does for
`Error.captureStackTrace`. The setter redefines `stack` as a plain data
property, so `err.stack = ""` keeps working.
- `crates/perry-runtime/src/object/class_constructors.rs` — install it from
`js_error_subclass_default_init` (the synthesized standalone ctor, which
also serves the dynamic-parent `super` path) and from
`default_error_init_for_implicit_chain` (the dynamic `new` replay), the
two runtime sites that already stamped `message`/`name` and stopped there.
In the replay the install is moved above the message guard, which returns
early for a no-argument `new X()` — exactly the instances that would
otherwise still have no trace.
- `crates/perry-codegen/src/expr/this_super_call.rs`,
`crates/perry-codegen/src/lower_call/new_error_init.rs` (new; the
static-`new` Error arm moved out of `new.rs`, which was 5 lines from the
2,000-line CI gate) — the same call from the two codegen sites that stamp
`message`/`name` inline: an explicit `super(message)` into a built-in
Error, and the static-`new` arm for a subclass with no own constructor.
`this` is reloaded from its slot first; the stamps above it can collect.

A unit test in the new module installs the accessor under forced evacuation,
which is the only condition that can expose an unrooted pointer — and which
caught the first cut of that rooting reading a NaN-box handle back with
`get_raw_const_ptr`, aborting every Error-subclass construction with
"runtime handle kind mismatch". Nothing in the unit suite constructed an
Error subclass before, so only a compiled probe saw it.

Validation: `test-files/test_gap_9410_error_subclass_stack.ts`
byte-compared against `node --experimental-strip-types` across a bare
subclass, a `this.name`-assigning subclass, one with an extra field, a
two-level subclass, a subclass that sets `message` after an argument-less
`super()`, `TypeError`/`RangeError` subclasses, a factory-constructed
instance, a caught throw, `Error.captureStackTrace` on a subclass, and
controls for the base `Error`, a non-Error class and a plain object. The
fixture asserts the portable parts of the contract — `typeof stack`, the
head line, the `toString` tag, `name`/`message`/`instanceof`, and that
`stack` is an own but non-enumerable property that stays out of
`Object.keys` — because stack CONTENTS are host-specific. Demonstrated
failing on a compiler built from unfixed `origin/main` (46 diverging lines).
66 changes: 66 additions & 0 deletions changelog.d/9412-cjs-entry-next-tick-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
### Fixed

- **A `require()` of a builtin no longer demotes `process.nextTick` below
promise microtasks.**

```js
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 correct, and measurement says so: the same file run
by node 26 as `.cjs` prints `["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 exactly what `js_mark_entry_module_esm` (#788) models. It was being
applied to the wrong module kind.

Entry codegen decided "is this an ES module?" with
`!hir.imports.is_empty() || !hir.exports.is_empty() || has_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 of that predicate became true for
every CommonJS program. The `require("path")` call itself contributes no
import at all; it folds to a native-module reference. Every real bundle
requires a builtin and every minimal fixture does not, so the ordering was
right in exactly the programs a test suite contains and wrong in exactly the
programs users run.

- `crates/perry-codegen/src/collectors/cjs_scaffolding.rs` —
`is_cjs_wrapped_module`, keyed on the local name the wrap's synthetic
`createRequire` import binds. Recognised from the HIR, not from an
expectation about the template: if the wrap stops emitting it the
predicate degrades to "not wrapped" (today's behaviour) rather than to a
wrong answer for hand-written ESM, and a user's own
`import { createRequire } from 'node:module'` is not mistaken for it
because the match is on the alias, not the specifier.
- `crates/perry-codegen/src/codegen/entry.rs` — gate only the
`js_mark_entry_module_esm` call on that. The `is_esm_entry` below it keeps
its meaning for GlobalDeclarationInstantiation: a CommonJS module's
top-level `function` declarations live inside the module wrapper and are
not global-object properties either, so "not a Script" stays the right
answer there — and that predicate is mirrored in `perry-hir`'s
`lower_module_fn`, which runs before the wrap flag is knowable in codegen.
- `crates/perry/src/commands/compile/cjs_wrap/preamble_canary_tests.rs` —
a template canary in the same family as #7139/#7152: rename the local in
`wrap.rs` and every CommonJS entry silently goes back to ES-module tick
ordering with nothing going red. Plus a negative control, so the fix
cannot drift the other way and give real ESM entries CommonJS ordering.

Validation: `test-files/test_gap_9412_require_builtin_tick_order.cts`
byte-compared against node — ticks first, a tick scheduled from inside a tick
joining the same drain, a tick scheduled from inside a microtask landing
after it, and a second event-loop turn where no evaluation checkpoint could
apply. It has to be a `.cts`: this repo is `"type": "module"`, so a plain
`.ts` is an ES module for node and perry alike and cannot carry the shape
(#9418 taught the runner to discover `.cts`).
`test-files/test_gap_9412_entry_tick_order.ts` pins the ESM side so the fix
cannot be "stop deferring, always". Demonstrated failing on a compiler built
from unfixed `origin/main`.
26 changes: 25 additions & 1 deletion crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1046,7 +1046,31 @@ pub(super) fn compile_module_entry(
// first microtask drain finishes promise/queueMicrotask jobs before
// the nextTick queue, matching Node's job-within-checkpoint ordering
// for ESM evaluation (#788). CJS-style entries keep ticks-first.
if !hir.imports.is_empty() || !hir.exports.is_empty() || hir.has_top_level_await {
//
// #9412: "has imports or exports" is not the same question for a
// CommonJS entry, because `cjs_wrap` gives every CommonJS file BOTH —
// a synthetic `import { createRequire as __perry_cjs_create_require }
// from 'node:module'` and an `export default _cjs`. So any entry
// containing a bare `require(` answered "ESM" here and ran its
// `process.nextTick` callbacks AFTER the promise queue, where Node
// runs a CommonJS program's ticks first. Measured against Node 26:
// an entry as `.cjs` prints ["tick","promise","await"], the same file
// as `.mjs` prints ["promise","await","tick"] — the deferral is right,
// it was just being applied to the wrong module kind. Every real
// bundle requires a builtin and every minimal fixture doesn't, so the
// ordering was correct in exactly the programs a test suite contains.
//
// Only this checkpoint is re-gated. `is_esm_entry` below keeps its
// original meaning for GlobalDeclarationInstantiation: a CommonJS
// module's top-level `function` declarations live inside the module
// wrapper and are NOT global-object properties either, so "not a
// Script" is the right answer there for a wrapped entry too — and
// that predicate is mirrored in `perry-hir`'s `lower_module_fn`,
// which runs before the wrap flag is knowable here.
let cjs_wrapped_entry = crate::collectors::is_cjs_wrapped_module(hir);
if (!hir.imports.is_empty() || !hir.exports.is_empty() || hir.has_top_level_await)
&& !cjs_wrapped_entry
{
ctx.block().call_void("js_mark_entry_module_esm", &[]);
}
// Initialize static class fields with their declared init
Expand Down
38 changes: 38 additions & 0 deletions crates/perry-codegen/src/collectors/cjs_scaffolding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,44 @@ fn for_each_stmt(stmts: &[Stmt], f: &mut dyn FnMut(&Stmt)) {
}
}

/// The local name `cjs_wrap` binds its synthetic `createRequire` import to.
///
/// Mirrors the `imports` prefix in
/// `perry/src/commands/compile/cjs_wrap/wrap.rs` — a wrapped module always
/// opens with
/// `import { createRequire as __perry_cjs_create_require } from 'node:module';`
/// and nothing else in the pipeline ever mints that local. The `perry` crate's
/// template canary
/// (`commands/compile/cjs_wrap/preamble_canary_tests.rs`) asserts the wrap
/// still emits it, so a template edit fails a test instead of silently
/// un-recognising every CommonJS entry.
pub const CJS_WRAP_CREATE_REQUIRE_LOCAL: &str = "__perry_cjs_create_require";

/// True when `module` is the output of `cjs_wrap`'s CommonJS-to-ESM rewrite
/// rather than a module the user wrote with `import`/`export`.
///
/// #9412: `is_esm_entry` asks "does this module have imports or exports?", and
/// the wrap gives EVERY CommonJS file both — a synthetic `node:module` import
/// and an `export default _cjs`. A CommonJS entry therefore answered "yes" and
/// took Node's *ES-module* `process.nextTick` ordering (ticks after the promise
/// queue drains) when Node runs it with *CommonJS* ordering (ticks first).
///
/// Recognised from the HIR, not from an expectation about the wrap template:
/// if the template stops emitting this binding the predicate degrades to
/// "not wrapped" — today's behaviour — rather than to a wrong answer for
/// hand-written ESM.
pub fn is_cjs_wrapped_module(module: &Module) -> bool {
module.imports.iter().any(|import| {
import.specifiers.iter().any(|specifier| {
matches!(
specifier,
perry_hir::ImportSpecifier::Named { local, .. }
if local == CJS_WRAP_CREATE_REQUIRE_LOCAL
)
})
})
}

#[cfg(test)]
mod tests {
use super::super::ptr_shape::collect_shape_proven_ptr_locals;
Expand Down
5 changes: 4 additions & 1 deletion crates/perry-codegen/src/collectors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ mod this_as_value;
mod uppercase_strings;

// Public re-exports for the visible API.
pub use cjs_scaffolding::{census as cjs_preamble_census, CjsPreambleCensus};
pub use cjs_scaffolding::{
census as cjs_preamble_census, is_cjs_wrapped_module, CjsPreambleCensus,
CJS_WRAP_CREATE_REQUIRE_LOCAL,
};
pub use clamp_detect::{detect_clamp3, detect_clamp_u8, returns_i32_identity_arg, returns_integer};

// Internal-to-crate re-exports — explicit names because globs don't
Expand Down
20 changes: 20 additions & 0 deletions crates/perry-codegen/src/expr/this_super_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1327,6 +1327,26 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
&[(I64, &this_handle), (DOUBLE, opts_val)],
);
}
// #9410: `stack`. `super(message)` into a built-in
// Error stamps `message`/`name`/`cause` onto the
// already-allocated plain instance and stops there,
// so `new (class extends Error {})("x").stack` was
// `undefined` while `new Error("x").stack` is a
// string. The frame is captured HERE, at the
// construction site; the `name: message` head is
// formatted on read, because a subclass
// constructor assigns `this.name` after `super()`
// returns and Node reports the assigned name.
let blk = ctx.block();
// Reload `this` from its slot: the stamps above
// can collect, and a DOUBLE held across a
// collecting call is the bare-pointer hazard
// #8770 is about.
let this_for_stack = blk.load(DOUBLE, &this_slot);
blk.call_void(
"js_error_subclass_capture_stack",
&[(DOUBLE, &this_for_stack)],
);
}
}
bind_derived_this_after_super(ctx);
Expand Down
22 changes: 22 additions & 0 deletions crates/perry-codegen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,25 @@ pub fn module_exported_return_shapes(
pub fn cjs_preamble_census(hir: &perry_hir::Module) -> CjsPreambleCensus {
collectors::cjs_preamble_census(hir)
}

/// #9412 template-change canary: the local name `cjs_wrap` binds its synthetic
/// `createRequire` import to.
///
/// [`crate::collectors::is_cjs_wrapped_module`] keys the CommonJS-entry
/// recognition on this name, and the entry codegen keys the
/// `process.nextTick`-vs-microtask ordering on that. The `perry` crate's
/// template canary asserts the wrap still emits it, so a template edit fails a
/// test rather than silently putting every CommonJS entry back on ES-module
/// tick ordering.
pub fn cjs_wrap_create_require_local() -> &'static str {
collectors::CJS_WRAP_CREATE_REQUIRE_LOCAL
}

/// #9412: is `hir` the output of `cjs_wrap`'s CommonJS-to-ESM rewrite?
///
/// Public for the `perry` crate's template canary, alongside
/// [`cjs_wrap_create_require_local`]. The compile pipeline reaches the same
/// predicate through `collectors::is_cjs_wrapped_module`.
pub fn module_is_cjs_wrapped(hir: &perry_hir::Module) -> bool {
collectors::is_cjs_wrapped_module(hir)
}
1 change: 1 addition & 0 deletions crates/perry-codegen/src/lower_call/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ mod native_table;
mod new;
pub(crate) mod new_alloc;
mod new_ctor_args;
mod new_error_init;
mod new_helpers;
pub(crate) use new_helpers::emit_ctor_return_override;
mod omitted_native_params;
Expand Down
Loading
Loading