Skip to content

fix(fs): cut round trips for rg, find and tree walkers on remote backends - #2816

Merged
chaliy merged 5 commits into
mainfrom
claude/project-thread-w2g6tc
Nov 3, 2026
Merged

chaliy merged 5 commits into
mainfrom
claude/project-thread-w2g6tc

Conversation

@chaliy

@chaliy chaliy commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Михайло · project thread

What changed

Tree-walking builtins now make far fewer FileSystem calls, which is what decides speed when each call is a network round trip (Everruns: ~10 ms).

  • Indexed search actually reaches rg/grep. In a default Bash session the index was never used: MountableFs and the per-execution scoped VFS both returned None from as_search_capable. MountableFs now forwards the provider of the backend serving the whole subtree (none if another mount sits below it) and maps result paths back under the mount point. The scoped VFS hands out a lease-checked provider: every search fails once the execution is revoked (TM-ISO-027, new regression test).
  • rg keeps the index with ignore rules on (ask 1). Index matches are filtered through the walk's own rules, loaded only along the directories that lead to a match; ignored/hidden directories are never entered. --max-depth now applies to index matches. A missing operand falls back to the walk so the error matches.
  • rg walk lists a directory first and opens only the ignore files the listing shows (it used to stat+read four names per directory and stat .git in every ancestor of every directory).
  • find reuses DirEntry.metadata (ask 2): lstat only for roots; symlinks still get read_link + target stat. The listing read for %n/-ls is reused when descending.
  • FileSystem::walk(root, max_depth) -> Vec<DirListing> (ask 3), default = one read_dir per directory (walk_with_read_dir), forwarded by FollowFs, MountableFs (no nested mount), RootFs (outside system trees), ReadOnlyFs and the per-execution wrappers. Used by find (when no -exec*/-delete/-prune/-quit) and du. Returns listings per directory rather than flat (path, metadata) pairs so callers know which listings are complete, including empty dirs.
  • Other walkers: grep -r takes sizes from listings and resolves only root/symlinked dirs (was an lstat per path component per directory); an index answer with zero matches is final (it used to re-walk the whole tree). du, tar use listing metadata. FollowFs copy/chmod/set_modified_time take a one-stat fast path instead of resolving every component, which drops cp -r from ~12 to ~2.4 lookups per file. MountableFs::read_dir lists mount points with the mounted root's real metadata.
  • bashkit::testing::CountingFs (doc-hidden): call-counting backend with optional index, bulk walk and busy-wait latency, for tests and benches.

Memory bounds (trees with millions of files)

  • FileSystem::walk takes WalkOptions { max_depth, max_entries } and returns a bounded batch (10k entries by default, never a split listing). find and du ask for the next batch as they descend and drop listings once used, so they hold about one batch whatever the tree size. If an answer is missing the requested directory, the walk falls back to read_dir instead of repeating walks.
  • rg charges each file's text to the aggregate-input budget as it reads it and stops at the first refusal. Before, it read every file and only then checked the budget (TM-DOS-140).
  • Tests: walk_batches_are_bounded_* (100-entry backend page: same output, peak ≤ page + one listing), rg_stops_reading_at_input_budget (stops after fewer than 200 of 1,000 files), and walk_cache unit tests.

Why

Everruns runs Bashkit over a remote filesystem; rg -l markkit crates and find /workspace -path '*markkit*' hit the 30 s timeout on a 3.7k-file checkout.

Before / After

cargo bench --bench remote_fs -- --counts, bashkit checkout (3,720 files), backend with an index. Full tables incl. plain and bulk-walk backends: crates/bashkit/benches/results/criterion-remote_fs-linux-x86_64-1793656706.md.

command calls before at 10 ms calls after (index) after (index + bulk walk)
rg -l markkit crates 176,281 1763 s 3 3
rg -l SearchCapable crates (13 hits) 176,281 1763 s 33 33
grep -rln markkit crates 23,709 237 s 2 2
find /workspace -path '*markkit*' 7,831 78 s 2,056 2
find crates -ls 9,087 91 s 1,983 2
du -s crates 7,105 71 s 1,983 2
cp -r crates/bashkit/src/builtins … 2,785 28 s 780 780
tar -cf … crates/bashkit/src/builtins 463 4.6 s 233 233
rg -l markkit crates (no index) 176,281 1763 s 5,127 —

Acceptance tests (tests/integration/remote_fs_round_trip_tests.rs, 1,000-file tree): rg PATTERN dir makes exactly one search and reads only the 20 matching files; find dir -type f makes one read_dir per directory (111) and at most one metadata lookup (the root). Differential tests assert index vs walk and bulk-walk vs read_dir give byte-identical output across .gitignore/.ignore/.rgignore, parent ignore files, re-includes, hidden dirs, --max-depth, -g, symlinks, -L, -maxdepth/-mindepth, -printf %n.

Not changed, noted: ls -R/tree are already one read_dir per directory (no bulk walk: they skip dot dirs / stop at an entry budget, so an eager default walk would read more); ** globs list each directory twice (TODO in test). Bench wall times are CPU-bound by InMemoryFs::read_dir, which scans every entry per call; worth a separate fix.

Risk

  • Medium
  • grep -r/rg now trust an empty index answer (documented on SearchCapable::search_provider: return None if the index may be stale). One unit test that relied on the old re-walk was updated.
  • Walkers trust read_dir metadata to equal lstat; custom backends must keep them consistent (documented in docs/filesystem.md).
  • Full slice cargo test --workspace --lib --bins --tests --features http_client,ssh,sqlite passes except ssh_supabase_connects (external host, unrelated); doc tests pass; fmt/clippy clean.

Checklist

  • Tests added or updated
  • Backward compatibility considered (new trait method has a default; no API removals)


<a href="''https://backend.blacksmith.sh/track/enable-autofix?expires=1796249005&installation_model_id=447267&pr_number=2816&ref=codesmith_pr_footer&repository=everruns%2Fbashkit&return_to=https%3A%2F%2Fgithub.com%2Feverruns%2Fbashkit%2Fpull%2F2816&signature=cc2259c6436b37e855854845a48e5afa09035dd8b0726876f8dfc1b20c4962fd"&gt;&lt;picture&gt;&lt;source media="(prefers-color-scheme: dark)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"&gt;&lt;source media="(prefers-color-scheme: light)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"&gt;&lt;img alt="Autofix with [code]smith" src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png" width="190" height="28">``
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.


Generated by Claude Code

@chaliy chaliy self-assigned this Nov 2, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
bashkit 022e2ba Commit Preview URL

Branch Preview URL
Nov 03 2026, 05:33 AM

@chaliy
chaliy force-pushed the claude/project-thread-w2g6tc branch from cdcdb1c to 3220240 Compare November 2, 2026 23:31

chaliy commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Runtime callbacks (macos-latest x86_64 (Rosetta)) failed with a Node crash under Rosetta (async hook stack has become corrupted, in node::AsyncHooks::FailWithCorruptedAsyncStack). That is a Node runtime fault on emulated x86_64, not something this PR changes: the PR touches no JS bindings or callback code, and the other runtime-callback jobs pass. The job will be re-run once the workflow run finishes.


Generated by Claude Code

…ends

- Indexed search reaches builtins: MountableFs forwards SearchCapable
  (with mount path mapping), the per-execution VFS hands out a
  lease-checked provider (TM-ISO-027).
- rg keeps the index with ignore rules on, loading rules only along
  directories that lead to a match; the walk reads ignore files the
  listing shows instead of probing four names per directory.
- find, du, grep -r, tar, cp -r use read_dir metadata instead of a
  per-entry stat; grep -r resolves only root and symlinked dirs; an
  empty index answer is final.
- FileSystem::walk(root, max_depth) bulk listing hook with a read_dir
  default, forwarded through wrappers; used by find and du.
- FollowFs copy/chmod/set_modified_time fast path.
- CountingFs test/bench backend, round-trip budget tests, remote_fs bench.
…once

FileSystem::walk now takes WalkOptions { max_depth, max_entries } and
returns a bounded batch (10k entries default, never a split listing).
find and du refill batches on demand through WalkCache and drop listings
as they descend. rg charges each file to the aggregate-input budget as it
reads and stops at the first refusal instead of loading every file first.
TM-DOS-139.
@chaliy
chaliy force-pushed the claude/project-thread-w2g6tc branch from 3220240 to d9c52b6 Compare November 3, 2026 05:14

@bashkit-pr-reviewer bashkit-pr-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Round-trip reduction looks correct in the paths I could verify; findings are hardening/docs-level, no blockers.

self.inner.search(query)
}

fn capabilities(&self) -> crate::SearchCapabilities {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] capabilities() bypasses the execution lease while search() enforces it, so a provider retained past completion still answers metadata queries. Either check the lease here too or document that capabilities are intentionally lease-free. Evidence: crates/bashkit/src/execution_capability.rs:511-521 (LeasedSearchProvider::search calls ensure_active, capabilities just forwards to inner). Fix: map a revoked scope to default/empty capabilities or add a comment stating capabilities carry no authority.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 022e2ba: LeasedSearchProvider::capabilities returns default (no content search) once the lease is revoked. retained_search_provider_fails_after_completion now asserts it.


Generated by Claude Code

}

impl super::SearchProvider for MountedSearchProvider {
fn search(&self, query: &super::SearchQuery) -> Result<super::SearchResults> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] MountedSearchProvider::search does not re-check has_mount_below at query time, so a mount added between search_provider() and search() (parallel executions share the MountableFs) yields a partial-index answer that callers treat as definitive. Evidence: guard exists only in search_provider (crates/bashkit/src/fs/mountable.rs:543), while search at :508 translates the root and delegates unconditionally; compare walk at :636 which re-checks per call. Fix: re-validate inside search (return empty/default to force walk fallback) or document that mounts are immutable during execution.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 022e2ba. MountableFs keeps a mount generation that is bumped on every mount and unmount. A provider records the generation it was handed out at, and search fails if it changed, so callers fall back to the walk. Test: search_provider_refuses_after_mount_table_changes.


Generated by Claude Code

if inputs.is_empty() {
return Ok(None);
}
// An index that answered with no (in-scope) match is still an answer:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] An in-scope but empty/stale index answer is treated as definitive (crates/bashkit/src/builtins/grep.rs:1624 "still an answer"), yet I found no documented completeness/consistency contract on SearchProvider/SearchCapable requiring that. A remote backend with an eventually-consistent or partial index will then silently miss matches where the old walk found them. Fix: document in fs/traits.rs (and docs/filesystem.md) that providers must return complete results for the queried root or return Err/empty-provider so callers fall back to walking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Documented in 022e2ba. SearchCapable::search_provider (fs/search.rs), docs/filesystem.md and knowledge/foundations/vfs.md now say that answers must be complete for the queried root. A backend whose index may be stale or partial returns no provider, or an error from search, and then rg/grep -r walk.


Generated by Claude Code

@@ -0,0 +1,120 @@
# remote_fs bench, bashkit checkout (598ebb4 + this change)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] Generated Criterion reports are committed (crates/bashkit/benches/results/criterion-remote_fs-*.md, e.g. the table with per-command round-trip counts). These are machine- and run-specific artifacts that will churn on every bench run. Fix: gitignore benches/results/ (or criterion/) and keep at most a hand-written summary in the PR/docs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Keeping these: the repo contract (AGENTS.md, Benches) requires saving criterion results under crates/bashkit/benches/results/ as criterion-<bench>-<moniker>-<timestamp>.md, so future runs have a baseline to diff against. Each run adds a new timestamped file, and existing files are never rewritten.


Generated by Claude Code

@bashkit-pr-reviewer

bashkit-pr-reviewer Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Review summary (head 022e2ba, 5 commits)

Verdict: the new push is a hardening pass that resolves two of my four P2s and adds regression coverage. No new findings; no blockers.

  • 5th-commit changes (vs previously reviewed d9c52b6, +73 net): MountableFs gains a mount-table generation counter (fs/mountable.rs +54) — providers snapshot it before the mount-below check and search fails closed (Err, callers fall back to walking) if the table changed; both mount and unmount bump it. LeasedSearchProvider::capabilities() now returns all-false default() after revocation (execution_capability.rs +4/−4). Docs (search.rs, filesystem.md, vfs.md) now state the index-authoritativeness contract and the error→walk fallback.
  • Finding status: mounted-search-remount-toctou — resolved (snapshot-before-check order is race-free; verified grep :1582 and rg :4090 fall back on Err). leased-capabilities-no-lease-check — resolved (revoked lease yields all-false capabilities, which forces walk fallback). search-provider-staleness-contract — withdrawn, my error: the completeness contract was already documented on SearchCapable::search_provider (fs/search.rs:127-137). committed-criterion-output — still stands as written, not reposted per no-repeat rule.
  • Verified this round: generation ordering in search_provider (mountable.rs:540-556), gen check in search (:524), bump sites (:362, :409), Err→walk fallback in both callers, SearchCapabilities::default() is all-false, docs hunks. Full base-to-head diff reviewed via 200 KiB fetch. No build/test run per project rules.

@chaliy
chaliy merged commit 51f1f85 into main Nov 3, 2026
57 checks passed
@chaliy
chaliy deleted the claude/project-thread-w2g6tc branch November 3, 2026 06:08
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.

1 participant