Skip to content

fix(ext/node): prevent panic when importing node builtins after module.register() - #33920

Merged
bartlomieju merged 1 commit into
mainfrom
fix/module-hooks-ext-resolve-panic
May 8, 2026
Merged

bartlomieju merged 1 commit into
mainfrom
fix/module-hooks-ext-resolve-panic

Conversation

@bartlomieju

Copy link
Copy Markdown
Member

Summary

  • When module hooks are active via register(), importing a node: builtin (e.g. node:os) panicked during V8's synchronous module instantiation because the builtin's ext: dependencies (like ext:core/mod.js) were routed through the async hook resolve bridge
  • Add ext: to is_already_resolved_specifier so internal Deno modules are never sent through user module hooks
  • Add a regression test that imports node:os after register()

Test plan

  • New spec test node_builtin_import_after_register in tests/specs/node/module_register_esm/
  • Existing module_register_esm spec tests still pass

…e.register()

When module hooks are active via register(), importing a node: builtin
(e.g. node:os) would panic during V8's synchronous module instantiation
because the builtin's ext: dependencies were routed through the async
hook resolve bridge. Add ext: to is_already_resolved_specifier so
internal Deno modules are never sent through user hooks.

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Substance walk

Clean targeted fix. The bug:

  1. module.register("hooks.mjs", ...) installs the async-hook resolve bridge
  2. Subsequent import("node:os") triggers V8's sync module_resolve_callback for the builtin's internal dependencies (e.g. ext:core/mod.js)
  3. The resolver routes those ext: specifiers through the async-hook bridge — which returns a Promise
  4. V8's sync callback expects a string and gets a Promise → panic

The fix at cli/module_loader.rs:1002 adds "ext:" to the existing is_already_resolved_specifier allowlist (which already includes node:, file:, http://, https://, data:, blob:):

  fn is_already_resolved_specifier(specifier: &str) -> bool {
    specifier.starts_with("file:")
      || specifier.starts_with("http://")
      || specifier.starts_with("https://")
      || specifier.starts_with("data:")
      || specifier.starts_with("blob:")
      || specifier.starts_with("node:")
+     || specifier.starts_with("ext:")
  }

ext: URLs are exclusively used by Deno's internal-extension module registry (ext:core/mod.js, ext:deno_node/..., ext:deno_web/... etc.) — none of which user-mode hooks should ever try to intercept. Routing them around the async bridge is correct: they're already-resolved by definition. ✓

Test coverage

Spec test module_register_esm/test_node_builtin_import.mjs reproduces the exact scenario:

import { register } from "node:module";
register("./hooks-basic.mjs", import.meta.url);
const os = await import("node:os");
console.log(typeof os.EOL);

Expected string (the actual EOL value '\n'/'\r\n''s type). Without the fix, this panics at the await import("node:os") line. ✓

CI

109 success | 14 in_progress | 0 fail | 1 skip

The 14 in_progress are mostly Windows-aarch64 + a couple slow debug shards. No failures so far on the substantive test paths.

Holding at COMMENT pending CI completion. Substance is APPROVE-quality — minimal, targeted, with a regression test that would fail in its current form against pre-fix code. Will re-confirm with APPROVE once CI lands clean.

@bartlomieju
bartlomieju merged commit 0f19a26 into main May 8, 2026
136 checks passed
@bartlomieju
bartlomieju deleted the fix/module-hooks-ext-resolve-panic branch May 8, 2026 11:52
bartlomieju pushed a commit that referenced this pull request May 13, 2026
…builtins (#34006)

Fixes #34004.

When `module.register()` is active, importing a `node:` built-in
(e.g. `node:path`) from an npm package was routed through the JS-side
load-hook bridge. The hook chain's `defaultLoad` returns
`{ source: null, format: "builtin" }` for `node:` URLs, which falls
through to the default Rust loader. That loader doesn't understand the
`node:` scheme and surfaces an `Unsupported scheme "node"` error — the
exact failure mode reported in #34004 during `vite build` of a Fresh app
that transitively calls `register()` via `@tailwindcss/postcss`.

The resolve path already short-circuits `node:` / `ext:` specifiers via
`is_already_resolved_specifier` (added in #33920). This applies the same
treatment to the load path: built-in modules are served by the runtime's
module map and must never be sent through user-supplied hooks.

`is_builtin_module_specifier` is factored out so the two call sites
share the exact same predicate.

Co-authored-by: lunadogbot <[email protected]>
littledivy pushed a commit to crowlKats/deno that referenced this pull request Jun 10, 2026
…e.register() (denoland#33920)

- When module hooks are active via `register()`, importing a `node:`
builtin (e.g. `node:os`) panicked during V8's synchronous module
instantiation because the builtin's `ext:` dependencies (like
`ext:core/mod.js`) were routed through the async hook resolve bridge
- Add `ext:` to `is_already_resolved_specifier` so internal Deno modules
are never sent through user module hooks
- Add a regression test that imports `node:os` after `register()`
littledivy pushed a commit to crowlKats/deno that referenced this pull request Jun 10, 2026
…builtins (denoland#34006)

Fixes denoland#34004.

When `module.register()` is active, importing a `node:` built-in
(e.g. `node:path`) from an npm package was routed through the JS-side
load-hook bridge. The hook chain's `defaultLoad` returns
`{ source: null, format: "builtin" }` for `node:` URLs, which falls
through to the default Rust loader. That loader doesn't understand the
`node:` scheme and surfaces an `Unsupported scheme "node"` error — the
exact failure mode reported in denoland#34004 during `vite build` of a Fresh app
that transitively calls `register()` via `@tailwindcss/postcss`.

The resolve path already short-circuits `node:` / `ext:` specifiers via
`is_already_resolved_specifier` (added in denoland#33920). This applies the same
treatment to the load path: built-in modules are served by the runtime's
module map and must never be sent through user-supplied hooks.

`is_builtin_module_specifier` is factored out so the two call sites
share the exact same predicate.

Co-authored-by: lunadogbot <[email protected]>
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.

2 participants