Skip to content

resolver: with options.paths, ask the package of the calling module for #imports and its own name - #47243

Open
robobun wants to merge 5 commits into
mainfrom
robobun/321f40ff/require-resolve-paths-caller-scope
Open

robobun wants to merge 5 commits into
mainfrom
robobun/321f40ff/require-resolve-paths-caller-scope

Conversation

@robobun

@robobun robobun commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • require.resolve(id, { paths }) searches a bare id from each entry as if the entry imported it (main, src/resolver/resolver.rs:2197). So the entry's package answers its name and #imports. Node searches only node_modules.
  • The calling module's package answers neither (MODULE_NOT_FOUND, or an entry's copy). In Node it answers both.

Fix

  • check_package_path_in_custom_dirs asks the calling module's package first. Its answer is final.
  • An entry search no longer asks the entry's package, unless it can install. A Worker's thread now frees its resolve buffer.
  • Verified: test/js/node/module/module-resolve-filename-paths.test.js (14 fail on main, Node passes its 13), test/js/node/module/, test/js/bun/resolve/.
  • Self-reviewed: 194 concerns raised, 177 addressed (rest in Notes).

Background

  • A file's package is the nearest package.json above it. #imports are its aliases. A self-reference requests its name.
  • Node reads both from the parent module's package. options.paths only starts the node_modules search.
  • Considered an argument through check_package_path and load_node_modules: every bare import pays. The paths slot costs them nothing.

Downsides

  • Shipped behaviour goes away, as in Node. Where an entry's search cannot install, its package no longer answers (nodejs/node#47681 asks for it). A calling package with exports no longer finds an entry's copy.
  • Without paths, a resolve executes -9 to +4 instructions. A found paths call executes about 1,450 more. .text grows 9,216 bytes.
  • A paths call from an unread directory reads package.json once. A #import that the caller lacks costs 8 syscalls and leaks 7 KB, as without paths.
Notes

How this was found. A differential run against Node v26.3.0 found it. No user reported it. The behaviour came in two steps. #18851 (v1.2.9) gave require.resolve the paths option with each entry as the importing directory. #24325 (v1.3.2) made Module._resolveFilename read options.paths. The vendored Node tests do not cover paths with a package.json on either side. So this PR turns no Node test green. The shared tests of this PR run under Node.

What changes. require.resolve(id, { paths }) and Module._resolveFilename(id, parent, isMain, { paths }). Bun's require(id, { paths }) and import.meta.require(id, { paths }) use the same search, so they load the same file.

Choices that this change makes. The sections below give the detail.

  • Whose tsconfig applies: the one above the directory that a search starts in, as on main. That directory is an entry, or the calling module's for a request that its package claims.
  • Which directory is the importer of an install: the directory that the search starts in, by the same rule.
  • An empty paths array: the calling module's package answers, then nothing is searched. This is Node's behaviour.
  • A parent with no filename: it is in no package. This is Node's behaviour, and Node's two exceptions are ported.

Repro (Node v26.3.0 as reference, bun --no-install):

mkdir -p /tmp/ps/sub/node_modules/callerpkg /tmp/ps/node_modules/subpkg /tmp/ps/caller && cd /tmp/ps
echo '{"name":"subpkg","exports":"./sub-main.js"}' > sub/package.json
echo '' > sub/sub-main.js
echo '' > sub/node_modules/callerpkg/index.js
echo '' > node_modules/subpkg/index.js
echo '{"name":"callerpkg","exports":"./main.js","imports":{"#x":"./main.js"}}' > caller/package.json
echo '' > caller/main.js
cat > caller/s.cjs <<'EOF'
const path = require("node:path"), root = path.join(__dirname, "..");
for (const id of ["subpkg", "callerpkg", "#x"]) {
  let v; try { v = path.relative(root, require.resolve(id, { paths: [path.join(root, "sub")] })); } catch (e) { v = e.code; }
  console.log(id, "=>", v);
}
EOF
node caller/s.cjs; bun --no-install caller/s.cjs
request Node main this PR
subpkg node_modules/subpkg/index.js sub/sub-main.js node_modules/subpkg/index.js
callerpkg caller/main.js sub/node_modules/callerpkg/index.js caller/main.js
#x caller/main.js MODULE_NOT_FOUND caller/main.js

Rows against Node v26.3.0. 41 calls, each from a module in a package and from a module in no package: 82 rows, one process per row (Node keeps a self-reference hit in Module._pathCache). 56 rows pass paths. Release builds of main at 11c3671 and of this change on that commit. Run with the cwd outside a package and again with the cwd in the caller's package. Both runs give these counts.

paths rows that differ from Node main this PR
of 56 35 2
rows that agreed on main and differ now 0

The 2 rows left are Module._resolveFilename("#x", null, false, { paths }). Node throws a TypeError there (it reads parent.filename of null). Bun throws MODULE_NOT_FOUND. The 26 rows without paths give the same answers on main and on this PR. Two more row sets were run on the same builds: 31 rows of parent filename shapes and 98 rows with a file:// parent filename. In both, no row that agrees with Node on main differs now. Node v26.12.1 gives the same answers as v26.3.0.

Rows outside these sets do move, and reviews found them. In the first four, an entry is the package that the parent is in, and main gave that package's answer through the entry.

  • A parent with a relative filename ({ filename: "./x.js" }, or a module whose filename was set to that). Node reads a package for it. This PR gives it none.
  • A parent that inherits from a module or wraps one (Object.create(module), a Proxy of it). Also a module whose filename was set to undefined or null. Node gives each the package of the module. In Bun the filename getter gives undefined to a receiver that is not the module, and the setter stores the string "undefined", on main too. So this PR gives them no package.
  • A filename with a NUL in a directory name. Node reads the package above that name. This PR gives it none.
  • A module that a plugin makes. Without paths it is in the package of the cwd. With paths it has none. Node has no such module.
  • A hand-made filename that starts with blob: or data:, with or without a / before it. Node gives it no package. This PR gives it the package of the cwd, as it gives a worker that is made from a string. A real file in the root of the filesystem with such a name gets the same.

A review ran 11,600 rows with paths over 60 parent shapes on an earlier draft. 202 rows agreed with Node on main and differed on that draft. 72 are of the first kind and 27 of the third. 103 came from two defects of that draft. An empty filename hid the id <repl> (64 rows). A real module with a bare relative filename got the package of the cwd (39 rows). Both are fixed. A review of the fix ran 21,528 processes over 77 parent shapes: 120 rows agree with Node on main and differ now. 88 are relative and hand-made blob: names, and 32 are the second kind. require.call(parent, id, { paths }) and Module._resolveFilename(id, parent, false, { paths }) gave the same answer in all 2,079 pairs. That review ran before data: names were added to the blob: rule.

Which package a parent is in. pathsParentOf (C++) classifies the parent for both callers, require and Module._resolveFilename. PathsParent::package_scope (Rust) decides the directory.

  • A filename that is an absolute path: the package of its directory. That directory is where the resolver searches from for the same module without paths.
  • null, {}, { id }, a string parent and a Module whose filename was never set have none. Node has two exceptions for a parent whose filename is not truthy, and both are ported. The id <repl> is in the package of the cwd. The id internal/preload is too, but not for a # request.
  • A module that is loaded from a blob: or data: URL is in the package of the cwd. That is a worker made from a string, from a Blob or from a data: URL. Bun names it with the URL (an ES module: /blob:<id>) and searches from the cwd for it without paths too. Node names a string worker [worker eval] in the cwd. A hand-made filename that starts like that gets the same.
  • An ES-module worker from a data: URL with a / in it has a name in a directory that does not exist (/data:text/javascript,...). It finds no package, with and without paths.
  • Any other filename that is not an absolute path has no package: a relative one, and a file:// URL. For the URL this is Node's behaviour. For a relative filename Node reads <directory>/package.json for each directory that the string names, and nothing above: ./x.js reads the one of the cwd, and x.js reads none. On Windows Node reads it as a path below the cwd.
  • A filename that ends in the path separator names a directory, in Node and here. On Windows a / at the end does not do that in Node's lookup (NormalizePath in src/node_modules.cc), and this PR follows that: on Windows CI, Bun gives such a parent no package. Node v26.12 keeps the package of a parent under the name of its directory (getTraversalStartDirectory). So on Windows its answer for that parent depends on what it looked up before, and the test does not ask Node for that row there.
  • A filename with a NUL in a directory name has no package. Node reads the directories above that name.
  • The package is the nearest package.json at or above the directory. One above a node_modules directory does not count, and one in the root of the filesystem does not (TraverseParent in Node's src/node_modules.cc: // We don't need to try "/", in v22.12.0, v24.3.0, v26.3.0 and main). So a file in the root is in no package. A run in a chroot with a package.json in the root agrees. Node v26.3.0 gives MODULE_NOT_FOUND for #x and for the package's name, from /app.js and from /src/app.js. This PR gives the same with paths.
  • A module that a plugin makes (build.module(), or onLoad in a namespace) has a name in the root, such as /virt. So it has no package with paths. Without paths Bun searches from the cwd for it. Node has no such module.
  • A package.json that is not a JSON object is passed over, as in Bun's search without paths. An empty file is a package without a name, as there. Node throws ERR_INVALID_PACKAGE_CONFIG for both. A package.json that is not a regular file is not opened.
  • A package claims a request when Node reads its field: imports or exports that is a number or a boolean is not read. Bun's parser gives an exports map with mixed keys ("." beside "default") the same marker. So such a package claims nothing with paths, and the entries answer, as on main. Node throws ERR_INVALID_PACKAGE_CONFIG there, and Bun without paths gives MODULE_NOT_FOUND.
  • A module that is embedded in a compiled executable is where the resolver searches from for it. That is the cwd, or <cwd>/<its directory> in an --aot executable when that directory exists. An executable reads package.json only when it is built with --compile-autoload-package-json. Then it answers as it does without paths. A default executable reads none. So no package claims a request there, and the answers are main's.
  • A module of an in-place executable (--compile-in-place) keeps its source directory, as under bun run. A review found one difference there when the sources were moved away. Without paths the cwd's package answers through the search after a miss. With paths an entry that has the name answers first.
  • With paths, a parent object whose filename is not a non-empty string has its id read once, also for a builtin request. Node reads it once or twice, and not for a builtin. Main does not read id in require.call(parent, id, { paths }), nor after an empty filename. Without paths the reads are main's.

What a claimed request gives. The search is the one of the same request without paths, from the calling module. So:

  • tsconfig paths of the calling module are read before its exports, as without paths. For every other request the tsconfig above the entry answers, as on main. A rule that the caller's tsconfig answers every paths request would move rows that agree with Node today. It needs a maintainer's yes first.
  • <name>/package.json of the calling package resolves although exports does not list it, as without paths. Node throws ERR_PACKAGE_PATH_NOT_EXPORTED. Main gives a copy below an entry, or MODULE_NOT_FOUND. The package's name with a ?query resolves as without paths too. Node does not find it.
  • A request that the package claims and does not have gives MODULE_NOT_FOUND. Node throws ERR_PACKAGE_PATH_NOT_EXPORTED or ERR_PACKAGE_IMPORT_NOT_DEFINED, with and without paths.
  • paths: [] gives the caller's answer, then nothing is searched. This is Node's behaviour.
  • An install starts where the same request without paths starts one. For example an imports target that is a bare name and is not installed goes to the registry. Under --install=force the install runs from the calling module's directory, so its package.json picks the version.
  • The package is the one that the directory cache has for the calling module, as for the request without paths. Node keeps the package.json that it read first.
  • After a miss, Bun drops the calling module's directory from the cache and searches once more, as for the same request without paths. Cost has the numbers.

Auto-install. A search can end in an install when no directory at or above its start has a node_modules directory. It also can with --install=fallback or --install=force. The importer of that install is the directory that the search starts in: an entry, as on main. On main the package of an entry answered #imports and its own name first, so such a request started no install. Two rules keep that:

  • A search from an entry that would start an install asks the package of that entry first (load_from_package_of_custom_dir). That package answers in the search that main runs from the entry. The node_modules directories and NODE_PATH are searched before it, as in Node.
  • When the package of an earlier entry has the request and that entry's search cannot install, the later entries are searched as in Node (CustomDirPaths::installs). No install starts at one, and its package does not answer.

46 rows give the same file and the same registry requests as main. They cover a plain package, a workspace member, a file: and a link: dependency, and a package in the global cache. Each runs with one and two entries in both orders. The plain package also runs against a registry that has a package of that name. A review ran 9,195 more rows in five install modes on an earlier draft. Every difference there is one that this description names. --install=force installs in every case, as on main.

