Skip to content

bun test: sort every scanned directory so test-file order is stable across filesystems - #36636

Open
robobun wants to merge 4 commits into
mainfrom
claude/e2d52796/test-file-discovery-order
Open

robobun wants to merge 4 commits into
mainfrom
claude/e2d52796/test-file-discovery-order

Conversation

@robobun

@robobun robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator

What

bun test discovers test files with a BFS directory walk. The root directory's entries were already being sorted (when the resolver had it cached), but every subdirectory visited from the BFS queue was processed in raw getdents / hash-map iteration order. That order is filesystem-dependent (ext4 htree hash, APFS, tmpfs insertion order, NTFS), so the same project ran its test files in a different order on different machines.

This change sorts every directory's entries by lowercased basename before visiting them, so discovery order is the same everywhere. The inline-readdir iterator callback and the has_iterated bookkeeping are no longer needed and are removed.

Why this matters for #6655

The reproduction in #6655 has two test files in packages/server-winston-mongodb/src/:

  • Log.entity.test.ts calls getModelForClass(LogEntity) at module-load time, which caches a typegoose model bound to mongoose's default (never-connected) connection
  • MongoDBTransport.test.ts later calls getModelForClass(LogEntity, { existingConnection }) and, because of typegoose's model cache, gets the already-cached model on the wrong connection, so model.create(...) buffers forever and the test times out

Node reproduces the same hang when those two modules are evaluated in that order in one process:

cached model conn readyState: 0
second getModelForClass === cached? true
model2 conn readyState: 0
Attempting create...
HUNG

Because bun test runs every file in one process with a shared module registry, whether the hang shows up depends entirely on which file runs first. On filesystems where getdents returned Log.entity.test.ts before MongoDBTransport.test.ts (APFS, some Docker layouts), the test hung; on ext4 here, MongoDBTransport.test.ts happened to come first and it passed. Copying the package to a fresh "standalone" directory changes the directory-entry order again, which is why workspace vs standalone looked like the trigger.

With this fix the discovery order is stable, so that project now fails the same way everywhere instead of appearing to be a workspace/module-resolution bug. The underlying hang is typegoose's cache, not Bun; the fix on that project's side is either getModelForClass(LogEntity, { options: { disableCaching: true } }) or calling deleteModel('LogEntity') between files.

Verification

=== SYSTEM BUN (before fix) ===
deep/sub/apple.test.ts
deep/sub/zebra.test.ts
deep/sub/Mango.test.ts      <-- getdents / htree-hash order

=== DEBUG BUN (after fix) ===
deep/sub/apple.test.ts
deep/sub/Mango.test.ts
deep/sub/zebra.test.ts      <-- case-insensitive sorted

New test in test/cli/test/bun-test.test.ts creates 17 files across nested subdirectories whose ext4 htree-hash order is non-alphabetical and asserts the exact BFS-plus-sorted execution order. Fails on main, passes with this change.

Related: #6655, #26851


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

Deletions

The scanner was the only non-() implementer of DirEntryIterator. With the collect-then-sort model nothing hooks readdir per-entry anymore, so the following are removed as dead:

  • DirEntryIterator trait and its () / &T impls (src/resolver/fs.rs)
  • the !I::IS_VOID branch in add_entry_with_store
  • the I: DirEntryIterator generic and iterator argument on readdir / add_entry_with_store
  • read_directory_with_iterator (folded into read_directory)
  • ScannerDirIter and the has_iterated flag (src/runtime/cli/test/Scanner.rs)

…table

The scanner already sorted entries for the top-level directory when the
resolver had it cached, but subdirectories visited from the BFS queue were
processed in raw getdents/hash-map order. That order depends on the
filesystem (ext4 htree hash vs APFS vs tmpfs insertion order), so the same
project could run its test files in a different order on different machines.

Because bun test runs every file in one process with a shared module
registry, a different file order means different cross-file module side
effects, which turned issue #6655 into a pass-on-one-machine /
hang-on-another situation.

Fix by always collecting a directory's entries, sorting by lowercased
basename, and visiting in that order (both for the scan root and every
queued subdirectory). The inline readdir iterator path and has_iterated
bookkeeping are no longer needed.
@robobun

robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:05 AM PT - Aug 1st, 2026

❌ @robobun, your commit 2454c7a has 1 failures in Build #86743 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36636

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

bun-36636 --bun

@coderabbitai

coderabbitai Bot commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The scanner now performs deterministic, case-insensitive directory traversal across platforms. A new end-to-end test verifies breadth-first execution order and successful completion.

Deterministic test scanning

Layer / File(s) Summary
Sorted directory scanning
src/runtime/cli/test/Scanner.rs
Direct directory reads replace the resolver callback iterator. Root and nested entries use lowercase-name sorting and shared processing.
Execution-order validation
test/cli/test/bun-test.test.ts
The test verifies deterministic nested-file ordering and confirms that 17 tests pass.

Suggested reviewers: jarred-sumner

🚥 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 clearly summarizes the main change: stable test-file ordering through sorting of every scanned directory.
Description check ✅ Passed The description explains the change, its motivation, affected issue, implementation details, and verification with a new test.

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🤖 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 `@src/runtime/cli/test/Scanner.rs`:
- Around line 235-241: Update the comparator in the entry_ptrs sort_by call to
use the original basename as a tie-breaker whenever base_lowercase() values
compare equal. Preserve lowercase ordering first, then compare each Entry’s
original basename so entries differing only by case sort deterministically.
- Around line 235-245: Update process_entries_sorted to avoid infallible
allocation when collecting entry pointers: reserve the required Vec capacity
with a fallible allocation API, return AllocError on failure, and preserve
sorting and processing behavior on success. Change all three
process_entries_sorted call sites to propagate the allocation error as
ScanError::OutOfMemory.

In `@test/cli/test/bun-test.test.ts`:
- Around line 1702-1710: In the test named “runs test files in stable sorted
order regardless of filesystem readdir order,” remove the narrative regression
comments and the explanatory name-order comment, keeping only the exact issue
URL comment for issue 6655; apply the same cleanup to the additional referenced
lines.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0854b47e-7241-4204-9c96-7e5094cf78f0

📥 Commits

Reviewing files that changed from the base of the PR and between 5f7e62d and a95ee83.

📒 Files selected for processing (2)
  • src/runtime/cli/test/Scanner.rs
  • test/cli/test/bun-test.test.ts

Comment thread src/runtime/cli/test/Scanner.rs
Comment thread src/runtime/cli/test/Scanner.rs
Comment thread test/cli/test/bun-test.test.ts
Comment thread src/runtime/cli/test/Scanner.rs Outdated
Comment thread src/runtime/cli/test/Scanner.rs Outdated
…er hooks readdir

The test scanner was the only non-() implementer of DirEntryIterator. With
process_entries_sorted doing a collect-then-sort after read_directory returns,
nothing observes entries during readdir anymore, so the trait, the &T blanket
impl, the !IS_VOID branch in add_entry_with_store, and the generic I parameter
threaded through readdir/read_directory_with_iterator are all dead. Fold
read_directory_with_iterator into read_directory and drop the iterator arg at
every call site.

@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 dead DirEntryIterator plumbing I flagged earlier is now fully removed in 2454c7a, and the rest is unchanged.

What was reviewed:

  • Traced the BFS in scan() against the new test's expected order (root→pkg→lib,src; lib pushes inner; src files emit before inner's) — matches.
  • Confirmed no remaining references to DirEntryIterator/read_directory_with_iterator/has_iterated; the IS_VOID hits in Package.rs are unrelated.
  • Checked that process_entries_sorted is the only path calling next() now, so no double-processing on cached vs. fresh directories.
  • Return-type widening on read_directory (&mut → &'static mut) is compatible with all existing call sites.
Extended reasoning...

Overview

This PR makes bun test file discovery order deterministic by sorting every scanned directory's entries by lowercased basename (with a case-sensitive tie-break). Previously only the root directory was sorted (and only when the resolver had it cached); subdirectories were processed in raw getdents/hash-map iteration order, which is filesystem-dependent.

Five files touched: Scanner.rs extracts the existing root-level sort into process_entries_sorted() and calls it for every directory in the BFS loop (both POSIX and Windows arms); fs.rs/lib.rs/resolver.rs drop the now-dead DirEntryIterator trait, its ()/&T impls, the !IS_VOID branch, the I generic on readdir/add_entry_with_store, and fold read_directory_with_iterator into read_directory; bun-test.test.ts adds a 17-file nested-directory test asserting exact BFS-plus-sorted execution order.

Security risks

None. This is file-discovery ordering in the test runner; no untrusted input parsing, no auth/crypto, no network.

Level of scrutiny

Moderate. The core change is a straightforward bugfix — the root-level sort already existed and expressed the intent; this extends it to subdirectories, which is what #26851's regression test already assumed. The resolver API deletions are mechanical dead-code cleanup that I requested in my previous review and are confined to plumbing whose only non-trivial consumer was the scanner itself. The one signature change (read_directory now returns &'static mut instead of an elided-to-&mut self lifetime) is a widening; every existing caller (grep confirms 6) is compatible.

Other factors

  • My prior review's only finding (dead DirEntryIterator plumbing) was fully addressed in 2454c7a; grep confirms zero remaining references.
  • All three CodeRabbit findings were addressed or withdrawn (tie-break added, comment trimmed, OOM propagation declined with a sound rationale).
  • The new test uses tempDir/bunEnv, drains all pipes concurrently, asserts stdout before exitCode, and has a load-bearing filename set documented with the htree-hash rationale. I hand-traced the BFS queue against the expected array and it matches exactly.
  • The removed has_iterated flag existed only to skip the manual root-entries pass when the readdir iterator had already fired; with the iterator gone, process_entries_sorted is now the sole caller of next() on every path, so there's no double-visit risk.
  • process_entries_sorted collects raw *mut Entry (EntryStore-backed, process-lifetime) before iterating, so self.next()'s call into entry.kind(real_fs, ...) cannot invalidate the pointers being walked.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

should the module resolver itself do the sorting? maybe we need a bool that's like "is_unsorted" and then a function to get the sorted dir entries list and then if is_unsorted is true we sort it first?

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

I kept the sort in the scanner because the scanner is (currently) the only thing that iterates a DirEntry's entries where order matters; the resolver itself does keyed get() lookups, not ordered walks. A couple of thoughts on the is_unsorted + cached-sorted-view idea:

  • The scanner visits each directory exactly once during its BFS, so the cache wouldn't save a second sort for this use case. The per-dir sort is over a Vec<*mut Entry> (8-byte elements, typically a few dozen entries), so it's cheap either way.
  • Caching the sorted list means an extra Vec/Box<[*mut Entry]> field on every DirEntry. The resolver caches a DirEntry for every directory it touches (node_modules subdirs, src dirs, etc.), so that's storage across the whole process for something only bun test discovery reads.

If the goal is to keep the "hash-map iteration order is unstable" detail out of the scanner, I'd lean toward a non-cached helper on DirEntry instead:

impl DirEntry {
    pub fn sorted_entries(&self) -> Vec<*mut Entry> {
        let mut v: Vec<*mut Entry> = self.data.values().copied().collect();
        v.sort_by(|a, b| unsafe {
            (**a).base_lowercase().cmp((**b).base_lowercase())
                .then_with(|| (**a).base().cmp((**b).base()))
        });
        v
    }
}

Then process_entries_sorted collapses to for e in entries.sorted_entries() { self.next(...) }. Same work, no extra per-dir storage, and if something else later wants deterministic iteration it's one call. Want me to switch to that, or go the full cached route?

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 2454c7a is green for this diff: the new scanner-order test passed on every lane, and the only hard-red job (:windows: 2019 x64 - test-bun) is test/regression/issue/36577.test.ts, which also fails on main and is unrelated to anything touched here. Everything else marked in the annotations is yellow (passed on retry or passed when run alone).

Holding for the sorted_entries() question above.

This branch has not been deployed

No deployments
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.

2 participants