Skip to content

linux: make the read-deny glob walk about six times faster - #627

Merged
ronleizrowice-ant merged 1 commit into
mainfrom
perf/linux-read-deny-walk
Sep 30, 2026
Merged

ronleizrowice-ant merged 1 commit into
mainfrom
perf/linux-read-deny-walk

Conversation

@ronleizrowice-ant

Copy link
Copy Markdown
Collaborator

On Linux a denyRead pattern with ** is expanded by listing everything under its base, at every command and on the caller's thread. Under a large directory (~/**/.env) that holds the caller up for seconds, once per pattern. This makes the walk about six times faster and denies exactly what it denied before.

What changes

  • No path for a plain miss. For every entry the walk spelled two paths and converted one before asking whether the entry matched. A pattern that splits into path components is matched by name, so a plain file it does not match now takes an early continue. Nothing further on records such an entry.
  • Patterns share their listings. The walk already kept each directory's listing for a second position to read. getFsReadConfig() and the wrap now hand one map to all the patterns of a read configuration. The map lives for one expansion: the next command lists afresh.

Measured

Six patterns over 400,000 files in 20,000 directories, one wrap:

Time Resident memory
Before 6.2 s 469 MB
Early continue only 2.5 s 377 MB
Both 0.97 s 286 MB

The mounts are the same in all three.

Known limits

Testing

  • Two new tests: each directory is listed once for all the patterns, at the walk and through both call sites.
  • Eight mutations of the new conditions each fail a test (four of them 1 to 63 existing ones).
  • Real bubblewrap, this branch beside main: a sandboxed cat of every file in a tree with symlinks, an unreadable directory, directory-form and unsplittable patterns gives identical results. Files made between two wraps of one process are denied at the next.

A denyRead pattern with `**` is expanded by listing everything under its
base, at every command and on the caller's thread. Under a large directory
that holds the caller up for seconds, once per pattern.

For every entry the walk spelled two paths and converted one before it asked
whether the entry matched. A pattern that splits into path components is
matched by name, so a plain file it does not match, which is nearly every
entry of a large tree and which nothing further on records, now costs no
path at all.

The walk already kept each directory's listing for a second position to
read. The patterns of one read configuration now share that map, so those
with a base in common list each directory once between them. The map lives
for one expansion: the next command lists afresh.

Six patterns over 400,000 files in 20,000 directories: one wrap 6.2 s
before, 0.97 s after; resident memory 469 MB before, 286 MB after; the same
mounts.
ronleizrowice-ant added a commit that referenced this pull request Sep 30, 2026
Both it and the walk's budget (#607) hand every pattern of one read
configuration something to share, so the listings go where the budget already
is: made in readDenyGlobExpander(), and handed on in the options of
expandReadDenyGlobLinux(). The walk's record of a directory gives up its own
copy of the entries for the shared map.

An entry is paid for before it is skipped: the budget bounds the entries
looked at, and one the fast path passes over was looked at too.
ronleizrowice-ant added a commit that referenced this pull request Sep 30, 2026
The walk's budget (#607), its shared listings (#627) and its steps (#628)
rewrite one code path, and the path entries of #620 to #622 sit on top of it.

Everything above the walk is a generator now: the read-deny expansion, the
function readDenyGlobExpander() returns, the allowRead expansion, the
resolution of read path entries, and withOtherReadings(), which calls the
expander once for each reading of an entry. The synchronous names finish
those on the spot.

In the walk the step comes first and the budget's clock is read after it,
before the next listing. The deadline is wall time, so turns given to other
work count against it: a wrap can be refused earlier for that, and a list is
never cut short.

The budget error is given its code around the delegated generator. An abort
is raised by the driver and does not pass through there, so a wrap with a
spent budget and an aborted signal rejects with the signal's reason.

A wrap that starts over makes a new budget and new listings, and reads the
new configuration's denyReadGlobBudget.

In the scan's catch the abort is looked at first, then what ripgrep listed.

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

No blocking issues. I compared this branch with its base on a few thousand random trees, including symlinks and unreadable directories, and in a real bubblewrap run. The denied paths are identical and the walk is about 4x faster here.


Generated by Claude Code

@ronleizrowice-ant
ronleizrowice-ant merged commit 269c707 into main Sep 30, 2026
8 checks passed
@ronleizrowice-ant
ronleizrowice-ant deleted the perf/linux-read-deny-walk branch September 30, 2026 12:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants