Skip to content

fs.glob: answer maxDepth with a native depthFromRoot() - #47832

Draft
robobun wants to merge 12 commits into
robobun/9bce678e/fs-glob-max-depthfrom
robobun/cdfe2c19/fs-glob-plain-depth
Draft

robobun wants to merge 12 commits into
robobun/9bce678e/fs-glob-max-depthfrom
robobun/cdfe2c19/fs-glob-plain-depth

Conversation

@robobun

@robobun robobun commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #47750. It must merge after that PR (order: #47308, #47750, this).
Parked draft: nobody asked for it, and one engine test is red with it (State and Open items in Notes).

Problem

  • With maxDepth, fs.glob calls path.resolve() and path.relative() for every match and directory.
  • A **/*.txt walk of 121 directories costs 6.44M instructions, 4.08M with no maxDepth (0.4 ms more). The calls are in DepthLimiter.#relativeCandidate (src/js/internal/fs/glob-depth-limiter.ts:155).

Fix

Background

  • The depth of a path is its number of segments below cwd. DepthLimiter (Node v26.12.1's text) tests it against maxDepth.
  • Node's main branch answers this in a native walker (fs: implement glob natively nodejs/node#65392).
  • Considered a count in JS, the first form of this PR: up to 74% slower with no JIT.

Downsides

  • bun grows by 12,288 bytes and 2 host functions.
  • A path that the function cannot place pays one call first: outside cwd when cwd or a name is not ASCII (+0.6% to +2.4%), outside cwd on Windows (up to +1.4%), cwd in another case on macOS (+2.3% to +4.7%). The last two are emulated.
Notes

State. A parked draft, with no request for review. A self-review said not to queue it. The reasons:

It stays a draft until the reviewer of #47750 or a user report asks for faster walks with a limit. The question is on #47750. If the answer is yes, it comes back as one commit on main, measured again, with the open items below done. If the answer is no, close it: the branch robobun/cdfe2c19/fs-glob-plain-depth keeps the work.

The branch has main merged up to a4c3a5e, because CI does not build an older branch. That merge brings 11 of the 18 files in the diff. They are not part of this change.

What the function answers. depthFromRoot(path, root, foldsCase) has one body for path.posix and one for path.win32. root is what resolve() answered for cwd.

  • >= 0: the depth of the path. It is what pathDepth(relative(root, resolve(root, path))) counts in the limiter.
  • ~k: the path is the directory k levels above root. The limiter needs this for an absolute pattern, whose walk starts at the root of the volume.
  • The least 32-bit integer (UNPLACED): the characters do not settle the depth. The limiter then runs the line of fs.glob: add the maxDepth option, with the match limit in the result set #47750.

The arms, in the order of the code:

  • A path of names (a/b): the count of the names. This is PlainDepth() of Node's native walker.
  • An absolute path that resolve() would not change, and a path of .. segments and then names. join() writes a path that leaves cwd in this second form. The answer is relative() of the two with no string made, from the kernel that path.relative() has (relative_posix_latin1).
  • Any other ASCII path (., ./a, a//b, a/../b): the function joins it to root and resolves it in 512 bytes on the stack, as path.resolve() does for a short path. A longer one is UNPLACED. Each walk asks about . this way.
  • With foldsCase, which the limiter passes on macOS: a path beside root is UNPLACED when in lower case it is root, or above it, or below it. Node's text asks the file system about such a path on macOS.
  • Windows, and a 16-bit string on each platform: the count of names, for a path of names and for a path that starts with root as it is spelled. The function resolves nothing there. On Windows a : makes the path UNPLACED.
  • A native path function gives a 16-bit string for a name with a character outside ASCII. So a path outside cwd, or through .., with such a name is UNPLACED on each platform.

Four methods of the limiter call the function: includes(), includesNow(), canTraverse() and canTraverseAsync(). includesAsync() is the text of #47750. The walker calls it only for a path that includesNow() could not answer.

Why a path with : is not counted on Windows. With Node v26.12.1's own path.win32 (Bun's gives the same three answers):

  • resolve("C:\\root", "x:y") is a path on drive x:, so it is not below cwd.
  • resolve("C:\\root", "C:") is C:\root itself: depth 0, and the count would say 1.
  • relative() gives C:\a for .\C:\a below cwd, and pathDepth() takes that for an absolute path: no depth, and the count would say 2.

An absolute path that starts with cwd has its : in the part that is compared, not counted.

Measurements, Linux. linux-x64 release builds of #47750 (f3d2cd6) and of this PR (18d3e33), from one build directory. A tree has 121 directories and 484 files, and a copy of itself beside cwd. Names have 2 characters where no other length is named. A number is the user instructions of the main thread per walk (perf_event_open) after 300 walks, with maxDepth: 100, which cuts nothing, where no limit is named. It is the median of 5 rounds in which the builds alternate. "Twice" is a copy of the #47750 binary against the #47750 binary in the same rounds. "Spread" is the highest round less the lowest, over the median, for the wider of the two builds.

walk #47750 this PR change twice spread
below cwd, **/*.txt 6,436,362 4,416,105 -31.4% -0.0% 2.9%
below cwd, no match 3,915,797 3,537,210 -9.7% -0.3% 3.2%
below cwd, */*/*/*/f0.txt 2,695,882 2,285,592 -15.2% -0.0% 0.9%
below cwd, ** 6,577,240 4,381,231 -33.4% +0.4% 1.1%
below cwd, ["**/*.txt", "*/*/*"] 6,872,469 4,816,872 -29.9% +0.1% 3.6%
below cwd, a path with no wildcard 171,613 164,347 -4.2% +0.8% 4.2%
absolute 5,820,500 3,875,901 -33.4% +0.2% 1.0%
absolute, no match 3,760,262 3,280,371 -12.8% -0.1% 0.9%
absolute, */*/*/*/f0.txt 2,881,160 2,485,089 -13.7% -0.1% 0.3%
through .. 6,672,424 4,488,100 -32.7% -0.1% 0.8%
through .., no match 3,882,078 3,474,922 -10.5% +0.2% 0.5%
through .., */*/*/*/f0.txt 3,111,672 2,688,841 -13.6% -0.1% 0.9%
beside cwd, absolute 5,992,167 3,921,954 -34.5% +0.2% 0.6%
beside cwd, absolute, no match 3,802,513 3,289,129 -13.5% -0.3% 0.9%
beside cwd, absolute, */*/*/*/f0.txt 2,924,671 2,497,293 -14.6% +0.1% 0.5%
beside cwd, through .. 6,866,117 4,537,127 -33.9% +0.1% 0.4%
beside cwd, through .., no match 3,919,306 3,484,003 -11.1% -0.1% 1.1%
beside cwd, through .., */*/*/*/f0.txt 3,151,309 2,699,302 -14.3% -0.0% 0.5%
beside cwd, ../other/*/*.nomatch 300,762 287,591 -4.4% (overlap) +0.8% 12.1%
../* 109,414 103,718 -5.2% +0.2% 0.9%
./**/*.txt 6,757,727 4,769,496 -29.4% -0.0% 5.6%
names of 49 characters, below cwd, **/*.txt 6,919,116 4,892,201 -29.3% +0.3% 4.3%
names of 49 characters, below cwd, no match 4,197,756 3,825,223 -8.9% +0.2% 5.7%
names of 49 characters, below cwd, */*/*/*/f0.txt 3,044,140 2,616,102 -14.1% -0.2% 0.7%
names of 49 characters, absolute 6,397,942 4,477,248 -30.0% -0.0% 0.6%
names of 49 characters, absolute, no match 4,242,128 3,743,266 -11.8% +0.1% 0.7%
names of 49 characters, absolute, */*/*/*/f0.txt 3,211,206 2,808,558 -12.5% +0.2% 0.7%
names of 49 characters, through .. 7,410,805 5,216,042 -29.6% -0.1% 2.3%
names of 49 characters, through .., no match 4,406,505 3,979,820 -9.7% +0.0% 0.8%
names of 49 characters, through .., */*/*/*/f0.txt 3,512,370 3,063,094 -12.8% +0.1% 0.5%
names of 49 characters, beside cwd, absolute 6,634,270 4,528,238 -31.7% +0.1% 0.5%
names of 49 characters, beside cwd, absolute, no match 4,282,949 3,753,295 -12.4% +0.2% 1.1%
names of 49 characters, beside cwd, absolute, */*/*/*/f0.txt 3,261,017 2,814,070 -13.7% -0.0% 0.4%
names of 49 characters, beside cwd, through .. 7,694,339 5,274,373 -31.5% +0.1% 0.8%
names of 49 characters, beside cwd, through .., no match 4,436,282 4,000,300 -9.8% -0.3% 0.5%
names of 49 characters, beside cwd, through .., */*/*/*/f0.txt 3,547,485 3,068,924 -13.5% +0.3% 0.5%
names of 200 characters, below cwd, **/*.txt 45,116,484 33,952,978 -24.7% +0.4% 0.7%
names of 200 characters, below cwd, no match 26,494,502 24,663,644 -6.9% +0.0% 0.9%
names of 200 characters, absolute 25,838,230 23,759,219 -8.0% +0.1% 0.3%
names of 200 characters, through .. 45,732,743 34,537,712 -24.5% -0.0% 0.2%
names of 200 characters, beside cwd, absolute 26,353,117 23,774,738 -9.8% -0.0% 0.2%
names of 200 characters, beside cwd, through .. 46,327,327 34,572,934 -25.4% -0.0% 0.2%
names of 200 characters, beside cwd, through .., no match 26,912,742 24,965,727 -7.2% -0.0% 0.4%
names of 200 characters, ./**/*.txt 45,514,008 34,326,506 -24.6% -0.2% 0.7%
16-bit names, below cwd, **/*.txt 19,676,460 12,874,410 -34.6% -0.1% 2.7%
16-bit names, below cwd, no match 10,751,322 9,504,836 -11.6% +0.3% 3.1%
16-bit names, absolute 20,098,135 12,821,914 -36.2% -0.0% 0.2%
16-bit names, through .. 20,600,882 20,984,724 +1.9% -0.0% 0.2%
16-bit names, beside cwd, absolute 20,537,444 20,966,644 +2.1% -0.0% 0.3%
16-bit names, beside cwd, through .. 21,049,061 21,421,680 +1.8% -0.1% 0.3%
16-bit names, beside cwd, through .., no match 11,213,636 11,282,332 +0.6% +0.0% 4.5%
202 entries above cwd, ../* 2,061,226 1,542,195 -25.2% +0.0% 0.4%
202 entries above cwd, ../*.txt 2,095,118 1,580,526 -24.6% -0.1% 0.5%
202 entries above cwd, <parent>/* 1,838,506 1,432,377 -22.1% +0.1% 0.4%
202 entries above cwd, <parent>/*.txt 1,877,223 1,469,121 -21.7% -0.4% 0.6%
202 entries above cwd, ../*, maxDepth: 1 86,620 84,045 -3.0% +0.3% 1.4%
202 entries above cwd, ../*, maxDepth: 2 2,060,368 1,542,187 -25.1% +0.1% 0.4%
202 entries above cwd, <parent>/*, maxDepth: 0 1,718,881 1,283,629 -25.3% +0.4% 0.8%
202 entries above cwd, <parent>/*, maxDepth: 1 1,724,631 1,283,429 -25.6% -0.3% 0.7%
202 entries above cwd, <parent>/*.txt, maxDepth: 1 1,755,054 1,319,277 -24.8% +0.4% 0.7%
202 entries above cwd, <parent>/*.txt, maxDepth: 2 1,872,678 1,468,733 -21.6% +0.2% 0.9%
below cwd, **/*.txt, maxDepth: 2 373,734 295,965 -20.8% +0.2% 2.6%
absolute, maxDepth: 2 501,230 418,774 -16.5% +0.2% 0.8%
through .., maxDepth: 2 444,949 361,196 -18.8% +0.0% 4.0%
beside cwd, absolute, maxDepth: 4 519,010 424,114 -18.3% +0.1% 1.1%
beside cwd, through .., maxDepth: 4 461,214 366,976 -20.4% -0.2% 5.8%
names of 49 characters, below cwd, **/*.txt, maxDepth: 2 389,397 303,172 -22.1% -0.1% 2.5%
names of 49 characters, beside cwd, through .., maxDepth: 4 473,548 380,701 -19.6% +0.2% 3.5%
16-bit names, beside cwd, through .., maxDepth: 4 1,045,039 1,059,281 +1.4% -0.0% 0.2%
below cwd, **/*.txt, no maxDepth 4,077,473 4,106,637 +0.7% (overlap) +0.9% 3.6%
absolute, no maxDepth 3,557,147 3,566,300 +0.3% (overlap) +0.1% 0.6%
through .., no maxDepth 4,119,711 4,115,210 -0.1% (overlap) +0.0% 0.4%
beside cwd, through .., no maxDepth 4,123,605 4,116,308 -0.2% (overlap) -0.2% 0.5%
names of 49 characters, below cwd, **/*.txt, no maxDepth 4,505,653 4,525,204 +0.4% (overlap) +0.1% 5.2%
16-bit names, below cwd, **/*.txt, no maxDepth 12,104,501 12,114,915 +0.1% (overlap) +0.1% 2.7%
walk #47750 this PR change twice spread
async, below cwd, **/*.txt 9,252,551 7,175,511 -22.4% +0.5% 0.8%
async, below cwd, no match 5,133,581 4,729,245 -7.9% +0.3% 0.8%
async, absolute 8,903,911 6,961,456 -21.8% -0.0% 0.7%
async, through .. 9,722,449 7,578,116 -22.1% +0.3% 0.6%
async, through .., no match 5,430,920 4,993,815 -8.0% -0.1% 1.7%
async, beside cwd, absolute 9,058,595 7,008,083 -22.6% +0.1% 0.8%
async, beside cwd, through .. 9,948,385 7,628,233 -23.3% -0.2% 1.3%
async, beside cwd, through .., no match 5,475,635 5,026,583 -8.2% -0.5% 2.6%
async, below cwd, ["**/*.txt", "*/*/*"] 9,825,902 7,713,328 -21.5% +0.1% 0.6%
async, below cwd, **/*.txt, maxDepth: 2 512,220 435,853 -14.9% -0.7% 0.6%
async, beside cwd, through .., maxDepth: 4 609,482 523,814 -14.1% +0.5% 1.1%
async, below cwd, **/*.txt, no maxDepth 6,945,290 6,940,817 -0.1% (overlap) -0.2% 0.6%
async, names of 49 characters, below cwd, **/*.txt 9,912,425 7,810,528 -21.2% -0.0% 0.4%
async, names of 49 characters, absolute 9,523,952 7,567,187 -20.5% -0.1% 0.6%
async, names of 49 characters, through .. 10,542,664 8,306,164 -21.2% -0.1% 0.8%
async, names of 49 characters, beside cwd, through .. 10,830,738 8,351,424 -22.9% -0.1% 0.6%
async, 16-bit names, below cwd, **/*.txt 22,491,237 15,620,471 -30.5% -0.1% 0.3%
async, 16-bit names, beside cwd, through .. 23,900,876 24,301,321 +1.7% +0.0% 0.4%
  • 86 rows have a limit. 80 cost less, by 3.0% to 36.2%. The 6 that cost more, by 0.6% to 2.1%, have 16-bit names and a path outside cwd or through ..: the function does not place such a path.
  • A cwd with a character outside ASCII gives the same class, with ASCII names too. Measured with a cwd named josé: below cwd -46.3%, absolute below cwd -38.0%, through .. +2.4%, beside cwd by an absolute path +2.1%, beside cwd through .. +2.4%, and +0.8% with no match.
  • The 7 rows with no maxDepth are within -0.2% to +0.7%. The same binary twice is within -0.7% to +0.9% over all rows.
  • Three rows were measured again with 7 rounds: below cwd, no match, below cwd, a path with no wildcard and 16-bit names, below cwd. In their first run other work was on the machine, and the same binary twice differed by -2.2%, -1.5% and +1.6%.
  • The gain goes with the share of the entries that are matches: -31.4% when every file is a match, -9.7% when none is.
  • The saving is about 0.5 µs for each match and directory: 0.3 to 0.4 ms for the 605 of this tree.
  • The async rows are Linux, where the async walk calls the sync methods of the limiter.
  • Wall time of a sync walk in µs, the median and the range of 9 rounds, with other work on the machine:
walk #47750, µs this PR, µs change twice
below cwd 1,233 (1,102 to 1,417) 805 (742 to 1,038) -34.7% -1.2%
below cwd, no match 800 (740 to 884) 775 (664 to 822) -3.1% +3.5%
absolute 1,030 (949 to 1,268) 751 (688 to 831) -27.1% -2.3%
through .. 1,216 (1,076 to 1,321) 814 (715 to 970) -33.1% -2.3%
beside cwd, absolute 1,014 (837 to 1,285) 753 (716 to 902) -25.7% -0.4%
beside cwd, through .. 1,190 (953 to 1,323) 825 (780 to 939) -30.7% +0.1%
beside cwd, through .., no match 809 (685 to 927) 697 (656 to 769) -13.8% -5.5%
names of 49 characters, below cwd 1,786 (1,600 to 2,120) 1,206 (1,127 to 1,601) -32.5% +5.2%
names of 49 characters, beside cwd, through .. 1,891 (1,642 to 2,378) 1,356 (1,107 to 1,500) -28.3% +0.0%
16-bit names, below cwd 2,721 (2,034 to 2,972) 1,770 (1,477 to 1,946) -35.0% -2.3%
16-bit names, beside cwd, through .. 2,876 (2,290 to 3,379) 2,859 (2,676 to 3,055) -0.6% -7.0%
below cwd, no maxDepth 881 (735 to 985) 851 (767 to 1,087) -3.4% -4.4%
async, below cwd 5,373 (4,361 to 6,530) 4,601 (3,593 to 12,125) -14.4% -8.5%
  • bench/snippets/fs-glob-max-depth.mjs (mitata, the average of a sync walk in µs, two runs each, with other work on the machine):
globSync, names of 2 characters #47750 this PR Node v26.12.1
below cwd, maxDepth: 100 1,160 and 1,330 884 and 934 2,790
below cwd, no match 765 and 828 726 and 819 1,520
below cwd, a limit that cuts 54 and 59 44 and 45 116
below cwd, no maxDepth 828 and 937 927 and 933 1,640
absolute, maxDepth: 100 1,090 and 1,290 897 and 843 3,000
absolute, no match 832 and 930 798 and 799 1,750
absolute, a limit that cuts 69 and 83 63 and 62 163
absolute, no maxDepth 818 and 908 855 and 855 1,640
through .., maxDepth: 100 1,280 and 1,370 1,010 and 921 2,980
through .., no match 872 and 946 831 and 806 1,570
through .., a limit that cuts 68 and 72 59 and 55 140
through .., no maxDepth 912 and 985 968 and 916 1,730
beside cwd, absolute, maxDepth: 100 1,070 and 1,220 910 and 913 3,120
beside cwd, absolute, no match 831 and 939 852 and 851 1,870
beside cwd, absolute, a limit that cuts 74 and 78 65 and 66 182
beside cwd, absolute, no maxDepth 843 and 851 860 and 854 1,720
beside cwd, through .., maxDepth: 100 1,280 and 1,280 1,000 and 933 3,200
beside cwd, through .., no match 817 and 950 879 and 858 1,740
beside cwd, through .., a limit that cuts 64 and 62 55 and 56 202
beside cwd, through .., no maxDepth 962 and 981 964 and 924 1,890

Where no JIT runs.

  • BUN_JSC_useJIT=0, user instructions of the whole process per walk (walks 4 to 13, a counter around the process). Of 35 kinds of walk with a limit, 34 cost 3.1% to 22.8% less. Below cwd: 22.39M to 18.86M (-15.8%). Names of 200 characters: -20.0% to -21.1%. The one that costs more has 16-bit names beside cwd: +1.3%. No maxDepth: -0.1%.
  • An AOT executable (bun build --compile --aot), main-thread instructions per walk (walks 4 to 23). "From a file on disk" is a compiled host that loads the walking script at run time (--aot-allow-code-from-disk). "Compiled" is the walking script itself compiled. Of 17 kinds with a limit, 16 cost 2% to 31% less in both shapes. 16-bit names beside cwd: +1.9% and +2.0%.
walk from a file on disk: #47750 this PR compiled: #47750 this PR
below cwd 7,357,298 5,777,534 (-21.5%) 4,974,711 4,297,439 (-13.6%)
below cwd, no match 4,986,070 4,709,181 (-5.6%) 3,234,909 3,091,278 (-4.4%)
below cwd, */*/*/*/f0.txt 3,338,620 3,048,088 (-8.7%) 2,259,644 2,126,159 (-5.9%)
absolute 7,232,428 5,704,509 (-21.1%) 4,589,401 4,065,128 (-11.4%)
through .. 7,786,792 6,150,396 (-21.0%) 5,407,783 4,660,783 (-13.8%)
through .., no match 5,231,805 4,938,275 (-5.6%) 3,471,823 3,310,231 (-4.7%)
beside cwd, absolute 7,437,879 5,748,792 (-22.7%) 4,591,039 4,108,968 (-10.5%)
beside cwd, through .. 7,992,391 6,188,510 (-22.6%) 5,398,541 4,697,820 (-13.0%)
beside cwd, through .., no match 5,272,677 4,948,091 (-6.2%) 3,465,496 3,319,995 (-4.2%)
../* 186,680 182,208 (-2.4%) 66,879 64,020 (-4.3%)
names of 49 characters, below cwd 8,127,024 6,548,971 (-19.4%) 6,817,046 5,074,340 (-25.6%)
names of 49 characters, absolute 7,909,352 6,440,137 (-18.6%) 6,350,809 4,796,591 (-24.5%)
names of 49 characters, through .. 8,722,945 6,998,645 (-19.8%) 7,370,932 5,508,496 (-25.3%)
names of 49 characters, beside cwd, through .. 9,014,871 7,041,746 (-21.9%) 7,661,824 5,560,518 (-27.4%)
16-bit names, below cwd 21,306,036 14,606,856 (-31.4%) 18,533,182 12,865,041 (-30.6%)
16-bit names, beside cwd, through .. 22,698,494 23,124,374 (+1.9%) 19,664,826 20,067,850 (+2.0%)
async, below cwd 9,898,480 8,304,485 (-16.1%) 6,182,708 5,410,891 (-12.5%)
below cwd, no maxDepth 5,414,608 5,414,037 (-0.0%) 3,984,131 3,984,644 (+0.0%)
  • The first walks of a process, all threads, from the first one (a counter around the process, 5 to 9 runs):
walks 1 4 8 40 twice
below cwd -8.1% -10.4% -12.3% -20.6% +0.2% to +0.8%
absolute -12.3% -15.5% -14.6% -20.7% -0.3% to +0.0%
through .. -9.2% -11.0% -12.5% -19.5% -0.1% to +9.1%
beside cwd, absolute -12.3% -14.7% -15.0% -20.8% +0.3% to +0.5%
beside cwd, through .. -9.0% -11.3% -13.0% -18.5% -0.5% to +0.4%
beside cwd, through .., no match -1.2% -3.8% -5.1% -3.9% -0.3% to +0.2%
names of 49 characters, below cwd -8.0% -10.3% -12.8% -19.7% -0.1% to +0.5%
names of 49 characters, absolute -12.2% -14.6% -15.0% -19.3% +0.0% to +0.3%
names of 49 characters, through .. -7.8% -10.4% -11.8% -26.6% +0.2% to +0.5%
names of 49 characters, beside cwd, absolute -12.1% -15.3% -15.3% -20.2% -0.0% to +0.5%
names of 49 characters, beside cwd, through .. -8.7% -15.5% -12.5% -27.1% -0.2% to +1.3%
names of 49 characters, beside cwd, through .., no match -2.4% -3.0% -4.4% -6.0% +0.1% to +0.7%
16-bit names, below cwd -13.5% -19.2% -22.1% -28.2% -0.1% to +0.2%
16-bit names, beside cwd, through .. +2.0% +2.9% +1.6% -2.0% -0.2% to +1.4%
async, below cwd -8.7% -6.5% -8.8% -13.7% -2.0% to +0.4%
below cwd, no maxDepth +0.0% -0.1% +0.3% -0.3% -0.4% to +0.7%

One question. Instructions for one call of the function, and for the route of #47750 for the same path, with the cost of the loop taken off. That route is resolve(), relative(), isAbsolute() and split(). The path has three directory names and a file name of the length given.

8-bit names:

path 2 48 200 #47750: 2 48 200 no JIT: 2 48 #47750, no JIT: 2 48
names below cwd 243 408 1,033 2,927 2,995 17,166 152 319 3,836 3,943
absolute, below cwd 407 580 1,207 2,601 2,653 3,340 318 494 3,518 3,602
through .., below cwd 471 664 1,290 3,259 3,352 17,483 384 577 4,167 4,286
absolute, beside cwd 472 644 1,270 2,900 2,919 3,815 384 557 3,895 3,949
through .., beside cwd 550 724 1,351 3,529 3,624 17,991 464 637 4,542 4,669
./ and names 791 1,002 482 (unplaced) 3,201 3,313 17,223 704 916 4,112 4,240
a directory above cwd 401 1,926 313 2,871

16-bit names:

path 2 48 200 #47750: 2 48 200 no JIT: 2 48 #47750, no JIT: 2 48
names below cwd 1,063 1,072 1,291 9,531 16,365 36,917 977 985 10,472 17,767
absolute, below cwd 1,171 1,180 1,400 9,350 15,908 36,450 1,085 1,093 10,279 17,317
through .., below cwd 644 (unplaced) 638 (unplaced) 638 (unplaced) 9,966 16,647 37,228 558 (unplaced) 553 (unplaced) 10,903 18,061
absolute, beside cwd 573 (unplaced) 573 (unplaced) 573 (unplaced) 10,012 16,511 37,107 487 (unplaced) 487 (unplaced) 11,039 18,009
through .., beside cwd 644 (unplaced) 639 (unplaced) 639 (unplaced) 10,632 17,248 37,880 558 (unplaced) 553 (unplaced) 11,651 18,774
./ and names 1,210 1,214 1,434 9,610 16,404 36,878 1,124 1,127 10,569 17,839
  • A path of 200-character names through ./ is over 512 bytes when it is joined, so the function does not place it. The walker does not write such a path: it drops the ./ of a pattern.
  • A 16-bit path goes through a cold function that resolves a rope and reads the string. That is why a placed 16-bit path costs more than an 8-bit one.

Calls and memory, per **/*.txt walk of the tree with maxDepth: 100:

per walk #47750 this PR no maxDepth
path.relative(), below cwd 605 0 0
path.relative(), absolute 731 0 0
path.relative(), through .. 606 0 0
path.relative(), beside cwd, absolute 731 0
path.relative(), beside cwd, through .. 606 0 0
path.resolve(), below cwd 1,331 726 725
path.resolve(), absolute 1,469 738 737
path.resolve(), through .. 1,336 730 729
path.resolve(), beside cwd, absolute 1,469 738
path.resolve(), beside cwd, through .. 1,336 730 729
path.isAbsolute(), below cwd 725 121 0
path.isAbsolute(), absolute 855 126 0
path.isAbsolute(), through .. 727 122 0
path.isAbsolute(), beside cwd, absolute 857 126
path.isAbsolute(), beside cwd, through .. 728 122 0
path.relative(), 16-bit names, below cwd 605 0
path.relative(), 16-bit names, absolute 731 0
path.relative(), 16-bit names, through .. 606 604
path.relative(), 16-bit names, beside cwd, absolute 731 724
path.relative(), 16-bit names, beside cwd, through .. 606 604
  • path.join(): 1,208 to 1,223 calls per walk on both builds, with and without maxDepth. With no maxDepth every count is equal on both builds.
  • Live JS heap at the last match of an async ** walk of 100,110 entries, after a full collection: below cwd 46.2 MB on fs.glob: add the maxDepth option, with the match limit in the result set #47750, 32.0 MB here and 32.0 MB with no maxDepth. Absolute: 44.0 and 34.4 MB. Through ..: 47.5 and 32.9 MB. fs.glob: add the maxDepth option, with the match limit in the result set #47750 keeps one entry of #relativeCache per path until the walk ends, as Node's text does. An unplaced path keeps its entry here too. The heap is a side effect, not the reason for the change.
  • JS heap allocated per walk (BUN_JSC_logGC, 2,000 walks): below cwd 545.2 KB to 392.1 KB, absolute 539.7 to 393.7, through .. 546.8 to 396.5. No maxDepth: 388 and 390 KB.
  • System calls of the main thread per sync walk, walks 22 to 41. strace is not in this container, so a ptrace counter gave them. getdents64 242, openat and close 121 each, statx 1 to 7 by the spelling, on both builds. The collector reads /proc/self/statm at each collection: 0.4 to 0.55 times per walk on fs.glob: add the maxDepth option, with the match limit in the result set #47750 and 0.3 to 0.35 here.
  • A walk with no maxDepth loads 0 more modules on both builds. The first walk with a limit loads 1, the limiter.
  • Binary: .text +9,728 bytes (110,632,857 to 110,642,585), .bun_builtins +944 (2,803,207 to 2,804,151), the file +12,288. internal/fs/glob-depth-limiter.js goes from 6,200 to 6,905 bytes. internal/fs/glob.js is identical (cmp, 83,697 bytes).
  • The 9,728 bytes by symbol: the POSIX body 2,495, its function for a path to put together 1,546, the Windows body 844. The two cold functions for a rope or a 16-bit string have 1,828 and 1,962. The glue of the two host functions has 820.
  • With the link by feature that CI uses (--trimmable=on), bun maps 16,496 bytes more. A --compile --aot hello maps 8,304 more, and a --compile --trim hello 20,592 more. With --compile --aot --trim the sizes move by pages, downward in this pair of builds: hello 25,964,935 to 25,956,855 bytes, a program that calls path.relative() 26,091,911 to 26,087,927, and one that calls fs.globSync() with maxDepth 29,860,231 to 29,848,055.

