Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .bazelrc
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,20 @@ common:asan --action_env=UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1:symbol
common:asan --test_env=ASAN_OPTIONS=halt_on_error=1:print_stacktrace=1:symbolize=1:color=always
common:asan --test_env=UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1:symbolize=1:color=always

# --config=tsan : ThreadSanitizer, run with `--config=clang --config=tsan`. Mutually
# exclusive with asan (a separate matrix cell), so the parallel walk's data-race
# coverage lives here. Mirrors the asan block; the `tsan` feature drives the
# hermetic toolchain and the explicit -fsanitize copt/linkopt keeps it portable.
common:tsan --config=symbolizer
common:tsan --copt=-g
common:tsan --copt=-fsanitize=thread
common:tsan --copt=-fno-omit-frame-pointer
common:tsan --copt=-DTHREAD_SANITIZER
common:tsan --linkopt=-fsanitize=thread
common:tsan --build_tag_filters=-no_san # skip targets tagged no_san
common:tsan --test_tag_filters=-no_san
common:tsan --features=tsan
common:tsan --action_env=TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1:symbolize=1:color=always
common:tsan --test_env=TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1:symbolize=1:color=always

try-import %workspace%/.bazelrc.user.end
21 changes: 20 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,31 @@ jobs:
python-version: "3.13"
- uses: pre-commit/action@v3.0.1

# ThreadSanitizer for the parallel walk (issue #43; mutually exclusive with the
# asan cell). ubuntu-only and repo-cache-only (no multi-GB disk cache) so the
# total Actions cache stays under GitHub's 10 GB limit next to the asan caches;
# the instrumented objects rebuild each run.
tsan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: bazelbuild/setup-bazelisk@v3
- name: Mount Bazel repo cache
uses: actions/cache@v4
with:
path: ~/.cache/bazel-repo
key: bazel-repo-tsan-${{ hashFiles('MODULE.bazel.lock', '.bazelversion') }}
restore-keys: |
bazel-repo-tsan-
- name: bazel test
run: bazel test //... --repository_cache=$HOME/.cache/bazel-repo --config=clang --config=tsan

# Single required status check: one job that gates merge on the whole matrix,
# so branch protection needs only `done`. A yq/jq step self-checks that every
# workflow job is wired into `needs`, so a newly added job cannot silently
# escape the gate. Mirrors helly25/mbo.
done:
needs: [test, pre-commit, trunk]
needs: [test, pre-commit, trunk, tsan]
if: always()
runs-on: ubuntu-latest
steps:
Expand Down
20 changes: 20 additions & 0 deletions docs/design-parallel.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,26 @@ output (when asked for), the `-prune`/`-quit`/`-depth` control semantics, and th
exit-code model. The VFS layer is already contractually safe to call from many
threads (`vfs::FileSystem`), so the foundation is ready.

## v1 scope (what shipped first)

The first implementation (engine PR for #43) takes three deliberate simplifications
of the design below; each has a noted follow-up:

- **The worker pool parallelizes `readdir`+`lstat` only.** The visitor (evaluate +
emit + exec + capture + summary) runs on the single coordinator thread in `--sort`
order, so that whole pipeline stays single-threaded and unchanged (`run.cc`
untouched) - the only new concurrent code is the pool, whose jobs are pure
(path -> stat'd listing) and touch just the thread-safe VFS and a mutex-guarded
queue. Per-worker parallel evaluation is a later option if profiling wants it.
- **The ordered modes are deterministic.** Siblings are consumed in sorted order
(reads still overlap via prefetch), so `dir`/`subtree`/`tree` are reproducible
across runs and machines. Streaming subtrees strictly by completion order (lower
latency, nondeterministic) is a later refinement.
- **Parallelism and sort are opt-in:** `-j`/`--jobs` and `--sort` (default stays
sequential + `none`, find-compatible). The mode-scoped auto-defaults (modern ->
parallel + `dir`; find/fd/rg -> all cores + `none`) land with the mode mechanism
(#54), which is where a persona can be queried.

## Architecture

A bounded **worker pool** over directories, with a single **emission-ordering
Expand Down
43 changes: 38 additions & 5 deletions xff/engine/run.cc
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
#include "xff/engine/run.h"

#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <map>
Expand All @@ -26,6 +27,7 @@
#include <vector>

#include "absl/status/status.h"
#include "absl/strings/numbers.h"
#include "absl/strings/str_cat.h"
#include "absl/time/time.h"
#include "xff/datetime/datetime.h"
Expand Down Expand Up @@ -106,21 +108,51 @@ SymlinkMode ResolveSymlinkMode(const std::vector<std::string>& globals) {
return mode;
}

// xff --sort[=name|none]: order siblings by name within each directory for
// deterministic output, or keep readdir order (none / absent). Bare --sort means
// --sort=name. Leading global, last occurrence wins.
// xff --sort=none|dir|subtree|tree: per-directory sibling ordering for the walk
// (see docs/design-parallel.md). `none` keeps readdir order (find's default);
// `dir` sorts each directory's listing; `subtree` adds contiguous subtrees;
// `tree` is a total path order. Bare --sort and the legacy `name` mean `dir`.
// Leading global, last occurrence wins.
SortOrder ResolveSort(const std::vector<std::string>& globals) {
SortOrder sort = SortOrder::kNone;
for (const std::string& global : globals) {
if (global == "--sort" || global == "--sort=name") {
sort = SortOrder::kName;
if (global == "--sort" || global == "--sort=dir" || global == "--sort=name") {
sort = SortOrder::kDir;
} else if (global == "--sort=subtree") {
sort = SortOrder::kSubtree;
} else if (global == "--sort=tree") {
sort = SortOrder::kTree;
} else if (global == "--sort=none") {
sort = SortOrder::kNone;
}
}
return sort;
}

// xff -jN / --jobs=N: worker threads for the parallel directory read-ahead (see
// docs/design-parallel.md). `1` (the default) is the sequential walk. Leading
// global, last valid occurrence wins; a non-positive or unparseable value is
// ignored. Mode-scoped auto-defaults (e.g. modern -> all cores) arrive with the
// mode mechanism (#54); until then parallelism is explicit.
std::size_t ResolveJobs(const std::vector<std::string>& globals) {
std::size_t jobs = 1;
for (const std::string& global : globals) {
std::string_view value;
if (global.starts_with("--jobs=")) {
value = std::string_view(global).substr(7);
} else if (global.starts_with("-j") && global.size() > 2) {
value = std::string_view(global).substr(2);
} else {
continue;
}
std::size_t parsed = 0;
if (absl::SimpleAtoi(value, &parsed) && parsed >= 1) {
jobs = parsed;
}
}
return jobs;
}

// xff --summary[=overall|type|ext]: reduce the matches to a count + total size
// table instead of printing each one. Bare --summary / =overall is one total row;
// =type groups by file type, =ext by filename extension; =none / absent is off.
Expand Down Expand Up @@ -445,6 +477,7 @@ int RunFind(const parser::Command& command, const vfs::FileSystem& fs, EmitFn em
WalkOptions options;
options.symlinks = ResolveSymlinkMode(command.globals);
options.sort = ResolveSort(command.globals);
options.workers = ResolveJobs(command.globals);
const render::Format format = ResolveFormat(command.globals);
const std::optional<std::string> tmpl = ResolveTemplate(command.globals);
// A -capture whose {capture.NAME} is never referenced ran a subprocess for
Expand Down
Loading
Loading