Question for a maintainer. This PR makes two shipped behaviours go away. Both are Node's behaviour. No Bun issue asks for either.

  1. Where a search from an entry cannot install, paths: [dirInPackageX] no longer resolves the name of X or its #imports through the entry. That is with a node_modules directory at or above the entry, with --no-install, under bun test, and in a compiled executable that reads package.json (--compile-autoload-package-json). A default executable reads none, so the entry's package did not answer there on main either. Node throws MODULE_NOT_FOUND there, or finds an installed copy. nodejs/node#47681 (open) asks Node to resolve a self-reference through paths. In nodejs/node#58487 (open) a Node member writes that a difference between paths: [dir] and a require.resolve run in dir "would be a bug". createRequire(dir + path.sep).resolve(id) gives the entry's answer in Node, on main and here.
  2. A request for the calling package's own name when that package has exports, or any # request when it has imports, is no longer searched from the entries. nx 24.0.1 asks for a copy of itself below an entry at dist/src/daemon/server/nx-console-operations.js:29 and :56, shutdown-utils.js:37 and command-line/configure-ai-agents/configure-ai-agents.js:50. Node gives the running copy there, and so does this PR. Main gives the copy below the entry. nx works around Node's answer in dist/src/utils/package-json.js with createRequire('/__nx_detached_resolver__/x.js'), which gives the copy below the entry in Node, on main and here.

If Bun should keep the first on purpose, three parts can go: the two gates in load_node_modules, load_from_package_of_custom_dir and the installs flag. The caller's half stays.

The directory cache. On main a paths search does not read the calling module's directory. What a later request finds there depends on that: a directory that was read is not read again until a miss drops it. So the package lookup (scope_package_json) reads the directory cache and does not write it.

  • A directory that is in the cache answers from there, with its parents. The lookup holds the resolver's lock for that read. dir_info_cached_maybe_log puts a directory in the cache and then fills it under that lock. A Worker that reads the same directory again does that.
  • Any other directory is asked for its package.json by path: one stat, and one read when the file is there. The answer is kept in a map on the resolver (custom_dir_scopes) until the directory cache has the directory.
  • After the search from a calling module whose package claimed the request, the claim is checked again. That search read the directory, and what it read decides.
  • An executable that does not read package.json makes no read by path (opts.load_package_json). A directory that Bun.build() read in such an executable answers from the cache, as it does for the request without paths.
  • A directory whose package.json path would be too long for a read by path is asked in the cache only.

A review ran 7,979 random sequences against an earlier draft that read the calling directory into the cache and dropped it again. 119 gave another answer than main. On this design the cases that it reported give main's answers, or the calling package's answer that this PR adds. So do 35 more sequences (a package that is created after a first call, then asked for again). The test the package lookup leaves the directory cache as it was has seven of them. A later review ran 14,000 sequences without imports or exports on the draft before this one: no answer differed from main, and in 220 traced sequences the only added syscalls were the package.json reads by path.

That review found two defects, and both are fixed. The lookup read the cache without the lock, so with a Worker it could see a directory that was not filled yet. And a package.json that is a FIFO was opened. The test the package of the calling module answers while a worker reads its directory again covers the first. On a copy of the release binary with the lock patched out, it fails in 5 of 5 runs. No other test of the file fails there. On this build it passed 10 of 10. A review ran about 14 million requests beside a Worker, and a hang hunt with four Workers and Bun.build(): no wrong answer and no hang.

Known limits. In each, a request that no package claims reads the calling directory into the cache, gets an old answer, or costs more than on main.

  • The lookup keeps what it read by path until a search reads the directory. That is a directory that no search read, and also the calling module's directory after a miss dropped it from the cache. A package.json that is created or edited in between is not seen by paths requests. Any request without paths from that directory reads it again. Node keeps its first read for the life of the process. A draft dropped the answer on each miss: a failed probe then read and parsed package.json again on every call.
  • The map says that the package claims a request, and package.json lost that claim on disk since. Then the search from the calling module runs, the second check rejects the claim, and the entries answer as on main. But that search read the calling directory into the cache. It takes an edit of package.json between two paths calls from a directory that nothing else read.
  • The same happens when a read by path cannot see the package.json that a listing sees, and a package above claims the request. One case is a package.json that is a symbolic link to something that is no regular file. The other is a directory of 4,083 to 4,095 bytes. A link to a FIFO then blocks the search, as it blocks the same request without paths on main.
  • A hand-made parent in directories that were never read costs two lookups for each level of its path, on each call. In microseconds for a call, this PR against main: 2.0 against 1.5 at 20 levels, 4.3 against 1.2 at 60, and 21.5 against 1.2 at 200. A filename of 200 KB costs 0.25 ms against 0.003 ms. A real module 200 levels deep costs 2.7 against 1.3.

Cost. Release builds of main at 11c3671 and of this change on that commit, Linux x64. The branch is rebased on a later main.

Instructions executed per call, without paths, inside $resolveSync, jsFunctionResolveFileName or Bun.resolveSync, _resolve, resolve_and_auto_install, resolve_without_symlinks, check_package_path, check_relative_path, resolve_without_remapping, load_node_modules, dir_info_cached_maybe_log and Package::parse_name (qemu-x86_64 -one-insn-per-tb -d exec,nochain, exact, (I(2N) - I(N)) / N):

call without paths main this PR
bare, no tsconfig, no package.json 1,834 1,825
bare, tsconfig without paths 1,849 1,840
bare, tsconfig with paths 1,864 1,855
bare, importer in a package 1,819 1,813
#import hit 1,363 1,362
self-reference hit 1,984 1,984
self-reference, subpath not exported 1,603 1,607
relative 1,044 1,045
bare miss (two searches) 2,850 2,850
Module._resolveFilename, bare 1,774 1,769
Bun.resolveSync, bare 1,808 1,804
Bun.resolveSync, relative 1,046 1,049

No function on this path moved out of line. The resolver has the same set of symbols in both builds, plus the new cold functions and the growth code of the map.

Whole process, perf_event_open instructions:u, N = 20,000. Each cell is the median of three series, and a series is the median of 5 interleaved runs. The found paths call costs 1,430 to 1,484 more in four series. The spread of one cell was up to 493 here, and a review saw up to 1,264 on a loaded machine. So the rows without paths are equal within it. The calling directory had six files: a miss reads it again.

call main this PR
paths, 1 entry, found 16,017 17,468
paths, 1 entry, found, caller in no package 16,025 17,651
paths, 1 entry, found, Module._resolveFilename 14,449 15,908
paths, 8 entries, not found 79,402 82,404
paths, 1 entry, not found 20,812 22,203
paths, 1 entry, nope/ not found 20,720 22,152
paths, 1 entry, @scope/pkg not found 17,614 19,108
paths: [], not found 12,297 13,462
paths, the calling directory does not exist, not found 20,738 22,501
paths, the caller's own name 21,814 (throws) 13,173
paths, the caller's #x 20,230 (throws) 11,760
paths, a # request that the caller's imports do not have 20,874 34,120
the same # request without paths 29,206 29,190
no paths, bare, found 13,590 13,596
no paths, bare, found, caller in no package 13,638 13,629
no paths, bare, found, tsconfig with paths 13,809 13,752
no paths, self-reference 9,103 9,097
no paths, #import 7,942 7,946
no paths, relative 6,722 6,752
no paths, bare, not found 43,779 43,780
no paths, Bun.resolveSync, bare 13,139 13,147
  • Allocations per paths call (qemu entry counts of the mimalloc entry points): found 2 malloc and 1 free. Not found: 6 malloc, 2 realloc and 5 free. Both are as on main. Claimed by the caller and found: 3 malloc and 2 free.
  • Resolver runs per paths call: 1, as on main. A claimed miss of one segment: 2, as for the same request without paths.
  • File syscalls per paths call in steady state (qemu-x86_64 -strace): 0, as on main.
  • A request that the caller's package claims is the request without paths, and it costs what that costs. A claimed miss of one segment reads the calling directory again: 8 file syscalls, and the listing that it replaces is not freed for the life of the process (bust_dir_cache drops the index entry and not the listing, on main too). 20,000 such misses add 131 to 134 MB of RSS (about 7 KB each). The same misses without paths add 130 to 137 MB on both builds. With paths on main they add 6 to 10 MB, because main does not search from the calling directory. A claimed hit can alternate with a miss of one segment from the same directory. That pays half (71 to 76 MB against 8 to 9 MB).
  • The first paths call from a directory that the directory cache does not have asks for package.json by path. That is 1 syscall for each such directory, and 5 when the file is there. An example is createRequire() of a file that was never loaded. Main reads nothing there. Per such directory that is about 2,700 more instructions (18,100 to 20,800), or about 9,700 with the parse of a package.json. The parsed file is kept: 4,000 such directories add 11 to 12 MB of RSS (main: 5 to 6 MB). Without a package.json they add 6 MB on both. The wall time of 2,000 such calls was 16 to 112 ms against 8 to 20 ms on main, on a shared machine.
  • RSS after 20,000 failed paths probes that the caller's package does not claim: +7 to +11 MB (main: +9 to +13 MB, 5 runs each). The same probes without paths add 135 to 142 MB on both.
  • Release .text: +9,216 bytes (108,646,665 to 108,655,881). The stripped binary grows by 12,288 bytes. By function (nm -S): importer_package_claims with the lookup 4,986, check_package_path_in_custom_dirs 1,543, pathsParentOf 1,081, load_from_package_of_custom_dir 345 and PathsParent::package_scope 301 (all new), the growth code of the map 740, Bun__resolveSyncWithPaths +535, load_node_modules +252, the drop and the two constructors of Resolver +214, check_package_path +169, jsFunctionResolveFileName +84, $resolveSync +10, resolve_without_symlinks -87.
  • size_of::<Resolver>() grows by 88 bytes (1,272 to 1,360): 32 for the slot (16 to 48) and 56 for the map. The map allocates nothing until a paths call comes from a directory that the cache does not have.

Left as they are. This PR does not change these.

  • How a relative entry becomes a directory (paths: ["./sub"], paths: ["sub"]), and the install order of the loop. resolver: anchor a relative require.resolve paths entry at the cwd #41491 owns both. The calling module's package answers first with relative entries too.
  • Module._findPath(request, paths) gives each entry to a full resolve as the parent file.
  • import.meta.resolve and import.meta.resolveSync with { paths: [p] } take paths[0] as the parent. This is a Bun-only shape.
  • Without paths, the search for the caller's package.json goes above a node_modules directory and reads one in the root of the filesystem. Node stops at both. With paths this PR stops there too.
  • Without paths, a parent filename that ends in the separator is read as a file in the directory above. A file:// or relative filename gets a package there. Node differs in each.
  • options.conditions is not read. module.paths is not searched. A package that is found through NODE_PATH is resolved without its exports (resolver: apply package.json exports to packages found via NODE_PATH #36566 is open).
  • A plugin onResolve callback runs while the slot is set. A resolve that it starts is searched with the paths of the outer call.
  • A miss does not drop the cached listings of the entries. A package installed above an entry after a failed probe can stay unseen.
  • A paths entry with a NUL byte. Release builds of main and of this PR give the same answer. A debug build of this PR stops at an assertion in the directory lookup of the entry. Main's loop makes that lookup too. No debug build of main was run.

Open PRs at the same place.

Also in this PR. A resolve that finds nothing allocates a path buffer for its thread (specifier_cache_resolver_buf in src/jsc/VirtualMachine.rs), and the slot has no destructor. The Worker shutdown now frees it, next to the HPACK buffer that it already frees. LeakSanitizer reported the 4,096 bytes on the ASAN lane, in the child process of the Worker test. Main has the leak. A debug build hides it behind the suppression leak:Bun__resolveSync, and the release ASAN build has no frame of that name. On a debug build without the call LeakSanitizer counts the leak under that suppression, and with the call it counts none. The VM of a Worker is freed without a drop of its fields, so Transpiler::deinit also releases the map of this PR (release_custom_dir_scopes). Without that, LeakSanitizer reported 200 bytes for a Worker that resolved with paths from a directory that no search had read.

Suites. Run on Linux x64. cargo check --workspace --target x86_64-pc-windows-msvc passes. The first CI run passed the test file under Bun on Windows and macOS. Its Node run failed one row on Windows (a filename that ends in /, see above), and its ASAN lane reported the Worker buffer above. The later commits answer both. They also write each comment on one line, and run the memory test on every build with a bound for debug and ASAN builds.

  • The test file. Debug build with ASAN of this commit: 31 pass, also with the LeakSanitizer settings of CI. Release build of the first commit: 31 pass. Release build of main: 17 pass, 14 fail. No test sets a timeout, and no test is skipped on a debug or ASAN build.
  • Node v26.3.0 and v26.12.1: 13 pass, as a direct run and with node --test.
  • Release build of this PR: test/js/node/module/ 177 pass, 1 skip. test/cli/run/run-autoinstall.test.ts 300 pass, 1 skip. test/aot/in-place-executables.test.ts 35 pass, 6 todo. test/js/bun/plugin/ 112 pass. test/js/node/worker_threads/ 191 pass, 1 skip. test/js/web/workers/ 449 pass, 47 skip. The 48 vendored Node tests test-require-*.js and test-module-*.js pass.
  • test/js/bun/resolve/ with a 30 s limit for a test: 424 pass, 7 skip, 3 todo. With the default 5 s, two tests can exceed it in this container, on both builds: load the same file 10000 times and load the same empty JS file 10000 times. Neither passes paths.
  • An executable built with --compile --aot --compile-in-place gives the answers of bun run for a caller in a package. This holds on main and on this PR.
  • nodeExeAtLeast in test/harness.ts now gives no Node when node is Bun under that name. Two of its three other users then skip. The third (node-tls-connect.test.ts) then expects Bun's answer and passes.
  • test/js/bun/module-graph/ has tests that time out on a loaded machine, on both builds. That was 3 of 6 runs on main and 1 of 6 on this PR. They pass when run alone.
  • The length rule of the lookup has no test. The repro needs a directory of exactly 4,083 bytes on Linux.

Self-review. Six rounds of review on earlier drafts raised 194 concerns. 177 are addressed here. 17 are not taken:

The last round also named three things that its review allowed to leave. They are counted as addressed, and this description lists each:

  • The cost of a deep hand-made parent.
  • A package.json that is a link to something that is no regular file.
  • The read of id before a builtin request.

[human-review] gate passed · iteration 1 · 13 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/module/module-resolve-filename-paths.test.js
ninja: Entering directory `/workspace/bun/build/debug'
[0/2] cargo plan → /workspace/bun/build/debug/rust-target/plan.json
FAILED: [code=1] rust-target/plan.json /workspace/bun/build/debug/rust-target/plan.json 
/workspace/bun/build/release/bun /workspace/bun/scripts/build/stream.ts cargo --console /workspace/bun/build/release/bun /workspace/bun/scripts/build/rust/plan.ts /workspace/bun/build/debug/rust-target/plan.input.json /workspace/bun/build/debug/rust-target/plan.json
^[[1m^[[91merror^[[0m: cannot update the lock file /workspace/bun/Cargo.lock because --locked was passed to prevent this
help: to generate the lock file without accessing the network, remove the --locked flag and use --offline instead.
error: /opt/rust/bin/cargo build -p bun_executable --lib … exited with 101
ninja: error: rebuilding 'build.ninja': subcommand failed
error: script "bd" exited with code 1
__F:-1:S:0

release without fix: 14 FAILED
bun test v1.4.3-canary.1 (367d939d9)

test/js/node/module/module-resolve-filename-paths.test.js:
(pass) Module._resolveFilename respects options.paths for package resolution [40.59ms]
(pass) Module._resolveFilename respects options.paths for relative paths [0.27ms]
(pass) Module._resolveFilename with overridden function receives options.paths [1.77ms]
(pass) require.resolve respects options.paths for package resolution [0.57ms]
(pass) require.resolve with relative path and options.paths (Next.js use case) [0.42ms]
(pass) Module._resolveFilename throws ERR_INVALID_ARG_TYPE if options.paths is not an array [0.22ms]
268 |       return below(resolveWith(request, { paths }));
269 |     } catch (error) {
270 |       return error.code;
271 |     }
272 |   };
273 |   expect(lines(resolved)).toEqual(lines((resolveWith, request, paths, expected) => expected));
                                ^
error: expect(received).toEqual(expected)

  [
-   "createRequire().resolve("caller-pkg", { paths: ["entry"] }) => caller/caller-main.js",
-   "Module._resolveFilename("caller-pkg", { paths: ["entry"] }) => caller/caller-main.js",
-   "createRequire().resolve("caller-pkg", { paths: [] }
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/module/module-resolve-filename-paths.test.js
bun test v1.4.4 (367d939d9)

test/js/node/module/module-resolve-filename-paths.test.js:
(pass) Module._resolveFilename respects options.paths for package resolution [105.99ms]
(pass) Module._resolveFilename respects options.paths for relative paths [7.36ms]
(pass) Module._resolveFilename with overridden function receives options.paths [9.74ms]
(pass) require.resolve respects options.paths for package resolution [8.59ms]
(pass) require.resolve with relative path and options.paths (Next.js use case) [6.19ms]
(pass) Module._resolveFilename throws ERR_INVALID_ARG_TYPE if options.paths is not an array [9.60ms]
(pass) options.paths: the package of the calling module answers its own name and its #imports [80.65ms]
(pass) options.paths: the package of an entry answers nothing [55.29ms]
(pass) options.paths: which package a parent is in [117.03ms]
(pass) options.paths finds what was created after the calling module's directory was read [38.93ms]
(pass) options.paths: a package whose "expo
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     f1d29ac0de
  features     lto, baseline

21 deps, 432 codegen, 1228 objects in 9408ms

ninja: Entering directory `/workspace/bun/build/release'
[1/5] fetch lolhtml
[lolhtml] up to date
[2/5] fetch rust-argon2
[rust-argon2] up to date
[2/5] cargo plan → /workspace/bun/build/release/rust-target/plan.json
301 units: 203 lib, 32 custom-build (host), 28 run custom-build, 16 proc-macro (host), 17 lib (host), 4 run custom-build (host), 1 rlib
[4/5] fetch WebKit (prebuilt)
[WebKit] fetching https://github.com/oven-sh/WebKit/releases/download/autobuild-fcc1ea0973a4b96a007e699b25f52aab6a3d3470/bun-webkit-linux-amd64-lto-aot.tar.gz
[WebKit] extracted to /root/.bun/build-cache/webkit-fcc1ea0973a4b96a-lto-aot (53.9s)
[4/5] reconfigure
[1/7] fetch WebKit (prebuilt)
[WebKit] up to date
[1/1622] rustc bun_output_tags 
[2/1622] rustc unicode_xid 
[3/1622] rustc heck 
[4/1622] rustc fnv 
[5/1622] rustc unicode_ident 
[6/1622] mkdir codegen
[7/1622] rustc ident_case 
[8/1622] mkdir stamps
[9/1622] rustc
... (truncated)
diff hotspot
src/bundler/transpiler.rs                          |    1 +
 src/jsc/VirtualMachine.rs                          |    9 +
 src/jsc/bindings/ImportMetaObject.cpp              |   34 +-
 src/jsc/bindings/ImportMetaObject.h                |   16 +-
 src/jsc/modules/NodeModuleModule.cpp               |   12 +-
 src/jsc/web_worker.rs                              |    2 +
 src/resolver/dir_info.rs                           |    2 +-
 src/resolver/lib.rs                                |    3 +-
 src/resolver/package_json.rs                       |   23 +
 src/resolver/resolver.rs                           |  431 ++++++++-
 src/runtime/api/BunObject.rs                       |   18 +-
 test/harness.ts                                    |   11 +-
 .../module/module-resolve-filename-paths.test.js   | 1019 +++++++++++++++++++-
 13 files changed, 1521 insertions(+), 60 deletions(-)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                                                      reads  edits  tests
src/bundler/transpiler.rs                                     0      0    166
src/jsc/VirtualMachine.rs                                     3      1    168
src/jsc/bindings/ImportMetaObject.cpp                         1      1    168
src/jsc/bindings/ImportMetaObject.h                           1      1    167
src/jsc/modules/NodeModuleModule.cpp                          1      1    167
src/jsc/web_worker.rs                                         0      0    166
src/resolver/dir_info.rs                                      0      0    166
src/resolver/lib.rs                                           1      1    166
src/resolver/package_json.rs                                  1      1    166
src/resolver/resolver.rs                                     27     15    167
src/runtime/api/BunObject.rs                                  3      2    168
test/harness.ts                                               0      0    177
…st/js/node/module/module-resolve-filename-paths.test.js      9      7    166

Loading
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