Skip to content

runtime: createRequire node:-prefix normalization + diagnostics_channel builtin (#6644) - #6647

Merged
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:fix/6644-createrequire-diagnostics-channel
Jul 19, 2026
Merged

runtime: createRequire node:-prefix normalization + diagnostics_channel builtin (#6644)#6647
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:fix/6644-createrequire-diagnostics-channel

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Root cause

require('node:diagnostics_channel') through createRequire(import.meta.url) — the esbuild banner shim every ESM bundle of CJS deps produces; in the pi bundle it's lru-cache's node build — threw:

Error: Perry createRequire() currently supports built-in modules only; package/file require('node:diagnostics_channel') is not supported

Two gaps, one non-gap:

  1. Missing builtin, not a prefix bug. The node: prefix strip already existed in supported_require_builtin (and in process.getBuiltinModule; module.isBuiltin handles both spellings too). The real hole: diagnostics_channel was absent from both the createRequire and getBuiltinModule allowlists — even though a full implementation (real pub/sub channel registry, tracingChannel, bindStore, cross-thread publishes) already lives in node_submodules/diagnostics.rs and serves static imports. Both resolvers now allowlist it and route to js_node_submodule_namespace("diagnostics_channel"), exactly like the existing timers/promises special case.
  2. Dynamic-path dispatch was never armed. The require closure resolves builtins from a runtime string, so codegen can't emit precise per-module dispatch installs; without arming, a dynamically required module's namespace comes back from an empty registry (diagnostics_channel would have been an unresolved stub even once allowlisted). New js_module_create_require_devirt arms the nm + submod install-all hooks and delegates — mirroring js_process_get_builtin_module_devirt. The NativeModSig row for module.createRequire targets the devirt symbol, so the all-buckets reference is linked only into programs whose source actually calls createRequire; the plain js_module_create_require (reachable via the always-pinned ambient-require keepalives and the module dispatch bucket) stays free of it, preserving per-module dead-stripping.

What shipped

The real implementation, not a stub: channel(name) returns name-keyed singleton Channel objects with working publish → subscriber delivery, subscribe/unsubscribe, live hasSubscribers, and tracingChannel() — all backed by the existing node_submodules/diagnostics.rs registry, now reachable from createRequire (both diagnostics_channel and node:diagnostics_channel spellings) and process.getBuiltinModule.

Known follow-up (pre-existing, unchanged): the ambient computed require(expr) fallthrough (js_module_ambient_require_apply) does not arm the hooks itself (its keepalive anchors are pinned into every program, so referencing install-all there would re-pin all buckets globally); it resolves fine once anything armed them. Also pre-existing: perry over-accepts a few bare spellings of node:-only builtins (e.g. sqlite).

Tests

  • New createrequire_resolves_diagnostics_channel_and_node_prefixed_builtins in crates/perry/tests/createrequire_builtin_modules.rs: both spellings, module shape, real pub/sub across handles from each spelling (subscribe → hasSubscribers flips → publish delivers on both handles → unsubscribe returns true → flips back), tracingChannel shape, require('node:path') through the same require, and the getBuiltinModule sibling path. Compiled outputs verified byte-identical to node for the issue's minimal fixture and the full fixture during development.
  • cargo test -p perry --test createrequire_builtin_modules: 2 passed.
  • cargo test -p perry-runtime --lib -- --test-threads=1: 1422 passed, 0 failed.
  • cargo test -p perry-codegen --test native_proof_regressions (242 passed) + --lib (205 passed). (--test perry_builtin_name_collision fails to compile on the base branch already — pre-existing, unrelated.)
  • cargo fmt --all -- --check: no new diffs vs. base (one pre-existing diff in issue_6559_dyn_function_interpreter.rs untouched).

Pi bring-up wall #3 (tracker #6564): the recompiled pi bundle gets past the #6644 throw (validation result in the issue/tracker).

Stacks on #6633 — branched from fix/6604-class-expr-capture-refresh; merge after it.

Fixes #6644

https://claude.ai/code/session_01JuiiePQfrXhAFD9fuCygB9

Summary by CodeRabbit

  • New Features

    • createRequire now supports the diagnostics_channel built-in module in both bare and node:-prefixed forms.
    • process.getBuiltinModule can resolve node:diagnostics_channel.
  • Bug Fixes

    • Fixed captured values in class expressions so they reflect current assignments and remain isolated across repeated evaluations.
    • Improved built-in module resolution and require initialization across supported Node-compatible modules.

Ralph Küpper added 2 commits July 18, 2026 22:47
…lass expressions (PerryTS#6604)

A class EXPRESSION capturing an enclosing-function var assigned AFTER the
class in source order — semver's shape in every bundled class file:

    var Comparator = class _Comparator {
      constructor(comp, options) { options = parseOptions(options); ... }
    };
    module.exports = Comparator;
    var parseOptions = require_parse_options();   // AFTER the class

replayed a stale declaration-time capture snapshot on DYNAMIC construction
of the escaped class value: the end-of-body refresh machinery (PerryTS#6037/PerryTS#6052)
scanned only `ast::Decl::Class` DECLARATION statements in both body twins
(`lower_fn_body_block_stmt` and `lower_fn_expr`), so `var C = class ...`
never got refresh statements. The captured var stayed `undefined` in the
name-keyed CLASS_CAPTURE_VALUES snapshot forever, the per-evaluation
`__perry_ctor_caps` array had snapshotted the same pre-assignment
`undefined` (TDZ-suppressed, PerryTS#6523), and the ctor prologue's
param-or-snapshot rebind (PerryTS#5437) found `undefined` on both sides →
"TypeError: value is not a function" at pi-native init (wall PerryTS#2 of the
pi bring-up, PerryTS#6564, directly behind PerryTS#6593).

Fix: `lower_class_expr` records every capturing class expression lowered in
a function body — `(resolved_registration_name, captured_ids)`, sidestepping
the expression-ident/binding-name and rename subtleties by recording the
name AFTER dedup/rename resolution — in a new body-scoped
`ctx.body_class_expr_captures` list. Both body twins mark the list length at
entry and drain their own suffix into the existing re_regs/re_reg_capsets
machinery, so class expressions get the same refresh-after-assignment,
refresh-before-return, and end-of-body re-registration as declarations. The
runtime construct replay then self-heals through the existing ctor-prologue
param-or-snapshot rebind: the stale per-eval `undefined` cap param falls
back to the now-live name-keyed snapshot. No codegen/runtime changes.

Scoping: entries' LocalIds are only meaningful in their own function's
numbering, so every body path drains/truncates back to its entry mark: the
twins drain; arrows truncate on exit (mark at scope entry, covering
expression bodies); the block twin truncates on its error path; and
`get_param_default` self-truncates so a class expression in a default-param
value — lowered BEFORE the callee body's twin takes its mark at fn-decl /
ctor / method param sites — can never be drained by the wrong (enclosing)
body. No `append_new_args_stmt` pass runs for expressions — their construct
sites are either static (live locals appended at the site) or dynamic
(replayed through the snapshot).

Known limitation (noted in the PR): a MULTI-evaluation factory whose class
captures a var assigned after the class still reads the LAST evaluation's
refreshed snapshot for slots that were undefined at its own evaluation —
same last-writer-wins semantics the name-keyed store always had
(PerryTS#5437/PerryTS#685); pre-fix those slots were `undefined`, so this strictly
improves the broken case. Per-evaluation isolation of the normal
assigned-before-class shape is unchanged (per-eval `__perry_ctor_caps`
values win in the param-first rebind).

Tests: tests/test_class_expr_capture_refresh_6604.sh (var/let/const named +
anonymous class expressions, argument-position, the esbuild __commonJS
semver comparator.js layout, captured-var reassignment, multi-eval
isolation) and node-suite parity fixture
test-parity/node-suite/object/class-expr-capture-refresh.ts — all
node-identical; failing (TypeError) before this fix.

Fixes PerryTS#6604

Claude-Session: https://claude.ai/code/session_01JuiiePQfrXhAFD9fuCygB9
…el builtin (PerryTS#6644)

require('node:diagnostics_channel') through createRequire (the esbuild
banner shim every ESM bundle of CJS deps produces) threw
ERR_PERRY_UNSUPPORTED_CREATE_REQUIRE. Two gaps:

- diagnostics_channel was missing from the createRequire allowlist and
  from process.getBuiltinModule's, even though a full implementation
  (real pub/sub channel registry, tracingChannel, bindStore) already
  lives in node_submodules/diagnostics.rs. Both resolvers now allowlist
  it and route to js_node_submodule_namespace like timers/promises.
- The require closure resolves builtins from a RUNTIME string, so
  codegen could not emit per-module dispatch installs; a dynamically
  required module's namespace came back method-dead unless something
  else had armed the registries. New js_module_create_require_devirt
  arms the nm + submod install-all hooks (mirrors
  js_process_get_builtin_module_devirt); the NativeModSig row targets
  it, so the all-buckets reference is only linked into programs that
  actually call createRequire.

The node: prefix strip itself already existed in both resolvers
(supported_require_builtin / getBuiltinModule); module.isBuiltin also
handles both spellings — no gap there.

Fixes PerryTS#6644

Claude-Session: https://claude.ai/code/session_01JuiiePQfrXhAFD9fuCygB9
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds diagnostics_channel support to createRequire and process.getBuiltinModule, introduces a devirtualized require runtime entrypoint, and scopes class-expression capture refresh state during HIR lowering. Regression tests cover both runtime resolution and capture-refresh behavior.

Changes

createRequire built-in resolution

Layer / File(s) Summary
Runtime createRequire routing
crates/perry-runtime/src/module_require.rs, crates/perry-runtime/src/process.rs, crates/perry-runtime/src/process/node_module.rs
diagnostics_channel is routed through the node-submodules registry, and the devirtualized entrypoint enables install-all hooks before creating the require closure.
Codegen registration and coverage
crates/perry-codegen/src/runtime_decls/strings.rs, crates/perry-codegen/src/lower_call/native_table/node_core/module_sea_tls_test.rs, crates/perry/tests/createrequire_builtin_modules.rs
Codegen selects the new runtime symbol, while tests cover bare and node:-prefixed built-ins and process.getBuiltinModule.

Class-expression capture refresh

Layer / File(s) Summary
Scoped class-expression capture tracking
crates/perry-hir/src/lower/lowering_context.rs, crates/perry-hir/src/lower/context.rs, crates/perry-hir/src/lower/lower_expr/arm_class.rs, crates/perry-hir/src/lower_decl/block.rs, crates/perry-hir/src/lower/expr_function.rs, crates/perry-hir/src/lower_patterns.rs
HIR lowering records, scopes, drains, and refreshes class-expression captures across function bodies, arrows, anonymous functions, and parameter defaults.
Capture-refresh regression coverage
test-parity/node-suite/object/class-expr-capture-refresh.ts, tests/test_class_expr_capture_refresh_6604.sh
Tests cover class-expression forms, reassignment refresh, bundled layouts, factory isolation, compilation, and exact output matching.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant js_module_create_require_devirt
  participant NativeHooks
  participant NodeSubmoduleHooks
  participant js_module_create_require
  Caller->>js_module_create_require_devirt: create require(filename_or_url)
  js_module_create_require_devirt->>NativeHooks: enable install-all hooks
  js_module_create_require_devirt->>NodeSubmoduleHooks: enable install-all hooks
  js_module_create_require_devirt->>js_module_create_require: construct require closure
  js_module_create_require-->>Caller: return require closure
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#6633 — It contains the same HIR class-expression capture scoping fix and corresponding regression coverage.

Suggested reviewers: andrewtdiz, thehypnoo

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is detailed but does not follow the repository template and is missing the required Summary, Changes, Related issue, Test plan, and Checklist sections. Rewrite the description using the provided template headings, adding a short summary, concrete changes, issue reference, test plan, screenshots/output if relevant, and checklist items.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: createRequire support for diagnostics_channel and node:-prefix normalization.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
crates/perry-runtime/src/module_require.rs (1)

1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consolidate duplicated built-in module resolution logic.

The special-case routing for diagnostics_channel (and similarly for timers/promises) to node_submodules::js_node_submodule_namespace is duplicated in both module_require.rs and node_module.rs. Consider extracting this resolution logic into a shared helper function (e.g., in node_submodules/mod.rs or object/native_module_registry.rs) to keep the routing DRY and ensure any future submodule additions are handled consistently.

  • crates/perry-runtime/src/module_require.rs#L132-144: Replace this block with a call to the shared helper.
  • crates/perry-runtime/src/process/node_module.rs#L859-869: Replace this block with a call to the same shared helper.
🤖 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/module_require.rs` at line 1, Extract the duplicated
built-in submodule routing for diagnostics_channel and timers/promises into a
shared helper near the existing node_submodules or native module registry logic.
Update the require resolution flow in module_require.rs and the corresponding
node module resolution flow in node_module.rs to call this helper, preserving
current fallback behavior and ensuring future submodule additions use the same
routing.
🤖 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.

Inline comments:
In `@crates/perry-hir/src/lower_patterns.rs`:
- Around line 1457-1469: Update the default-expression handling around
lower_expr in the parameter-lowering path to retain body_class_expr_captures
entries for capturing class expressions instead of truncating them. Scope those
entries to the callee body’s capture-refresh machinery so they are not consumed
by the enclosing body while still observing later parameter/body assignments;
add regression coverage for a default class capturing a reassigned binding.

In `@crates/perry-hir/src/lower/lower_expr/arm_class.rs`:
- Around line 189-205: Update the class-expression capture refresh flow around
body_class_expr_captures and lookup_class_captures so refresh state is stored
per fresh class evaluation rather than in a single name-keyed global snapshot.
Ensure captures from later evaluations such as B cannot populate stale slots for
A, while preserving assigned-after-class refresh behavior. Add a regression
covering multiple assigned-after-class factory evaluations and construction of
the earlier class.

---

Nitpick comments:
In `@crates/perry-runtime/src/module_require.rs`:
- Line 1: Extract the duplicated built-in submodule routing for
diagnostics_channel and timers/promises into a shared helper near the existing
node_submodules or native module registry logic. Update the require resolution
flow in module_require.rs and the corresponding node module resolution flow in
node_module.rs to call this helper, preserving current fallback behavior and
ensuring future submodule additions use the same routing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5c34f5c4-35bb-405f-a1ff-0bc60d32ad66

📥 Commits

Reviewing files that changed from the base of the PR and between cca6e54 and 5f9bc0f.

📒 Files selected for processing (14)
  • crates/perry-codegen/src/lower_call/native_table/node_core/module_sea_tls_test.rs
  • crates/perry-codegen/src/runtime_decls/strings.rs
  • crates/perry-hir/src/lower/context.rs
  • crates/perry-hir/src/lower/expr_function.rs
  • crates/perry-hir/src/lower/lower_expr/arm_class.rs
  • crates/perry-hir/src/lower/lowering_context.rs
  • crates/perry-hir/src/lower_decl/block.rs
  • crates/perry-hir/src/lower_patterns.rs
  • crates/perry-runtime/src/module_require.rs
  • crates/perry-runtime/src/process.rs
  • crates/perry-runtime/src/process/node_module.rs
  • crates/perry/tests/createrequire_builtin_modules.rs
  • test-parity/node-suite/object/class-expr-capture-refresh.ts
  • tests/test_class_expr_capture_refresh_6604.sh

Comment on lines +1457 to +1469
// #6604: a capturing class EXPRESSION used as a default value
// (`function f(C = class { … }) {}`) must NOT register with the
// enclosing body's end-of-body capture-refresh machinery: param
// defaults are lowered BEFORE the callee's own body twin takes
// its list mark (fn-decl / ctor / method param sites), so the
// entry would be drained by the WRONG (enclosing) body and its
// ids interpreted in the wrong function's local numbering.
// Truncate whatever this default expression recorded — the
// default is re-evaluated at every call anyway, so its
// evaluation-time snapshot is per-call fresh.
let mark = ctx.body_class_expr_captures.len();
let default_expr = lower_expr(ctx, &assign.right)?;
ctx.body_class_expr_captures.truncate(mark);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not discard default class expressions that capture body-mutated bindings.

Per-call evaluation is fresh, but the class must still observe later body assignments:

function f(x, C = class { get() { return x; } }) {
  x = 2;
  return C;
}

Truncating here excludes C from assignment refresh, leaving its snapshot at 1. Scope these entries to the callee body instead of dropping them, and add regression coverage.

🤖 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-hir/src/lower_patterns.rs` around lines 1457 - 1469, Update the
default-expression handling around lower_expr in the parameter-lowering path to
retain body_class_expr_captures entries for capturing class expressions instead
of truncating them. Scope those entries to the callee body’s capture-refresh
machinery so they are not consumed by the enclosing body while still observing
later parameter/body assignments; add regression coverage for a default class
capturing a reassigned binding.

Comment on lines +189 to +205
// #6604: register this capturing class EXPRESSION with the enclosing
// body's end-of-body capture-refresh machinery (#6037/#6052), which
// previously scanned class DECLARATION statements only. Without the
// refresh, a captured var assigned AFTER the class expression (semver's
// `var Comparator = class _Comparator { … }; …; var parseOptions =
// require_parse_options()`) stays `undefined` in the decl-site snapshot,
// and dynamic construction of the escaped class value replays that stale
// snapshot. Recording the RESOLVED registration name here (post
// rename/dedup) sidesteps re-deriving it from the AST at body end. Module
// top is skipped — module-level ids are stripped from capture lists by
// `filter_module_level_captures`, so there is nothing to refresh.
if !at_module_top && !captured_args.is_empty() {
if let Some(ids) = ctx.lookup_class_captures(&synthetic_name) {
ctx.body_class_expr_captures
.push((synthetic_name.clone(), ids.to_vec()));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve per-evaluation captures for assigned-after-class factories.

This registers ClassExprFresh captures in one global, name-keyed snapshot. With A = make("a"); B = make("b"); new A(), stale slots in A can be backfilled from B’s later snapshot.

Keep refresh state on the fresh class object, or otherwise key it per evaluation. Add this assigned-after-class multi-evaluation regression alongside the existing assigned-before-class case.

🤖 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-hir/src/lower/lower_expr/arm_class.rs` around lines 189 - 205,
Update the class-expression capture refresh flow around body_class_expr_captures
and lookup_class_captures so refresh state is stored per fresh class evaluation
rather than in a single name-keyed global snapshot. Ensure captures from later
evaluations such as B cannot populate stale slots for A, while preserving
assigned-after-class refresh behavior. Add a regression covering multiple
assigned-after-class factory evaluations and construction of the earlier class.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

CodeRabbit's two findings here (param-default class expressions discarded; name-keyed snapshot cross-contamination in multi-eval factories) both target the #6604 refresh machinery that merged via #6633 — this PR's diff only carries it as a stacked base. Consolidated, with the third sibling finding from #6650/#6653, into follow-up issue #6654 for fixture-driven verification.

@proggeramlug
proggeramlug merged commit e61a01d into PerryTS:main Jul 19, 2026
23 of 25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant