Skip to content

resolver: attach resolve metadata to the ENAMETOOLONG ResolveMessage - #34097

Closed
robobun wants to merge 4 commits into
mainfrom
claude/farm/14894628/resolve-error-metadata-long-specifier
Closed

robobun wants to merge 4 commits into
mainfrom
claude/farm/14894628/resolve-error-metadata-long-specifier

Conversation

@robobun

@robobun robobun commented Jul 13, 2026 •

Copy link
Copy Markdown
Collaborator

What

A module specifier one byte past MAX_PATH_BYTES * 1.5 (6144 on Linux) produced a ResolveMessage with err.code === undefined, err.specifier === "", and err.importKind === "", while the message text itself was intact. Node.js keeps ERR_UNKNOWN_BUILTIN_MODULE / MODULE_NOT_FOUND at any length.

import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
for (const n of [6144, 6145, 60000]) {
  const spec = "node:x" + "a".repeat(n - 6);
  try { require(spec); } catch (e) {
    console.log(`len=${n} code=${e.code} specifier.len=${(e.specifier ?? "").length} importKind=${JSON.stringify(e.importKind ?? "")}`);
  }
}

Before:

len=6144  code=ERR_UNKNOWN_BUILTIN_MODULE specifier.len=6144 importKind="require-call"
len=6145  code=undefined specifier.len=0 importKind=""
len=60000 code=undefined specifier.len=0 importKind=""

After:

len=6144  code=ERR_UNKNOWN_BUILTIN_MODULE specifier.len=6144 importKind="require-call"
len=6145  code=ERR_UNKNOWN_BUILTIN_MODULE specifier.len=6145 importKind="require-call"
len=60000 code=ERR_UNKNOWN_BUILTIN_MODULE specifier.len=60000 importKind="require-call"

Error-handling code keyed on err.code (optional-dependency probes, builtin-vs-missing-dep fallbacks) would silently misclassify any >6 KiB specifier.

Why

resolve_maybe_needs_trailing_slash has an early length guard that short-circuits to an ENAMETOOLONG ResolveMessage. That arm built its Msg with ..Default::default(), which yields Metadata::Build. ResolveMessage::get_code / get_specifier / get_import_kind only populate on Metadata::Resolve, so all three getters fell through to their default branch.

The fix attaches the same Metadata::Resolve(MetadataResolve { specifier, import_kind, err }) that the sibling resolve-error fallback a few lines below (and the one in jsc_hooks.rs) already builds.

Test

Added a parameterized test in test/js/bun/resolve/resolve-error.test.ts that exercises both sides of the threshold (6144, 6145, 60000) across require() of a node: specifier, require() of a relative specifier, and dynamic import().

Fails on main with USE_SYSTEM_BUN=1 for 6145/60000, passes with this change.


no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/resolve/resolve-error.test.ts

The early length guard in resolve_maybe_needs_trailing_slash
(specifier.length() > MAX_PATH_BYTES * 1.5) built its Msg with
Default::default(), which yields Metadata::Build. ResolveMessage's
get_code/get_specifier/get_import_kind only populate on
Metadata::Resolve, so a specifier one byte past 6144 lost
err.code (ERR_UNKNOWN_BUILTIN_MODULE / MODULE_NOT_FOUND),
err.specifier, and err.importKind while the message text survived.

Attach the same MetadataResolve the sibling fallback already builds.
@robobun

robobun commented Jul 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced with the script in the report, root cause confirmed at resolve_maybe_needs_trailing_slash's ENAMETOOLONG arm.

Addressed review (201373b, 46cfce6): per-platform test threshold; clamped the stored specifier to u16::MAX at all four BabyString::r#in sites (ENAMETOOLONG arm + post-_resolve fallback in both VirtualMachine.rs and the near-duplicate jsc_hooks.rs::resolve_hook); added a Bun.resolveSync case to cover the IS_A_FILE_PATH=false path.

Verification:

USE_SYSTEM_BUN=1 bun test test/js/bun/resolve/resolve-error.test.ts -t 'long specifier'   # 2 fail (threshold+1, 200000)
bun bd test test/js/bun/resolve/resolve-error.test.ts                                      # 18 pass

CI: resolve-error.test.ts passed on all lanes across builds 72583, 72592, and 72604. The remaining red are unrelated flakes that also appear on main (install-registry/bun-add/no-orphans, repl-close, spawn-streaming-stdin, webview-chrome, fs-promises-readFile). Ready for review.

@coderabbitai

coderabbitai Bot commented Jul 13, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Long resolver error paths now clamp UTF-8 specifiers to u16::MAX bytes before storing metadata. Tests cover platform-specific long built-in and relative specifiers across CommonJS, ESM, and synchronous resolution.

Changes

Resolve Specifier Handling

Layer / File(s) Summary
Clamp oversized resolve metadata
src/jsc/VirtualMachine.rs, src/runtime/jsc_hooks.rs
Resolver error metadata construction now bounds specifiers to u16::MAX bytes in overlong and fallback paths.
Validate long-specifier resolution
test/js/bun/resolve/resolve-error.test.ts
Parameterized tests cover long CommonJS, ESM, and synchronous resolution, platform thresholds, error metadata, and specifier prefix preservation.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title is concise and accurately summarizes the ENAMETOOLONG resolve-metadata fix.
Description check ✅ Passed The description is detailed and covers purpose, behavior change, and verification, though it uses different section headings than the template.

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

@robobun

robobun commented Jul 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:55 PM PT - Jul 13th, 2026

✅ @robobun, your commit 46cfce61a4cec5c7a7c3727401040064db45b5da passed in Build #72604! 🎉


🧪   To try this PR locally:

bunx bun-pr 34097

That installs a local version of the PR into your bun-34097 executable, so you can run:

bun-34097 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Dynamic import fails for Data URI when parameters string is over 6144 characters long #20374 - Dynamic import fails for Data URI when specifier exceeds 6144 characters, hitting the same ENAMETOOLONG resolver path this PR improves
  2. Bun's native Worker throws NameTooLong on long data: URL module specifiers, hanging ffjavascript's multi-threaded init #33596 - Worker throws NameTooLong on long data: URL module specifiers, hitting the same ENAMETOOLONG early-return in the resolver

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #20374
Fixes #33596

🤖 Generated with Claude Code

@robobun

robobun commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

This PR does not fix #20374 or #33596. Those issues are about long data: URLs being rejected by the length guard when they should successfully load. This PR only fixes the error's .code/.specifier/.importKind properties on the rejection; the rejection itself still happens:

$ ./build/debug/bun-debug -e 'await import("data:text/javascript,const " + "b".repeat(6116) + "=1")'
FAIL: ERR_MODULE_NOT_FOUND ENAMETOOLONG while resolving package 'data:text/javascript,...'

Fixing those would mean either exempting data: specifiers from the IS_A_FILE_PATH length guard or handling them before the guard runs, which is a separate change.

@claude claude Bot 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.

LGTM — the Rust change is a mechanical copy of the sibling fallback at line ~4420 in the same function, and the test covers both sides of the threshold on Linux/macOS. The two inline nits (per-platform threshold in the test, and the pre-existing BabyString u16 wrap at >64 KiB) are worth a follow-up but don't block.

Extended reasoning...

Overview

The PR adds Metadata::Resolve to the Msg built in resolve_maybe_needs_trailing_slash's early ENAMETOOLONG guard (src/jsc/VirtualMachine.rs:4274-4281), so ResolveMessage getters (.code, .specifier, .importKind) populate correctly for specifiers longer than MAX_PATH_BYTES * 1.5. A parameterized test in test/js/bun/resolve/resolve-error.test.ts covers require/import of node: and relative specifiers at lengths 6144/6145/60000.

Security risks

None. This only affects which metadata variant is attached to an already-thrown resolve error. No new input parsing, no allocation size derived from untrusted input beyond what already existed, no auth/crypto/permissions.

Level of scrutiny

Low. The 6-line Rust change is byte-for-byte identical to the existing fallback at lines 4420-4428 in the same function (same printed.clone(), same BabyString::r#in(&printed, specifier_utf8.slice()), same import_kind, same err: ModuleNotFound). This is filling in a metadata field that was previously left at Default::default() — there's no novel logic to reason about.

Other factors

Two nits surfaced by the bug hunter, both non-blocking:

  • The test's hardcoded [6144, 6145, 60000] encodes the Linux threshold; on Windows (threshold 147453) none of these enter the patched arm, and on macOS (threshold 1536) all three do so there's no below-threshold control. The fix itself is platform-agnostic and is exercised by Linux + macOS CI, so this is a coverage gap rather than a correctness risk.
  • BabyString::r#in truncates length to u16, so specifiers >65535 bytes would wrap. This is a pre-existing property of BabyString (the sibling fallback uses the same call), not introduced here, and >64 KiB import specifiers are pathological. Pre-PR behavior at those lengths was also wrong (.code === undefined), so this isn't a regression.

The PR includes before/after evidence and the test is confirmed to fail with USE_SYSTEM_BUN=1 and pass with the change on Linux.

Comment thread test/js/bun/resolve/resolve-error.test.ts Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
robobun added 2 commits July 13, 2026 23:56
…shold

BabyString packs a u16 length, so the new ENAMETOOLONG arm wrapped the
stored specifier length to 0/1 bytes for specifiers > 65535 bytes, which
flipped .code back to MODULE_NOT_FOUND. Clamp to u16::MAX so the stored
prefix always carries enough to classify .code.

Derive the test's threshold per platform (4096*1.5 Linux, 1024*1.5
macOS, 98302*1.5 Windows) and add a 200000-byte case that exercises the
clamp.
Comment thread src/jsc/VirtualMachine.rs
Comment thread src/jsc/VirtualMachine.rs
Apply the same u16::MAX specifier clamp to the post-_resolve fallback in
resolve_maybe_needs_trailing_slash, which is reachable with unbounded
specifiers via Bun.resolveSync/Bun.resolve (IS_A_FILE_PATH=false skips
the length guard) and via require()/import() on Windows where the guard
threshold is 147453.

Also mirror both fixes (Metadata::Resolve + clamp) in the near-duplicate
resolve_hook in jsc_hooks.rs so the two implementations stay in sync.

Add a Bun.resolveSync case to the parameterized long-specifier test.

@claude claude Bot 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.

LGTM — all prior feedback addressed in 201373b and 46cfce6; the four BabyString::r#in sites are now consistent.

Checked that the clamped prefix is still locatable by index_of inside printed (byte-level search, and data.text is a clone so the stored offset stays valid). The Bun.resolveSync test case now covers the IS_A_FILE_PATH=false path where the length guard is bypassed, and the 200000-byte case exercises the clamp on both the ENAMETOOLONG arm and the post-_resolve fallback. Also confirmed the byte-boundary clamp cannot panic on non-ASCII input since r#in and slice operate on &[u8], not str.

Extended reasoning...

Overview

Attaches Metadata::Resolve to the ENAMETOOLONG early-return in resolve_maybe_needs_trailing_slash so the resulting ResolveMessage exposes .code/.specifier/.importKind for specifiers longer than MAX_PATH_BYTES * 1.5. Adds a u16::MAX clamp before BabyString::r#in at all four call sites (two in VirtualMachine.rs, two in the near-duplicate jsc_hooks.rs::resolve_hook) to prevent length wraparound. ~30 lines of Rust across four near-identical hunks plus a ~75-line parameterized test.

Security risks

None. This only changes which metadata variant is attached to an error object on an already-failing resolve path; it does not alter what resolves or how. The clamp narrows a slice, so no new OOB surface.

Level of scrutiny

Low-to-medium. The Rust change is mechanical — it copies the exact Metadata::Resolve construction from the pre-existing sibling fallback ~150 lines below, and the clamp is a defensive .min() on a byte slice. The jsc_hooks.rs copy has zero live callers today (kept in sync for the crate-layering migration). No control-flow, allocation, or lifetime changes; printed.clone() matches the existing pattern so the BabyString offset computed against &printed is valid against the stored data.text.

Other factors

This PR has been through two rounds of my review. All four findings (per-platform test threshold, u16 wrap, sibling-fallback clamp, jsc_hooks.rs mirror) were addressed with follow-up commits and verified by the author at 65535/65536/65537/70000/200000. The bug-hunting pass on 46cfce6 raised no findings; one candidate (lone-surrogate panic in BabyString::r#in) was examined and ruled out — r#in operates on &[u8] via index_of, not str indexing. Test passes on the debug build and fails with USE_SYSTEM_BUN=1 per the author's evidence.

Jarred-Sumner added a commit that referenced this pull request Aug 18, 2026
…s for dead fields (#39327)

### Problem
- `LoaderHooks` (src/jsc/ModuleLoader.rs) has a `resolve` field that
nothing reads.
- Its only dispatch site,
`module_loader::resolve_maybe_needs_trailing_slash`, was deleted in
#35002; the field and the body it pointed at survived.
- The body installed for it, `resolve_hook` plus its inner `resolve` in
src/runtime/jsc_hooks.rs (about 470 lines), is a second copy of the live
resolver, `VirtualMachine::resolve_maybe_needs_trailing_slash` /
`_resolve` in src/jsc/VirtualMachine.rs. Every real caller uses the live
one: `Bun.resolveSync` and `Bun__resolveSync*`
(src/runtime/api/BunObject.rs), the ESM loader's
`Zig__GlobalObject__resolve` (src/jsc/JSGlobalObject.rs), and
`Module._findPath` (src/jsc/NodeModuleModule.rs).
- The two copies had already drifted and the dead one keeps attracting
patches: #37157 added the `data:` ENAMETOOLONG exemption to the live
copy only; #34097 and #35703 (open) patch both copies; #39316 was first
attempted against the dead copy, where it had no effect.
- rustc's `dead_code` lint cannot catch this shape: the field is `pub`,
and the body is referenced by the `__BUN_LOADER_HOOKS` static
initializer.

### Fix
- Delete the `resolve` field of `LoaderHooks`, `resolve_hook`, its inner
`resolve`, and the helpers only they used (`EVAL_SUFFIX` /
`STDIN_SUFFIX` / `normalize_specifier_for_resolution` /
`normalize_source` in jsc_hooks.rs), plus the `resolve: resolve_hook`
entry in the static.
- Delete `src/bundler_jsc/PluginRunner.rs`, a re-export module whose
only user was the dead body, and make the function it re-exported
(`plugin_runner_on_resolve_jsc`) `pub(crate)`, since its remaining
caller is in the same crate.
- No behavior change: the deleted code was unreachable, and the live
resolver in VirtualMachine.rs is untouched (the alias lookups there are
what #39316 changes).
- Test: `test/internal/source-lints/dispatch-hook-tables.test.ts` finds
every `safe static __BUN_*: XHooks;` table (`LoaderHooks`,
`RuntimeHooks`, `SqlRuntimeHooks`, 48 fields) and fails on any field
with no `(hooks.field)(..)` call site. On main it fails naming exactly
`LoaderHooks ... resolve`; with this diff it passes. It also covers the
next field that loses its dispatch site in any of the three tables.
- Call sites are attributed per table: only files that call the table's
accessor (`loader_hooks()`, `runtime_hooks()`, `hooks()`) or name its
struct in code count, and a site in a file that can reach two tables
sharing the field name counts for neither (so
`RuntimeHooks::timer_insert` cannot vouch for
`SqlRuntimeHooks::timer_insert`). Full-line `//` comments are blanked
before matching.
- Verified: `bun bd` builds; `bun bd test` on
test/js/bun/resolve/{resolve,resolve-error,require,import-query,import-meta,import-meta-resolve,bun-main-entry-point},
test/js/node/module/node-module-module.test.js,
module-resolve-filename-paths.test.js,
test/js/node/missing-module.test.js, and test/js/bun/plugin (220 + 44
pass); `bun test test/internal/source-lints/` (all 25 lints pass, the
existing dead-code inventories are unaffected); `cargo fmt --check`
clean.

### Background
- Crate tiers: `bun_jsc` (module loader, VM) sits below `bun_runtime`
(resolver plumbing, plugins, blob registry) in the crate graph, so
`bun_jsc` cannot call `bun_runtime` functions directly.
- Hook table: the way around that. The lower crate declares a struct of
fn pointers (`LoaderHooks`, `RuntimeHooks`, `SqlRuntimeHooks`) and an
`extern "Rust" { safe static __BUN_*_HOOKS: ... }`; `bun_runtime`
defines that `#[no_mangle]` static with one body per field; the lower
crate calls a field as `(hooks.field)(..)`. A field is live only if such
a call exists somewhere.
- The live resolver can sit in `bun_jsc` because the two `bun_runtime`
pieces it needs are already reachable from there: the blob URL registry
through `RuntimeHooks::has_blob_url`, and the plugin `onResolve` runner
through `plugin_runner_on_resolve_jsc`, which was moved down into
`bun_jsc`. That leaves nothing for a `LoaderHooks::resolve` route to do.

<details>
<summary>Evidence the field is dead</summary>

```
$ grep -rn "hooks\.resolve)" src      # no dispatch site
$ grep -rn "loader_hooks()" src       # all in src/jsc/ModuleLoader.rs: transpile_source_code,
                                      # fetch_builtin_module, transpile_file, get_hardcoded_module,
                                      # resolve_embedded_node_file, transpile_virtual_module
$ git log --oneline -S "(hooks.resolve)" -- src
85ddc95 Remove ~39k lines of dead Rust across the workspace (#35002)
23427db Rewrite Bun in Rust (#30412)
```

Lint output with main's versions of the five src files checked out:

```
error: LoaderHooks (src/jsc/ModuleLoader.rs) has hook fields that nothing dispatches:
  resolve
(fail) every field of LoaderHooks is dispatched
(pass) every field of RuntimeHooks is dispatched
(pass) every field of SqlRuntimeHooks is dispatched
```

Not changed here: `bun_bundler_jsc`'s Cargo.toml still lists
`bun_resolver`, which this crate no longer uses; its manifest already
lists a dozen other crates its sources do not reference, so that is left
for a separate manifest cleanup. `VirtualMachine.entry_point` in bun_jsc
(read only by the `bun:main` branch of `_resolve`, which the alias table
short-circuits before it is reached) looks similarly vestigial but sits
in the function #39316 edits, so it is also left alone.
</details>

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-14, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant