Skip to content

Brackets are read in linear time, and a read rule is compiled once - #718

Merged
ronleizrowice-ant merged 2 commits into
mainfrom
perf/brackets-in-linear-time-and-a-read-rule-compiled-once
Oct 16, 2026
Merged

ronleizrowice-ant merged 2 commits into
mainfrom
perf/brackets-in-linear-time-and-a-read-rule-compiled-once

Conversation

@ronleizrowice-ant

@ronleizrowice-ant ronleizrowice-ant commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

getFsWriteConfig() is a getter that callers reach from permission checks, once a path. Under read rules that hold many brackets, one call takes up to 300 ms on main, where 0.0.79 takes 4.

Why

  1. globToRegex() split its pattern with /(\[[^\]]*\])/, which looks for a ] from every [ anew: the square of the length on a run of [ that none closes. A profile has 77 % of the getter's time there. It came with Read brackets that spell no set of characters as text, so no path entry keeps a sandbox from starting #687 and is in no release.
  2. Every read rule is compiled for each answer. That is old: 1,000 ordinary globs cost 15 ms a call in 0.0.79 too.

What changes

  • splitAtBracketExpressions() goes through the pattern once, for the same parts. Every caller of globToRegex() gains, a wrap among them.
  • compiledDenyGlob() keeps what a read rule compiles to, by its normalized pattern, which is all that depends on. Past a million characters of pattern text, all that is kept is let go.
  • What is asked of the configuration and of the disk is asked every time, so the answers are what they were.
  • One answer differs from main's. With the working directory gone, a relative pattern (rel/*) throws ENOENT, as a relative name does, and as both do in 0.0.79. On main the try round the compile took in the step before it, and such a pattern counted as one that covers.

Keeping the whole answer would be faster still, and I tried it. It lags behind the disk: where an allowRead directory is replaced by a link, the entry stops counting at once today, and would go on counting until something refreshed the answer. A test now holds that.

Measured

Node 22, milliseconds.

read rules this branch main 0.0.79
a call of the getter: 20 ordinary globs 0.33 0.39 0.42
1,000 ordinary globs 12.5 16.5 15.1
16 KB of [ 0.49 52.6 0.70
96 KB of [ 2.7 315 4.2
96 KB of [a] 2.5 41.5 3.0
95 KB of [z-a], a credentials deny 1.7 137 2.4
a wrap: 16 KB of [ 26 115 throws
96 KB of [ 78 653 throws

Bun 1.4.3 gives the same picture. Memory over 4,000 distinct patterns of 3.9 KB levels off where main's does (250 MB). With nothing let go it is at 352 MB by then and rising.

Testing

  • The branch against main: globToRegex() and denyGlobRegex() on 300,000 generated bracket patterns and 20 edge ones: none differs. getFsWriteConfig(), getFsReadConfig() and wrapWithSandbox() on 160 generated configurations, on Node 22, Bun 1.4.2 and Bun 1.4.3: the same byte for byte, the session's token, its ports and the helper's path set aside.
  • 9 new tests. 3 fail on main. Without the scan: its time test. Without the kept compile: its two. A kept answer, by content or by the configuration object: "follows what is on disk", and for the object "follows a configuration that is changed in place".
  • The property test that holds globToRegex() against a reference matcher fails 38 tests on a wrong scan.
  • The whole suite: nothing fails that does not on main.

ronleizrowice-ant and others added 2 commits October 15, 2026 03:09
getFsWriteConfig() is a getter that callers reach from permission checks, once
a path. Under read rules that hold many brackets one call took up to 300 ms.

globToRegex() split its pattern with /(\[[^\]]*\])/, which looks for a `]` from
every `[` anew: the square of the length on a run of `[` that none closes. It
now goes through the pattern once, for the same parts.

Every read rule was compiled for each answer. What a rule compiles to is now
kept by its normalized pattern, which is all it depends on. What is asked of
the configuration and of the disk is asked every time, so the answers are what
they were.
Glob syntax was read in two places, each in its own way: globToRegex()
by a chain of replacements, the walk by a tokenizer of its own. Now
parseGlob() goes through a pattern once and gives typed tokens, and both
are built from those.

Four spellings read differently. A * or ? between brackets is a member
of the set. Brackets with nothing in them are text. So is every [ that
nothing closes. A name that spells __GLOBSTAR__ is that name. The walk
can follow each of these a name at a time, so it lists them through
symlinked directories like any other pattern.

The same meaning in other text: a ] outside a set is written \], and
wildcards side by side are kept to the fewest that match the same.
@ronleizrowice-ant

Copy link
Copy Markdown
Collaborator Author

/approve 069588c

@ronleizrowice-ant
ronleizrowice-ant enabled auto-merge (squash) October 16, 2026 15:59

@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 blockers found at 069588c (Linux, bubblewrap, bun 1.4.2; macOS and Windows not run here).

  • Speed-up is real: one getFsWriteConfig() call with 96 KB of [ in a read rule takes 3.6 s on the base under bun and 3 ms here; 16 KB goes from 109 ms to 1.3 ms. Ordinary rules are no slower (20 globs 0.1 ms, 1,000 globs 13 ms -> 10 ms).
  • Same answers as the base: getFsWriteConfig(), getFsReadConfig() and the bwrap command are byte-identical for 9 configurations. The Linux deny expansion and the raw walk agree on 600 random trees with 2,700 symlinks (8,400 expansions, 16,800 walks, 0 differences). The macOS profile text for 17 glob rules differs only where **/**/x is now written **/x.
  • The new parser matches what the base matches: 300,000 random patterns, 8.4 million pattern-and-path pairs, 0 differences outside the three kinds below.
  • Live sandbox: **/.env, a /** directory deny and a folder named [user-id] are denied on base and here; writes land only where allowed.
  • Merged into today's main (five PRs landed after this branch's CI ran): it merges clean, typechecks and builds, and the sandbox test suite fails the same 41 tests as main alone (container-related), with 123 more passing.

Not blocking, but the description does not say it: the second commit changes how three kinds of pattern are read, compared with main.

  • A wildcard inside brackets is now a member of the set: [r*]eadme.txt denies readme.txt here and did not on main (seen live).
  • Empty brackets are text: list[].ts matches the file of that name; on main it matched nothing.
  • Two or more [ that nothing closes are text; on main they matched odd strings.
    In each case main's reading was not something anyone would rely on, and the README is updated, but it belongs in the 0.0.80 notes.

Generated by Claude Code

@antdres antdres mentioned this pull request Oct 16, 2026
@ronleizrowice-ant
ronleizrowice-ant merged commit 3a2954d into main Oct 16, 2026
16 checks passed
@ronleizrowice-ant
ronleizrowice-ant deleted the perf/brackets-in-linear-time-and-a-read-rule-compiled-once branch October 16, 2026 16:29
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.

3 participants