Windows and macOS (emulated). The texts of the walker and of the limiter for win32 and darwin run as user code on Linux, over a tree in memory. They use path.win32 or path.posix and the native function of this build. The table has the change in the instructions per walk against the text of #47750, with maxDepth: 100 and names of 2 and of 49 characters:

walk win32, 2 win32, 49 darwin, 2 darwin, 49
below cwd, **/*.txt -66.6% -57.6% -44.1% -37.7%
below cwd, no match -36.9% -26.9% -16.6% -13.0%
below cwd, */*/*/*/f0.txt -44.1% -13.1% -21.1% -5.3%
absolute -67.7% -59.5% -46.3% -41.9%
absolute, no match -43.5% -35.2% -23.2% -25.1%
through .. +0.9% +0.6% -40.5% -35.5%
through .., no match +0.6% +0.4% (overlap) -16.1% -12.5%
through .., */*/*/*/f0.txt +0.8% +0.0% (overlap) -18.3% -4.8%
beside cwd, absolute +0.8% +0.4% -50.9% -57.4%
beside cwd, absolute, no match +0.5% +0.4% (overlap) -25.9% -31.9%
beside cwd, through .. +1.4% +0.9% -47.6% -52.7%
beside cwd, through .., no match +0.7% +0.4% -18.9% -21.2%
beside cwd, through .., */*/*/*/f0.txt +0.7% (overlap) +0.2% (overlap) -23.3% -8.6%
../* -0.5% (overlap) -1.6% (overlap) -1.6% (overlap) -5.0% (overlap)
cwd in another case, absolute +0.6% (overlap) +0.4% +4.7% +3.6%
cwd in another case, through .. +0.8% +0.5% +3.9% +3.1%
async, below cwd, **/*.txt -62.1% -55.7% -30.4% -27.1%
async, absolute -63.3% -57.5% -32.1% -30.6%
async, through .. +0.5% +0.3% -29.5% -26.0%
async, through .., no match +0.4% +0.3% (overlap) -12.9% -10.7%
async, beside cwd, absolute +0.5% +0.4% (overlap) -36.5% -44.8%
async, beside cwd, through .. +1.1% +0.7% -34.4% -41.5%
async, beside cwd, through .., no match +0.4% +0.4% (overlap) -14.9% -17.1%
async, cwd in another case, absolute +0.3% (overlap) +0.3% +3.1% +2.3%
  • This is not a run on Windows or macOS. The path.win32 here is the one of a Linux build, whose scratch buffer has 32,768 elements against 98,303 on Windows, and the tree is in memory. So the size of the win32 gain is not a prediction for Windows.
  • win32: names below cwd and an absolute path below cwd as it is spelled cost 13.1% to 67.7% less. Every other kind pays the one call: +0.3% to +1.4% where the rounds do not overlap.
  • darwin: every kind costs 4.8% to 57.4% less, but a pattern that spells cwd in another case: +2.3% to +4.7%. There the function compares the two paths in lower case before it says UNPLACED.
  • The names and cwd of these trees are ASCII. With a cwd or a name outside ASCII, a path outside cwd or through .. is UNPLACED on each platform.
  • On the win32 text no match of an async walk below cwd waits for includesAsync(). On fs.glob: add the maxDepth option, with the match limit in the result set #47750 all 484 wait. The walk takes 3,635 microtask turns, against 5,329. By an absolute path below cwd: 3,682 against 5,497. Through .. it is as on fs.glob: add the maxDepth option, with the match limit in the result set #47750: 484 matches wait, and 5,342 turns.
  • On the darwin text the async walks take 3,635 to 3,667 turns, against 3,756 to 3,912. The lists and the fs calls are the same.

Equal to #47750's text.

  • The function against resolve() and relative() of path.posix and path.win32, and against a JS text of the same arms: 0 differences in 5,000,000 random cases per platform (ASCII, Latin-1, 16-bit, a rope and a substring of each). An answer is equal or UNPLACED.
  • The limiter alone against the text of fs.glob: add the maxDepth option, with the match limit in the result set #47750, with every fs call that it makes and the question at which it makes it: 0 differences in 2,226,040, 2,226,040 and 6,196,949 rows (linux, darwin, win32 texts). A row is one question, or a directory and then names in it (sync, async and mixed), or 400 questions in a random order.
  • The walker and the limiter against the texts of fs.glob: add the maxDepth option, with the match limit in the result set #47750, over a file system in memory with path.win32 and path.posix: 0 differences in 142,884, 142,884 and 158,436 walks (linux, darwin, win32). The matches, their order, the order of the fs calls and the calls made when each async match arrived are compared.
  • Linux, release builds of both, a real tree: 142,884 walks over 77 patterns, 8 option sets, 9 forms of cwd, 6 depths and 3 kinds of consumer. The same four things are identical.
  • The win32 and darwin runs are emulations on Linux. On Windows and macOS themselves, CI ran the tests of this form, and they pass: see Tests.

Mutants.

  • The JS arms: 117 mutants of the lines that this PR adds to the limiter, each with one edit by a fixed operator. Each ran against the real test file on Linux. On the darwin and win32 texts each ran against ports of the count tests and of the limiter test. 15, 10 and 36 pass, and none of these changes an answer on its platform. They are of four kinds:
    • a constant of another platform,
    • depth <= 0 or depth < 1 for depth < 0, which give the same answer,
    • the ~k arms on Windows, where the function gives no ~k,
    • on Linux, the two async methods that the Linux walker does not call.
  • The function: one build has 22 one-line mutants of the Rust, chosen by an environment variable. All 22 fail 1 to 34 tests. 19 of them change an answer in 200,000 random cases per platform. The other 3 change only what a path over 512 bytes gets.
  • A JS text of the same arms in place of the native function, in the limiter and in the tests: 53 hand mutants. 50 fail 1 to 55 tests. The 3 that pass change no answer: two are equal forms, and one is a property of the JS text only.
  • The two rows of takes a name in another case for cwd with a wildcard at the limit come from this: two mutants that passed the darwin ports before.

Tests.

  • Three tests count the calls of path.relative(): asks path.relative() only about a path that its characters do not place, the 27 rows of the depth of a name below cwd (18 on Windows), and takes a name in another case for cwd only where the file system says that it is cwd. Each runs its walks in a child process that replaces path.relative before the first walk with maxDepth. Only the limiter calls path.relative(), and its module loads at that first walk, so the count sees every call. A row gives the list of Node v26.12.1 and what the limiter asked about: no path, no path below cwd, or a path below cwd. Two rows with a 16-bit name outside cwd must ask on every platform. They fail if the count stops seeing the limiter.
  • depthFromRoot() calls both bodies of the function on every platform, through an export of bun:internal-for-testing. One test compares 11,715 generated cases with resolve() and relative() of path.posix and path.win32. An answer must be equal or UNPLACED, and it must not be UNPLACED where the function has a rule. 45 rows name one rule each. One test has the paths around 512 characters.
  • gives the depth that path.resolve() and path.relative() give asks the limiter itself, through a getter in exposedInternals. It asks the five methods about more than 800 paths and about names below each. The limit is the depth of the path, one less, and one more.
  • The test file imports bun:internal-for-testing as a namespace. On a build without the export the file loads, and the 77 tests fail.
  • On the debug build the four slowest of them take 0.9 s to 1.7 s each.
  • CI ran glob.test.ts with this form on 11 lanes, with 0 fail on each. Windows 2019 x64 and Windows 11 aarch64: 203 pass. macOS x64 and aarch64: 213 pass. debian 13, ubuntu 25.04 and alpine 3.23, each on x64 and aarch64: 196 pass. debian 13 x64 with ASAN: 195 pass. Node's test-fs-glob.mjs is not among the failures of any lane. This is the first run of this form on Windows and macOS.
  • Also run on the release build of this branch: test/js/node/path/ (170 pass), Node's 16 test-path-*.js files, test/internal/builtins-table/ and test/internal/source-lints/.

Open items, for the day this is wanted. The last self-review left these. None is done, because each needs another round of code and nobody has asked for the change.

  • One engine test is red in CI with this form: test/aot/engine/ir-6.test.ts, for valueflow/modules/create-require-read-late-on-a-copy-of-node-module-returns-a-require-function-with-flow-rules-delayed.ir. It is red on five lanes: debian 13 x64, aarch64 and x64 with ASAN, Windows 2019 x64 and Windows 11 aarch64. It passes on main at the commit that this branch has merged (a4c3a5e). This change adds two host functions to the table of built-ins, and the ids of the entries after them move. With the new table, and with testing=DelayFlowRules, the engine gives the effect of this program without its site. The defect is in the engine, not in the table. With the table of fs.glob: add the maxDepth option, with the match limit in the result set #47750 (a release build), testaot run of the same program alone with --option=testing=DelayFlowRules:59 fails too, and so do :76, :175, :190 and :199. With this branch's table (a debug build) 13 of the 256 orders fail, and one of them is order 0, the order of the fixture. This needs a cure in the engine before the change can merge.
  • No row may cost more than the base. On POSIX the depth is a function of the two strings alone, so the function can place every path there when foldsCase is off. It needs a form of the two arms with no buffer that reads 16-bit strings (about 60 to 90 lines). The 6 slower rows and the rows under a cwd outside ASCII then go.
  • The arm that joins and resolves in 512 bytes answers one path that the walker writes: .. The count of names gives 0 for it. The review found no other such path in 14,976 walks, so the arm can go, with its 1,546 bytes.
  • No test in the tree gives the function a 16-bit root, which is what a cwd outside ASCII is. The case rule of macOS is pinned for 8-bit ASCII only. Rows with such roots in the table of depthFromRoot() close both.
  • 7 of the new tests expect "no path" for a walk that leaves cwd. They fail when the temporary directory has a character outside ASCII.
  • The test of the generated cases cannot tell which arm answered. Six one-line mutants of the two arms with no buffer pass: they send a path through the buffer, with the same answer.
  • The rules are 184 lines that take only slices. In src/node_path a cargo test and Miri can run them. In src/runtime/node/path.rs they cannot.
  • There is no wall time at scale. The saving is per match and per directory, so it shows from about 10,000 of them. The bench has 605.
  • A measurement on a Windows machine and on a Mac. The tests pass there in CI, but the win32 and darwin numbers here are an emulation.
  • Separate from this change: resolved_latin1() (src/jsc/bindgen.rs) makes a view of a flat 16-bit string that it then drops, and path.win32.resolve() fills a stack buffer with zeros on each call. Both are on main.

Not in this PR.

  • On Windows, a path outside cwd or through .. keeps the route of Node's text. The function resolves no Windows path: that needs the drive, UNC and case rules of path.win32, and nothing here runs Windows.
  • A 16-bit path outside cwd or through .. keeps that route on every platform: see the first open item.
  • On Windows and macOS the async walk still awaits canTraverseAsync() at each directory. That is in glob.ts.
  • Pattern.isAbsolute (glob.ts) joins two strings and calls isAbsolute() at each directory test.
  • The walker calls resolve() 725 times and join() 1,208 times per walk, with or without maxDepth. node:fs glob: read directories ahead in the async walk, less work per entry #42963 is about those calls.

The first form of this PR. It counted the names of a path in JS with a $charCodeAt loop. It also remembered the directory that the walk may read. A review found three things:

A second design review picked the native function. The endsWith private name of the first form is gone.

Credit. The shape is that of DepthFromRoot() and IsAncestorOfRoot() in Node's native walker (nodejs/node#65392, src/glob/glob_walker.cc). The rows of odd names in the tests come from an earlier form of #47750 (7898b35).


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/fs/glob.test.ts, test/cli/run/run-crash-handler.test.ts, test/bundler/bundler_compile_prelinked.test.ts

robobun and others added 6 commits November 2, 2026 08:47
…46969)

### Problem

- `mordant-baseline.toml` accepts more findings than main has for
fourteen (lint, file) lines. The `mordant` job accepts findings up to
the baseline count. A new finding in one of those files then passes with
no report.
- Commits on main removed 21 findings in nine crates. The counts did not
follow.

### Fix

- Delete ten lines and lower four counts to what `bun run
rust:mordant:baseline` records on main at 271b65e. The baseline goes
from 494 findings to 473. The Notes list each line.
- Six more stale lines are left out. #46244 removes five of them. #46528
rewrites the sixth, `unused_pub:src/sys/lib.rs` (55, main has 53).
- Verified: `bun run rust:mordant` over the three CI targets exits 0
with this baseline. No source file changes.

### Background

- mordant is the lint pack that the `mordant` job in
`.github/workflows/rust-lints.yml` runs through
`scripts/rust-mordant.ts`. `mordant-baseline.toml` holds, for each
crate, the count of findings that the job accepts for each lint and
file.
- The job fails only when a file has more findings than its count. It
reports nothing when a file has fewer, so a count stays too high until
someone lowers it.
- A count is the largest one that any of the three targets (Linux,
Windows, macOS) records. The `unused_pub` counts come from one run over
all three targets together.

<details><summary>Notes</summary>

| Line | Before | After |
| --- | --- | --- |
| `bound_checked_only_in_debug:src/css/properties/custom.rs` | 2 |
deleted |
| `unused_pub:src/glob/lib.rs` (the only line of `[bun_glob]`) | 1 |
deleted |
| `generic_body_not_generic:src/js_parser/visit/visit_expr.rs` | 1 |
deleted |
| `unused_pub:src/jsc/host_fn.rs` | 2 | deleted |
| `unused_pub:src/jsc/lib.rs` | 1 | deleted |
| `generic_body_not_generic:src/resolver/fs.rs` | 1 | deleted |
| `field_valid_only_when:src/runtime/shell/subproc.rs` | 1 | deleted |
| `field_valid_only_when:src/runtime/timer/mod.rs` | 2 | deleted |
| `generic_body_not_generic:src/runtime/webcore/Blob.rs` | 2 | deleted |
| `unused_pub:src/windows_sys/externs.rs` (the only line of
`[bun_windows_sys]`) | 1 | deleted |
| `safe_fn_returns_shared_mut:src/bundler/transpiler.rs` | 2 | 1 |
| `safe_fn_returns_shared_mut:src/http/HTTPThread.rs` | 3 | 2 |
| `generic_body_not_generic:src/jsc/ConsoleObject.rs` | 6 | 5 |
| `generic_body_not_generic:src/runtime/socket/socket_body.rs` | 8 | 4 |

Main has 197 lines, 494 findings and 38 sections. This branch has 187
lines, 473 findings and 36 sections.

How the counts were measured, with mordant at the pinned revision
564c8da5 and `nightly-2026-09-01`:

- `bun run rust:mordant:baseline` on main at 271b65e writes a
baseline of 184 lines and 464 findings. It differs from the committed
one in these fourteen lines, in the five lines of #46244, and in
`unused_pub:src/sys/lib.rs` (55 to 53).
- `bun run rust:mordant` with the baseline of this branch exits 0 and
writes no `target/mordant/over-baseline.txt`. It is one `cargo mordant
--workspace --keep-going` run over `x86_64-unknown-linux-gnu`,
`x86_64-pc-windows-msvc` and `aarch64-apple-darwin`.

Two lines with a known cause:

- `[bun_glob]`: #47559 added the line on 2026-10-31, because
`bun_glob::walk_bun_glob` (#47513) had no caller. After that, #47316 put
`#[cfg_attr(mordant, allow(unused_pub))]` on the function, and #44833
added a caller in `src/js_parser/lower/import_meta_glob.rs`.
- `custom.rs`: #47025 removed the two `debug_assert!(*d <= 0x7F)` checks
that the lint reported.

Changes from the first version of this PR (nine lines, measured at
449877f):

- `generic_body_not_generic:src/runtime/jsc_hooks.rs` stays at 1. The
baseline that main regenerates has that line again.
- #47238 deleted the `IOReader.rs` and `IOWriter.rs` lines on main, so
this PR does not change them.
- Eight lines are new: `custom.rs`, `resolver/fs.rs`, `timer/mod.rs`,
`Blob.rs`, `[bun_glob]`, `[bun_windows_sys]`, `transpiler.rs` and
`HTTPThread.rs`.

</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · no src or test change; test-proof not
applicable

<!-- robobun:evidence:end -->
### Problem
- `mordant-baseline.toml` accepts more findings than `main` has for five
`(lint, file)` keys. The `mordant` job tolerates an over-count, so a new
finding of the same lint in one of these files passes unseen until the
count is used up.
- Four of these files changed after their count was recorded, and `main`
now has fewer findings in them. The fifth, `WebSocketUpgradeClient.rs`,
has not changed since #45223 set its count to 2, and mordant reports one
finding there. Nothing regenerated the baseline.

### Fix
- Lower each count to the number of findings that `main` has:
-
`"generic_body_not_generic:src/http_jsc/websocket_client/WebSocketUpgradeClient.rs"`:
2 to 1
- `"generic_body_not_generic:src/sql_jsc/mysql/JSMySQLConnection.rs" =
1`: removed
- `"generic_body_not_generic:src/sql_jsc/mysql/MySQLConnection.rs"`: 4
to 1
-
`"generic_body_not_generic:src/sql_jsc/mysql/protocol/DecodeBinaryValue.rs"
= 1`: removed
- `"options_as_enum:src/lint/driver/lint.rs" = 1`: removed, with the
`[bun_lint_driver]` header of its now empty section
- The baseline goes from 198 lines and 495 accepted findings to 195
lines and 488. No code changes.
- Verified: `bun run rust:mordant` exits 0 with the new file (three
targets, pinned `MORDANT_REV`).

### Background
- `mordant-baseline.toml` is a ratchet. Each line is `"<lint>:<file>" =
<count>`. The `mordant` job hides that many findings of the lint in the
file and fails only when the file has more.
- A count is the largest number that any one of the three targets
reports. `unused_pub` is the exception: one run over all three targets
counts it (`scripts/rust-mordant.ts`).
- No other line is stale: runs with an empty baseline matched every
other count, `unused_pub` included.

<details><summary>Notes</summary>

Findings per target for the five keys:

| key | old count | Linux | Windows | macOS |
| --- | --- | --- | --- | --- |
|
`generic_body_not_generic:src/http_jsc/websocket_client/WebSocketUpgradeClient.rs`
| 2 | 1 | 1 | 1 |
| `generic_body_not_generic:src/sql_jsc/mysql/JSMySQLConnection.rs` | 1
| 0 | 0 | 0 |
| `generic_body_not_generic:src/sql_jsc/mysql/MySQLConnection.rs` | 4 |
1 | 1 | 1 |
|
`generic_body_not_generic:src/sql_jsc/mysql/protocol/DecodeBinaryValue.rs`
| 1 | 0 | 0 | 0 |
| `options_as_enum:src/lint/driver/lint.rs` | 1 | 0 | 0 | 0 |

How the counts were taken: `cargo +nightly-2026-09-01 mordant
--workspace --target <triple> --keep-going` for each of
`x86_64-unknown-linux-gnu`, `x86_64-pc-windows-msvc` and
`aarch64-apple-darwin`, with `MORDANT_TOML` set to the text of
`mordant.toml` with its `baseline` key pointed at an empty file, so that
every finding prints. Then one run with all three `--target` flags for
`unused_pub` (160 findings in 22 files, the same as the baseline).

The finding that remains in `MySQLConnection.rs` is
`MySQLConnection::process_split_packet` (generic over `C`). The finding
that remains in `WebSocketUpgradeClient.rs` is
`HTTPClient::<SSL>::connect`.

When each count was recorded:

- `MySQLConnection.rs`, `JSMySQLConnection.rs` and
`DecodeBinaryValue.rs`: in #43669. #45304 changed all three files after
that.
- `lint/driver/lint.rs`: in #45478. #45800 and #46001 changed the file
after that.
- `WebSocketUpgradeClient.rs`: in #45223, which is also the last change
to that file. #45720 later changed two other files of the same crate
(`websocket_client.rs` and `WebSocketProxyTunnel.rs`).

</details>
)

Lint-ratchet maintenance, no reachable defect: `main` already refuses a
graph for a module with more than 65535 requests. #44968 left out #39165
and #40312 of this category ("refactor nothing needs"). Merged #39169
has this shape. @alii decides whether this is wanted.

### Problem
- `serialize()` in `src/bundler/prelinked_module_graph.rs` packs a `u32`
request index into 16 bits of each import and export entry, bounded
there only by a `debug_assert!`.
- The release bound is one function up, in `build()`, on the request
count. mordant reports `bound_checked_only_in_debug`.

### Fix
- `parse_module` converts the request count once
(`u16::try_from(count).ok()?`) and takes every index from
`0..request_count`, as a `u16`. The const, the count test and the
assertion are gone.
- Removes
`"bound_checked_only_in_debug:src/bundler/prelinked_module_graph.rs" =
1` from `mordant-baseline.toml`.
- Verified: `bun run rust:mordant` (564c8da, 3 targets) exits 0. Empty
baseline: 0 findings in the file (`main`: 1).
`test/bundler/bundler_compile_prelinked.test.ts`: 83 pass (debug build).
Same bytes as `main` on 4033 graphs in a model of the file.
- Self-reviewed: 9 concerns raised, 9 addressed.

### Background
- `bun build --compile --bytecode` embeds a pre-resolved module graph
for JavaScriptCore. A request is one module that a chunk imports.
`PrelinkedModuleGraph::tryCreate` refuses more than 65535 per module.
- Considered an `assert!` at the shift: it costs a compare and a branch
per entry.

### Downsides
- Two new test cases build executables with 65535 and 65536 imports: 1
to 3 s each on release, 13 to 35 s on debug with ASAN, no CI timing yet.
- They pass on `main` too, so they hold the limit and prove no fix. The
65536 case builds the `module_info` fallback executable and does not run
it.

Behaviour change: none

<details><summary>Notes</summary>

**Why nothing observable changes**

- On `main`, `build()` returns `None` when `module.requests.len() >
MAX_REQUESTS_PER_MODULE` (65535), and the only call of `serialize()`
comes after that test. So the shift never sees an index above 65534 in
any build.
- The limit was stated twice. The const and its count test are one
statement. The `debug_assert!` on the index is the second, with a `u32`
between them, and it is a different inequality (it allows index 65535).
- mordant reports the shift because the lint looks at one function, and
the release bound is in another one.

**Measurements**

- mordant (564c8da, 3 targets): `bun run rust:mordant` exits 0 with the
baseline line deleted. Findings in `prelinked_module_graph.rs` with an
empty baseline: 0 (`main`: 1). The baseline goes from 495 accepted
findings to 494.
- Limit on a real binary (debug build of this PR, `bun build --compile
--bytecode --format=esm --packages=external`): 65535 requests give a
graph with `requestCount` 65535 and largest packed index 65534. 65536
requests give exit 0 and no graph. A release build with `main`'s version
of the file gives the same two results.
- Serialized bytes: a model that compiles the real file text against
stubbed dependencies gives byte-identical output for `main`'s file and
this PR's file on 33 boundary graphs and 4000 random graphs (0
differences). The model is not a bun build.
- Release-profile object of `bun_bundler` (one codegen unit, no LTO),
`main` against this PR: `.text` 1,893,032 to 1,892,349 bytes (-683),
`.rodata` 83,806 to 83,806. Symbols that name `prelinked_module_graph`:
34,043 to 33,360 bytes. `build` (with `parse_module` and `serialize`
inlined): 3267 to 3175 instructions, 336 to 328 conditional branches.
Limit compares against `0xffff`: 1 per module and 0 per request in both.
- Not measured: the linked release binary. The numbers above are from
the crate's object before the link.
- Struct sizes (`-Zprint-type-sizes`): `Import` 28 to 24 bytes, `Export`
28 to 24 bytes, a star-export element 4 to 2 bytes. These exist only
during the build. The serialized layout does not change, and `VERSION`
stays 2.
- Over-limit module (65536 requests, `build` returns `None`), same model
with a counting allocator: 84 to 11 allocator calls, 14.7 MB to 5.2 MB
requested. The refusal now happens before the module's requests are
parsed.
- Diff: `prelinked_module_graph.rs` +21/-26, `mordant-baseline.toml`
+0/-1, test +68/-0.

**The new test cases**

- `compile/prelinked/RequestLimit65535` and `RequestLimit65536` build an
entry with that many external imports and read the executable. They do
not run it.
- They pass before and after this change (checked on a release build of
`367d939d9`, which has `main`'s version of the file). They hold a limit
that had no test and that now depends on one line. A build with that
line changed to `.unwrap_or(u16::MAX)` fails the 65536 case.
- Time per case: 0.5 to 2.6 s on a release build (three runs). On a
debug build with ASAN: 12.7 s and 25.1 s in a run of the whole file,
18.0 s and 34.7 s when only these two run. An existing case of this file
takes about 30 s on that debug build.
- The entry is built as a `Buffer`. `itBundled` sends a string through
`dedent()`. With the entry as a string, the two cases took 42 s and 57 s
in a run of the whole file on the debug build.
- The build subprocess of `itBundled` has a 60 s limit. The build step
of these cases takes 12 to 17 s on the debug build.
- A module over the limit is the only way an executable gets per-module
`module_info` bodies in place of a graph. No other bundler test builds a
module over the limit. `RequestLimit65536` has no `run`, so nothing
executes that path.

**What moved**

- The refusal of a module over the limit now happens at the head of
`parse_module`'s request section, before the requests are parsed.
`build()` interns the same strings before that point as it did before.
- The check reads the length of `requested_modules_keys`. The old check
read the length of the zipped request list. The three request vectors of
a `ModuleInfo` have one writer and the same length, so the two are
equal.
- The four `as usize` reads of the index became `usize::from`, because
the type now allows it.

**Left as they are**

- `bound_checked_only_in_debug:src/bundler/Chunk.rs = 3` (`Query::new`,
`EntryPoint::non_entry_point`, `set_entry_point_id`): same lint and
crate, no shared type with this file.
- The `kind` and `resolution` fields of the same packed word are `u32`
values from a `repr(u8)` enum and a five-value table. mordant does not
flag them.
- A local export keeps the placeholder `request: 0`. Nothing reads it as
an index.

</details>
…#47523)

### What does this PR do?

On Linux arm64, code compiled with `-mbranch-protection=pac-ret` saves
its return addresses signed: on a processor with pointer authentication,
the bits above the address hold a signature. The crash handler walks the
frame pointers and read that word as it is. A signed word is the address
of nothing, so the frame went into the report as unknown.

Who that hits:

- A Bun that is itself compiled that way loses every frame. #47517
(closed, never for merging) ran the tests with such a build, on top of
oven-sh/WebKit#945, and a crash there reported the trace
`AA___________________`.
- In a Bun as it is built today, only the caller of a function that
signs: a native addon or a system library compiled with the flag.

The fix is `strip_return_address_signature` in `src/bun_core/debug.rs`,
at the places that read a return address:

- `StackIterator::next`, the walk.
- `return_address_of_frame`, which is the read that `return_address()`
(`src/bun_core/lib.rs`) did itself, moved out so that a test can give it
a frame. `capture_current` looks for that address in the walk to trim
the handler's own frames. One without the other would never match, and
the trim falls back to the whole trace without saying so.
- x30 in the `Registers:` line (`src/crash_handler/fault_registers.rs`),
which the report of an executable with compiled code has. From the first
instruction of a function that signs to its first call, the link
register is signed. That line prints a value that may be an address by
what it points into, so that it does not say where anything was loaded.
A signed word is above the range that it takes for addresses, so all of
it was printed, the address included. Between calls the register may
hold anything, so the stripped word is taken only where it is in that
range.

It is `XPACLRI`, written as `hint #7`: a no-op on a processor without
pointer authentication, and an address without a signature comes out as
it went in. It needs no key and checks nothing. So it is compiled for
every arm64 target, and there is none to leave out: I have not looked at
whether anything signs on macOS or Windows, where Bun is tested but
nothing here is.

Left as they are:

- The walk on Windows, which is `RtlCaptureStackBackTrace`.
- Frames of ahead-of-time compiled code, which are walked by their
tables: that code does not sign.

### How did you verify your code works?

**The new test**, `a stack trace takes the signature off a return
address` in `test/cli/run/run-crash-handler.test.ts`, on Linux arm64. No
function of an ordinary build signs, so the new
`crash_handler.stackTraceStartsAtSignedReturnAddress()` of
`bun:internal-for-testing` does in inline assembly what a function
compiled with the flag does: `PACIASP`, save the frame record, call,
restore, `AUTIASP`. The callee takes the return address of that signed
frame with `return_address_of_frame`, captures the stack trimmed to it,
and the hook says whether the trace starts at the address that was
signed. That takes both strips.

It fails without the fix only where something is signed, so it is
skipped unless `/proc/cpuinfo` has `paca`.

**Nothing tests x30 in the `Registers:` line.** It takes a fault in the
few instructions in which the register is signed, in an executable with
compiled code. `test/aot/crash-in-compiled-code.test.ts`, which reads
that line, is for x64, and plants its fault in compiled code, which does
not sign. I gave the expression a signed return address under the
emulator, which came out as the address, and an unsigned one,
`0xfffe000000000005`, `0x7ff8123456789abc`, 42, all ones and
`0xffff800012345678`, which came out as they went in.

**Under emulation, each strip taken out in turn.** I have no arm64
hardware, so I compiled the helpers and the hook as they are in this PR,
around a walk and a trim that do what `StackIterator::next` and
`capture_current` do, for `aarch64-unknown-linux-gnu`, and ran it with
`qemu-aarch64`, 100 times each at `opt-level` 0 and at 3. How many of
the 200 pass:

| `-cpu` | both strips | not in the walk | not in
`return_address_of_frame` | neither |
|---|---|---|---|---|
| `neoverse-v1` (pointer authentication) | 200 | 1 | 1 | 1 |
| `neoverse-n1` (none) | 200 | 200 | 200 | 200 |

The few that pass without a strip are the runs in which the signature is
all zeros. That can let a broken build through now and then. It cannot
fail a sound one.

That is of 2a073ce, and b3b1f95 changes neither. What follows, to
the end of the section on CI, is of the commits before it, which had the
same two strips, with the asm and the hook in a longer form.

**On hardware, in a Bun that signs.** #47517 had this PR's first commit
on top of its Linux arm64 build, which is compiled with
`-mbranch-protection=pac-ret` (C, C++ and Rust) and links the WebKit of
oven-sh/WebKit#945. The test machine printed `paca` from
`/proc/cpuinfo`. The addresses in the reports of the same three crashes,
without that commit ([build
135982](https://buildkite.com/bun/bun/builds/135982)) and with it
([build 135994](https://buildkite.com/bun/bun/builds/135994)); `_` is a
frame that is in no image:

| crash | without | with |
|---|---|---|
| `bun fmt` panics (`cli/format/conformance.test.ts`) |
`___________________`: 0 of 19 | `u8nghGu5t/gG2iv/gGm7r7gG+nw7gG…`: 19
of 19 |
| `crash_handler.panic()` | `__________________`: 0 of 18 |
`mq/xwC+t7uwC+molwC_u903jGm93tjG…`: 17 of 18 |
| `crash_handler.segfault()` | `29nlwC___________________`: 1 of 20, the
faulting instruction | `29nlwC_u903jGm93tjG2x7mnG…`: 19 of 20 |

- One frame stays unknown in the last two, which JavaScript calls. I
have not looked at which it is. The trace of `crash_handler.panic()` on
x64 has one too.
- Each trace has as many frames as before, so `capture_current` still
finds `return_address()` in the walk.
- `test/cli/run/run-crash-handler.test.ts`: 33 pass, 8 skip, 0 fail,
with the test in its first form among them. With this PR's second commit
on top as well ([build
136000](https://buildkite.com/bun/bun/builds/136000), on a machine that
printed `paca` too), so with the test as it is now: 33 pass, 8 skip, 0
fail.

**In this PR's CI** ([build
136672](https://buildkite.com/bun/bun/builds/136672), at b3b1f95, the
head, and the same in [build
135999](https://buildkite.com/bun/bun/builds/135999), at 113095a),
`test/cli/run/run-crash-handler.test.ts` has no failure on any Linux
lane, and each arm64 lane has one pass more and one skip less than its
x64 twin, which is this test:

| | arm64 | x64 |
|---|---|---|
| Debian 13 | 33 pass, 8 skip | 32 pass, 9 skip |
| Ubuntu 25.04 | 33 pass, 8 skip | 32 pass, 9 skip |
| Alpine 3.23 | 30 pass, 11 skip | 29 pass, 12 skip |

Since 2a073ce the test is skipped where `/proc/cpuinfo` has no `paca`.
It ran on all three arm64 lanes of build 136672, so all three machines
have pointer authentication.

40 test files fail in that build, 39 of them in `test/aot`. They are the
40 that fail on `main`: a comment below has the builds.

**On Linux x64**, with a debug build:

- `test/cli/run/run-crash-handler.test.ts`: 31 pass, 10 skip, 0 fail.
One of them failed before, on `main` too: `crash trace starts at the
crash site, not inside the crash handler` expected the first frame to be
named `js_panic`, and nothing in `src/` has had that name since #46540.
It runs only in a debug build on Linux with `llvm-symbolizer`. It is a
test of what `return_address()` is for, so it now expects the name that
the crash site has, `crash_handler_jsc::panic`.
- `crash_handler.segfault()` still reports a trace with resolved frames,
through Rust, C++ and LLInt.

**`bun run rust:check-all`**: 12 ok, 0 failed, 0 skipped (of 12). `cargo
clippy -p bun_core -p bun_crash_handler` for `x86_64-unknown-linux-gnu`
and `aarch64-unknown-linux-gnu`: nothing.


---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…47779)

`getAzure` in `scripts/ci-image.ts` now requests an OIDC token with
`buildkite-agent oidc request-token` and sends it to Azure as the
`client_assertion`. The Packer template takes `client_jwt` in place of
`client_secret`.

- Lookups read the client ID from the Buildkite secret
`AZURE_LIST_IMAGES_CLIENT_ID`, and bakes from
`AZURE_BAKE_IMAGES_CLIENT_ID`.
- The script no longer reads `AZURE_CLIENT_ID` or `AZURE_CLIENT_SECRET`.
- The template change renames both Windows images.
With a finite maxDepth, DepthLimiter asked path.resolve() and
path.relative() about every match and every directory, and kept one
cache entry per path, as the text of Node v26.12.1 does.

The limiter now reads the depth from the text of a path when the text
says it:

- plainDepth() counts the names of a relative path with no ".."
  segment. It is PlainDepth() of the native walker on Node's main branch
  (nodejs/node#65392).
- #plainDepth() also counts what follows cwd in an absolute path that
  starts with cwd. The test of the start is the private endsWith of JSC,
  which user code cannot replace.
- The limiter remembers the directory that canTraverse() last allowed by
  its depth. A name in that directory is one level deeper at most, so
  includes() does not ask about it. On Windows and macOS only a
  directory that the text placed is remembered: there Node's text asks
  the file system about a path that can be cwd in another spelling, and
  the limiter asks in the same order.

A path with a ".." segment and, on Windows, a path with ":" keep the
route and the awaits that they had. internal/fs/glob.ts does not change,
so a walk with no maxDepth is the walk that it was.

The tests run walks in a child process that counts the calls of
path.relative(): the module of the limiter loads at the first walk with
maxDepth, so a count that is in place before that walk sees every call.
One test asks the limiter itself, through exposedInternals of
bun:internal-for-testing, and compares its five methods with
path.resolve() and path.relative() on every platform.
bench/snippets/fs-glob-max-depth.mjs has each spelling of a pattern.
@github-actions github-actions Bot added the claude label Nov 3, 2026
@robobun

robobun commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status of this PR.

How the cost was reproduced. A release build of #47750 (f3d2cd6) walks a tree of 121 directories and 484 files with fs.globSync("**/*.txt", { cwd, maxDepth: 100 }). The main thread uses 6.44M instructions per walk (perf_event_open, after 300 walks), against 4.08M with no maxDepth. A child process that replaces path.relative before the first walk counts 605 calls per walk. With this PR the walk uses 4.42M instructions and makes no path.relative() call.

State. A parked draft, with no request for review. Nobody has asked for this change: no issue reports the cost, and maxDepth is in no Bun release. A self-review said not to queue it for that reason. The branch has the final form, a native depthFromRoot(), and the description has the measurements and the open items. The question for a maintainer is on #47750. If the answer is no, close this PR: the branch keeps the work.

CI. test/js/node/fs/glob.test.ts passes on each of the 11 lanes that run it. Windows 2019 x64 and Windows 11 aarch64: 203 pass. macOS x64 and aarch64: 213 pass. Seven Linux lanes: 195 or 196 pass. This is the first run of the native function on Windows and macOS. One engine test is red because of this change, on five lanes: test/aot/engine/ir-6.test.ts. It passes on main at the commit that this branch has merged. This change adds two host functions to the table of built-ins. With the new table the engine loses a site of one fixture under testing=DelayFlowRules. The engine has this defect with the table of #47750 too: the same program fails there with --option=testing=DelayFlowRules:59. The cure belongs to the engine, so this PR does not have it. The other 38 red test files are AOT tests (test/aot and test/bundler/compile-aot-size.test.ts). Each is red on main at that commit too.

@robobun

robobun commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:17 AM PT - Nov 4th, 2026

❌ @robobun, your commit 9db2ed5 has 39 failures in Build #137567 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47832

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

bun-47832 --bun

CI builds a branch only when it has this commit.
Comment thread src/bun_core/debug.rs Resolved
Comment thread src/bun_core/debug.rs Resolved
Comment thread src/js/internal-for-testing.ts Resolved
Comment thread src/runtime/api/crash_handler_jsc.rs Resolved
DepthLimiter calls depthFromRoot(path, root, foldsCase) for each match
and directory. The function answers from the characters of the two
strings: a count of names, and on POSIX resolve() and relative() of an
ASCII path with no string made. When the characters do not settle the
depth, the limiter takes the route of Node.js's text, as before.

The limiter has no scanner, prefix or remembered directory of its own
any more.
…lasses

The 8-bit body of depthFromRoot() is in the host function alone: a rope
is resolved in a cold function that hands the two strings back. The path
that resolve() has to put together is a function of its own, as for
relative(). A 16-bit string takes the count by names with the root in
either width.

bench/snippets/fs-glob-max-depth.mjs has a row for each way that a
pattern names its paths, with names of 2, 49 and 200 characters and
16-bit names.
… at the limit

The walker calls includesAsync() only for a path that includesNow()
could not answer, so a call of depthFromRoot() there is never of use.

The test of a name in another case has two rows with a wildcard at the
limit: there the walk reads the directory only where the file system
says that it is cwd. The test of the 512 characters has an absolute
path. The bench has one group for each way that a pattern names its
paths.
With names of 200 characters the deepest file of the tree is over the
1,024 bytes that macOS allows for a path.
@robobun robobun changed the title fs.glob: answer maxDepth from the text of a path at or below cwd fs.glob: answer maxDepth with a native depthFromRoot() Nov 4, 2026

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