Skip to content

perf: parser, metric, output, and CLI batch (#1098, #1100, #1107-#1111, #1115, #1118) - #1134

Merged
dekobon merged 19 commits into
mainfrom
fix/batch-2026-07-30
Jul 31, 2026
Merged

dekobon merged 19 commits into
mainfrom
fix/batch-2026-07-30

Conversation

@dekobon

@dekobon dekobon commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Second batch off the #1105–#1127 performance audit: nine issues fixed
across the parser, metric, output, and CLI layers, on one integration
branch.

Issue Change
#1118 Reuse one tree-sitter Parser per thread instead of rebuilding and rebinding it per file
#1111 Skip the modeline regex scan when the file extension already decides the language
#1110 Serialize Ops through a borrowed projection rather than rebuilding and re-sorting per-space vocabulary strings for every ancestor
#1109 Store per-space loc line sets as a bitset instead of merging element-wise up the space stack
#1108 Hash the Halstead operator map by kind_id rather than through SipHash on the per-node path
#1107 Share indirect-include closures and borrow macros, retiring an O(V²) time-and-owned-memory walk
#1100 Width-gate the exclude_tests attribute scan and scale the forward scan by depth, retiring the O(attributes × depth) climb
#1115 Buffer every file and terminal output path; structured and per-line output no longer writes through unbuffered handles
#1098 Exit 1 on unreadable input for every subcommand, not just check

Supporting refactors and test work ride along: the parser cache is
extracted with its reuse guarded, the observation counters are shared,
the stdout lock is tied to a stdout sink, the terminal dump buffer is
bounded, wire's Rust-only test imports are scoped to their test, and
the tools/parser fixtures are routed through one selector.

CHANGELOG.md is updated in-branch (75 lines under ## [Unreleased]).

Scope

60 files, +6,549 / −1,094. New test files include
tests/parser_reuse.rs and src/wire_ops_tests.rs.

Note on validation

I opened this PR at the request of the repository owner; the commits
are from a separate working session, so I have not personally run
make pre-commit over all 18 of them. What I did verify before
publishing: the working tree is clean, the branch is 18 commits ahead
of main with no merge conflicts, and cargo check --workspace --all-targets --all-features completes with zero warnings and zero
errors
. CI's full 42-check matrix is the real gate here and runs on
this PR.

Two incidental observations from that check, neither affecting this
branch: an untracked lossy_probe.rs exists in the working directory
but is not tracked and so cannot reach CI, and editor diagnostics
reporting dead code in src/ops.rs were stale — no such warning
reproduces against the committed tree.

Merge note

Each fix carries its own single Fixes #N trailer rather than one
multi-issue trailer, so all nine issues auto-close on merge. A single
Fixes #a #b #c line would close only the first.

dekobon added 18 commits July 30, 2026 21:44
`guess_language` ran the Emacs/Vim modeline scan on every file — up to
20 regex executions plus a lowercased `String` — even though every arm
of its extension branch returned the extension's language, including
the "modeline agrees" arm where the two are equal by construction. The
extension lookup also allocated a lowercased copy unconditionally.

Resolve through a lazy `or_else` chain instead, so the modeline runs
only when the extension resolves to nothing and the shebang only when
both do. The extension is now borrowed when it is already ASCII
lowercase; the `is_ascii` guard is load-bearing because
`str::to_lowercase` folds U+212A onto ASCII `k`.

Precedence and the public `(Option<LANG>, &'static str)` result are
unchanged. Measured over a 53 KB buffer with a resolving extension:
824.4 ns/call before, 16.4 ns/call after. End to end this is below the
noise floor — `bca metrics -p src` is 524 ms +/- 3 ms for ~360 files.

Fixes #1111
`Tree::new` built a fresh `tree_sitter::Parser` for every file. It now
borrows one from a thread-local slot, rebinds the grammar, parses, and
puts it back.

The issue's stated cause was wrong, and measuring first changed the
design. `Parser::new()` + `set_language()` costs 0.27-0.75 us against a
200-350 us parse of a 4 KiB file — 0.1-0.2%, not "a measurable per-file
constant". What reuse actually saves is re-growing the parser's lexer,
GLR stack, stack-node pool, subtree pool and scratch arrays, which
`set_language` and `parse` reset without releasing.

Two consequences for the plan the issue proposed:

* Keying the cache on `LANG` to skip `set_language` buys nothing —
  rebinding on every parse measures identically to binding once, and
  `ts_language_copy` is a no-op for the static grammars we ship. So the
  slot caches the parser only, and the stale-grammar failure mode never
  becomes representable.
* `Parser::reset` is redundant. `ts_parser_set_language` opens with
  `ts_parser_reset`, and `Tree::new` always parses with `None` as the
  old tree.

The parser is moved out of the slot for the duration of the parse, so a
re-entrant call builds its own rather than observing one mid-parse. Both
accesses use `try_with`: a parse issued from another thread-local's
destructor can run after this slot is destroyed, where `LocalKey::with`
aborts the process ("thread local panicked on drop") rather than
failing. That path is reached in practice — swapping `try_with` for
`with` turns the teardown test into a SIGABRT.

Caching a parser across files is sound because the tree does not depend
on it: `ts_tree_delete` releases subtrees through a pool of its own and
holds its own grammar handle, so a tree outlives both the parser and the
thread that produced it.

Measured, honestly: -2.5% of in-situ parse time (median of 9 paired
runs, faster in 8 of 9) over 8,792 files averaging 3.3 KiB; 8-9% in a
parse-only loop where the buffers stay cache-hot. End to end it is under
the noise floor — a `nocache` control build, semantically identical to
the old code, differed from it by 2.2% on wall clock, which is larger
than the effect. Retention is bounded by tree-sitter's own pool caps
(32 subtrees, 50 stack nodes), so a worker holds a few KiB whatever it
parses. `make bench-scaling`: 18/18 probes green, no class change.

The baseline refresh records `src/node.rs` `loc.sloc = 800` for the soft
headroom tier. It also corrects pre-existing line-number drift for two
`src/metrics/cognitive.rs` entries left stale by daccb6f.

Fixes #1118
Review of the #1111 / #1118 wave found two problems worth fixing before
the batch continues.

The parser-cache machinery pushed `src/node.rs` to `loc.sloc = 800`
against a cap of exactly 800, so the next production line added anywhere
in the file would have turned `make self-scan` red for everyone, and the
baseline entry recording it could not absorb that. Moving the cache to
`src/node/parser_cache.rs` drops the file to 745, retires the baseline
entry, and gives the cache a home for its own unit tests.

Nothing observed the reuse itself. Deleting the write-back in
`parse_on_scratch_parser` reduces the cache to a no-op, and all five
tests in `tests/parser_reuse.rs` still pass, because every one of them
compares parse *output* against a fresh-parser reference. Verified by
perturbation: with the write-back removed, those five stay green and
only the two new construction-count tests fail. The counter sits on the
slot-miss path, which runs once per thread, so it costs nothing per file.

Also from review:

* `lang_from_ext` was byte-identical to `get_language_for_file`'s body;
  `guess_language` now calls the public function directly.
* `lowercase_ext` folds with `to_ascii_lowercase` past the `is_ascii`
  guard, where the input is ASCII by construction.
* `get_regex` uses `captures` rather than `captures_iter().next()`.
* The `parse` invariant now records that it holds only because nothing
  sets a timeout, cancellation flag, or included ranges — `set_language`
  resets parse state but not those, and the parser is no longer freshly
  built.
* `ParseOnDrop` records into an `AtomicBool` checked after `join`; an
  assertion inside a thread-local destructor aborts the process instead
  of reporting a named failure.
* `assert_parsed_cleanly` checks for a function node rather than a
  magic-number s-expression length.
The reference tree and the tree under test were selected by two separate
`if lang == LANG::Rust` expressions, so a third language would have had
to be added in both places; getting one wrong compares a Rust tree to a
TypeScript reference, which fails for a confusing reason. Both now go
through `fixture`.

`threads_are_isolated_and_trees_outlive_their_thread` was also the one
test computing references without running `assert_parsed_cleanly` over
them, leaving its comparisons able to pass against two equally broken
trees.

`assert_parsed_cleanly` takes the `LANG` rather than a hand-written
label, which `Display` already provides.
The test audit found that both wave-1 optimizations were unobserved.

The parser-cache tests called `parse_on_scratch_parser` directly, so
nothing checked that production still reaches it: reverting `Tree::new`
to its pre-#1118 body left all 3,143 lib tests and all 5 integration
tests passing. A test now drives the public `Ast::parse` seam and counts
constructions, and is the only failure under that revert.

`guess_language` had the same gap one level over. Every assertion pins
what it returns, and all of them hold if the modeline scan runs eagerly
and is discarded — which is the work #1111 removed. A thread-local scan
counter closes it; re-eagering the chain fails that test alone, 1 of
3,145. The counter is unconditional so tests observe the production
path, and the prose around it was tightened to keep `src/tools.rs`
inside the soft headroom tier rather than spend a baseline entry on it.

Also from the audit:

* `guess_language_unrecognised_modeline_keeps_extension` used `foo.c`,
  whose extension short-circuits, so it never reached the branch it
  named. Renamed, and given the two inputs that do reach it.
* The parser-cache tests are gated on the `rust` feature, as their
  integration sibling already was; without it they failed at `expect`
  under `--no-default-features` rather than being skipped.
* `each_thread_builds_its_own_parser` relied on iterator laziness to
  serialize its threads. That property is load-bearing — a global cache
  is only deterministically observable across consecutive threads — so
  the loop is now spelled out and the reason recorded.
`HalsteadMaps::operators` is keyed by a tree-sitter `kind_id` — a
`u16` grammar symbol this crate generated, drawn from an alphabet of
at most 639 values (mozcpp, the largest grammar here). Hashing it with
keyed SipHash-1-3 buys nothing and costs a full keyed round on a path
that runs once per non-primitive operator node. That is the case
`src/int_hash.rs` was introduced for in #1069; Halstead's map was
missed at the time. Move it onto `IntKeyHashMap` and add the
`write_u16` override the map's key width needs, so the probe does not
fall into `Hasher::write`'s byte-at-a-time loop.

The issue's other two proposals are declined, with the reasoning
recorded in the module docs:

- A dense `Box<[u64]>` indexed by `kind_id` would be slower here, not
  faster. Measured over the three corpus repositories, the median
  space holds 8 distinct operators and the 99th percentile 40, against
  grammars of 16-639 symbols — so a per-space dense array would zero
  and rescan several hundred slots per space, and again per `merge`,
  to replace ~10 map entries.
- `primitive_operators` and `operands` stay on SipHash. Their keys are
  identifiers and literals lifted verbatim out of the file being
  analysed, which for this crate is untrusted input: `bca` runs over
  whatever a repository contains and `bca-web` accepts source in a
  request body. Hash-flooding resistance is load-bearing there.

Metric values are unchanged — `merge` and `finalize` are both
order-independent, and the one place operator iteration order escapes
into output (`bca ops`) has sorted since #1091. No submodule snapshot
moved.

`make bench-scaling` holds: `halstead/nested-paren` 1.00,
`halstead/nested-not` 0.99, all eighteen probes ok.

Fixes #1108
`Ploc::lines` and `Cloc`'s two comment-line sets were
`IntKeyHashSet<usize>`, and merging a child space into its parent
re-inserted every element one at a time. A row inside D nested spaces
therefore cost D hash probes on the way up, and the sets stay resident
in the returned `FuncSpace` tree.

Replace them with `LineSet`, a word array offset by the first row
inserted. Merge is a word-wise `|=`, cardinality a popcount sum, and
`cloc()`'s two-set union is inclusion-exclusion over the shared words.
Semantics are unchanged: the fold is still a set union, so a line shared
by sibling spaces counts once in the parent.

The offset is derived from the data rather than from the space's
`[start_row, end_row]` span. Instrumenting `compute_minmax` showed no
parsed space records a row outside its span across pdf.js, DeepSpeech
and serde, but the span is not yet known when the first row arrives and
`Stats::with_cloc_sloc` records rows past it deliberately — a
span-anchored offset would drop such a row silently instead of failing.

`IntKeyHashSet` had no other user and is removed.

Measured: the new `loc/nested-fn-rows` scaling probe fits 2.11 before
and 1.10 after (bound 1.50); at depth 4000 its cost relative to the
`nom/nested-fn-rows` control falls from 39.4x to 1.36x. Over the three
corpus repositories (14 438 files, 4.03M rows, 234 605 spaces) the merge
path sheds 7 547 304 hash inserts, and the retained set payload drops
from ~170 MB to ~5.8 MB. On `corpus/walk/loc` the effect is below this
host's noise floor: 11 interleaved rounds gave a median after/before of
1.003 with 5/11 rounds faster, while the `corpus/walk/nom` control moved
just as far (0.990, 6/11) — real code nests spaces at most nine deep, so
the merge is a small share of that walk.

Fixes #1109
`serde_json::to_string(&ops)` built a full owned `wire::Ops` clone first,
and `Ops` carries a `Vec<String>` vocabulary per space. `finalize` merges
each child space's Halstead maps into its parent, so a parent's
vocabulary is a superset of every descendant's and the clone re-copies an
entry once per enclosing space. On a hundred-level nest of functions with
distinct identifiers that clone was 80% of the call: 0.445 ms -> 0.082 ms.
On a tree past `MAX_SPACE_SERIALIZE_DEPTH` it was *all* of it, because the
whole tree was cloned and then dropped unserialized: 109 ms -> 0.007 ms at
2 000 levels. `Ops` now serializes through a borrowed mirror in the new
private `wire::ops_view`; `to_wire()` still returns the owned projection.

`compute_operators_and_operands` sorted a freshly built `Vec<String>`.
Sorting the borrowed keys and rendering afterwards keeps each swap to a
fat pointer and lays the `String`s out in output order, which every later
pass reads sequentially: `Ast::ops` on the same shape is ~22% faster at
depth 2 000 (760 ms -> ~595 ms) and `to_wire` ~52% (108 ms -> ~51 ms).
The two orders differ only when a key is not valid UTF-8, since the lossy
rendering is not order-preserving, so a vocabulary that actually lost
bytes is re-sorted on the rendered text.

`ops_inner` also classified a space kind for every node and used it only
when a space opened, where `spaces::compute::open_func_space` has
classified after the decision since #522. Hoisted. The effect is below
this host's noise floor, so a thread-local counter is what pins it.

Output is unchanged: `bca ops` over `src` and `tests` is byte-identical
to the pre-change binary across 59 120 files in JSON, YAML, TOML and
CBOR, and the human-readable tree agrees as a multiset (its file order
has always been non-deterministic under the parallel walk).

Adds an `ops/nested-fn` probe to the scaling gate, the first to run
`ops_inner` at all: 1.06 before, 1.03 after, both linear. `Probe` now
carries a `Workload` so a probe can drive `Ast::ops` as well as
`Ast::metrics`. The probe covers the walk, not the vocabulary — `Ops`
publishes a `Vec<String>` per space, so a shape with distinct identifiers
per level has quadratic *output* and no implementation fits under a
linear bound. That is why the issue's interning proposal is not here: it
needs a shared representation in the public type, i.e. a 3.0 break.
`docs/development/benchmarking.md` records the reasoning.

Fixes #1110
`serializing_ops_builds_no_owned_projection` is gated on the `rust`
feature but its three imports sat at module scope, so a
`--no-default-features` build warned on all three. `make pre-commit`
does not run that leg; CI does.
Review of the wave-2 fixes (#1108, #1109, #1110) found one performance
regression, two vacuous tests, and four independently-grown copies of
one idiom.

`Ploc`/`Cloc`/`Stats::compute_minmax` called their accessors twice.
Those were `HashSet::len()` — O(1) — before #1109; `ploc()` is now a
popcount over the word array and `cloc()` is three array scans, so
`Cloc::compute_minmax` did six scans per space where it used to do two
O(1) reads. Bound once, it does three.

`insert_range_ignores_an_inverted_span` did not cover its guard: both
endpoints sat in one word, where the mask arithmetic already yields
zero, so deleting `if end < start { return; }` left all 13 tests green.
The guard only bites across a word boundary, where the interior fill
slices `words[2..0]`. With a cross-word case the deleted guard is the
only failure in the suite. Nothing called `insert_range` on a non-empty
set either, leaving both growth branches — the ones the "reserve both
ends first" comment defends — unreached; a seeded case now covers them.

Four thread-local counters existed so a test could observe an
optimization that changes no output, written independently in three
dialects. Their shared invariant — the counter and its bump are
unconditional, only the accessor is `cfg(test)` — was enforced by
nobody, and gating the counter would leave the test measuring a build
production never ships. `observation::counter!` emits the three items
together so that cannot happen. Per-site rationale stays above each
invocation.

Also: the `insert_range` guard comment named the wrong justification,
`LineSet`'s density claim was too absolute for a blank-line-heavy file,
`len()`'s new O(words) cliff and `reserve`'s asymmetric growth cost were
undocumented, `classify_space_kind` read as claiming parity with
`open_func_space` (they diverge — #1130), `STABILITY.md` did not say
that ops ordering is over the rendered strings, and `lib.rs` still
listed `metrics::loc` as an `int_hash` consumer.
`Checker::should_skip_subtree` reached `Node::previous_sibling` to
walk backward across the run of `#[...]` siblings before an item.
`ts_node__prev_sibling` opens with `ts_node_parent`, which tree-sitter
resolves by descending from the root, so the scan was
`O(attributes x depth)` per item and quadratic over a nested file.

The hook now takes the `Ancestors` chain the walker already carries
and, under a parent narrow enough for it to be the cheaper bound,
reads the attribute run forward from that parent instead. Measured
here, a cursor sibling step costs ~68 ns against ~420 ns for a whole
`previous_sibling` on a shallow node, and that 420 ns did not move
with the sibling index -- so reading forward unconditionally would
have traded the depth quadratic for a width one (a generated file of
2_000 top-level attributed items went 6.0 ms -> 569 ms). Above the
measured break-even the backward walk stays.

No metric value moves: `rust_outer_attr_scans_agree` compares the two
readings node by node across every attribute shape, and an
`observation::counter!` on `Node::previous_sibling` is what makes a
revert fail rather than merely run slower.

`Workload::Metrics` in the bench harness gains an `exclude_tests`
flag, which unblocks two probes that had no way to reach their hot
path: `nom/nested-attributed-fn` (2.00 -> 1.04 fitted, 2.05 s ->
8.1 ms at depth 4_000) and `nom/nested-cfg-predicate`, the guard
#1105 deferred here.

Fixes #1100
`record_indirect_includes` ran a fresh petgraph `Dfs` per file over the
already-collapsed include graph, so every shared header was re-visited
once per file that reached it. It now computes each node's closure once,
in reverse topological order over the cycle-free graph, merging its
successors' finished index sets; the per-file walk is kept as the
fallback for a graph `toposort` cannot order, so a violated assumption
costs time rather than correctness.

`get_macros` deep-cloned a file's whole transitive macro set on every
C/C++ parse and allocated a `PathBuf` per indirect include just to probe
a `PathBuf`-keyed map. `Parser::new` now goes through a crate-internal
`visible_macros`, which borrows the names and keys the map with
`Path::new`; the published `get_macros` shape is unchanged.

Measured on the 10_918-file DeepSpeech corpus (V=10_039, E=46_773,
732_585 closure entries), both paths compiled into one binary:
`record_indirect_includes` 126.6 ms -> 83.0 ms (median of 3), and the
macro set per full pass 292 ms -> 195 ms with 2.37 M fewer allocations.
Closures are byte-for-byte the same across all 10_024 files.

Not shipped, deliberately: storing closures as interned ids, bitsets, or
`Arc`s. `PreprocFile::indirect_includes` is a published, serialized
`HashSet<String>`, so materializing one owned string per reachable pair
is fixed until 3.0 — and it is two thirds of what this pass costs.

Fixes #1107
Review of the wave-3 fixes found a cliff left by #1100, a latent bug in
#1107, and a test that could not fail.

#1100 dispatched on parent width alone, so one child past the threshold
returned the full O(depth^2) walk — a module body holding five items
instead of four, nested a few hundred deep, kept the quadratic. The
budget now grows with the depth the walker already knows. The factor is
3, measured rather than assumed: the marginal cost of one level inside
`previous_sibling` is ~120 ns against ~35 ns per cursor step, a ratio
near 3.4. At depth 3200 a width-7 parent goes 2.671 s to 0.056 s, and
nothing measured is slower — a 20 000-module shallow file and a
400-child parent are both flat. Reverting to width-only fails the two
new tests and nothing else.

`merge_sorted_ids` never cleared its output buffer; correctness rested
on its one caller doing it. A second caller would have produced a
corrupt union silently. It clears its own buffer now, and the new test
seeds a non-empty one — the existing test seeded from empty, so it
could not have caught this.

The preproc fixture asserted `cyclomatic_sum() == 3` to prove the
C-family macro masking ran. It proves nothing: `$` is an identifier
byte in tree-sitter-cpp, so `DBG` and `$$$` give identical metrics.
Deleting the whole `LANG::C | Cpp | Mozcpp` arm from `get_fake_code`
left the test green. Equal-length macro names now collapse to one
masked operand, so `unique_operands()` reads 3 masked against 4
unmasked, and deleting that arm fails the test.

Also from review: the threshold boundary had no fixture (widths ran
{1,2,4,5,8,9,65,400}, so neither 6 nor 7 was covered, and no assertion
recorded which arm ran); `src/preproc.rs` sat at exactly its 760-sloc
soft cap, where one added line turns the gate red for everyone, and is
now at 757; the scaling report told the reader `nom/nested-fn` was the
only probe leaving `exclude_tests` off, when 21 do; and
`benchmarking.md` claimed the width axis was pinned by unit tests that
only pin agreement between the two readings, never cost.
`#1060` wired the read-failure counter into `bca check` only. Every
other walking subcommand left `Config.read_failures` at `None`, so an
input file that could not be read printed `error processing <path>: …`
on stderr and the process still exited 0 — a metrics document, report,
node count, or exemptions audit silently missing files.

Move the guard into the shared walk layer instead of repeating it per
runner: `read_failures` becomes unconditional, the single
`ConcurrentRunner` seam returns the tally, and `run_walk` /
`run_walk_resolved` / `run_walk_collecting` enforce it. That covers
`metrics`, `ops`, `report`, `functions`, `find`, `count`, `dump`,
`exemptions`, `preproc`, and `strip-comments` — which the issue's list
missed — and cannot be forgotten by a future subcommand.

The contract is "any read failure", not "produced nothing at all",
matching `check` and the documented meaning of `EXIT_TOOL_ERROR`. The
narrower rule would miss exactly the cases that matter: a partial
`--output` aggregate looks complete, and a `diff --since` side that
lost a file reports it as added or removed rather than as I/O failure.

`diff --since` returns `DiffError::UnreadableInputs` rather than
exiting, so its two temp trees still unwind, and names the side.

Fixes #1098
Every file destination handed the serializer a raw `std::fs::File`, and
the terminal dumps wrote through a `StandardStream` (a `LineWriter`), so
the incremental serializers issued one `write(2)` per structural token
and the tree renderers one per line. A 165-file `--output-dir` run cost
4,757,028 writes; the 12 MB `--output` aggregate 4,757,194.

One seam in `formats.rs` now wraps both destinations — file and stdout —
in a 64 KiB `BufWriter` and flushes it explicitly. The flush is the
correctness half: a `BufWriter` drained only by `Drop` discards the error
it hit, turning a full disk into a truncated document and exit 0.
`check_format.rs`, the three `vcs` CBOR arms, and the `vcs` CSV writer
route through it; `emit_check_results` buffers its stderr lock and
flushes before the step-summary and `process::exit` paths.

`open_file`'s eager `create_dir_all` before every `File::create` becomes
a retry on `ErrorKind::NotFound`, and the four terminal dumps render into
a `termcolor::Buffer` printed under the stdout lock in one `write_all`,
which keeps a parallel walk's per-file trees atomic as `StandardStream`
did. `bca dump`/`find` hold the stdout lock across their banner so the
wider render window cannot detach it from its tree.

Measured (pdf.js/src): --output-dir 4,757,028 -> 303 writes
(1.55s -> 0.13s), --output aggregate 4,757,194 -> 197 (3.36s -> 0.20s),
metrics text tree 1,524,444 -> 165 (0.47s -> 0.14s), ops text tree
170,560 -> 169, check sarif 5,622 -> 16, check stderr 741 -> 4.
Output is byte-identical in every format and on every destination.

Fixes #1115
Review of the wave-4 fixes found one regression this batch introduced,
one gap it left open, and an untested half.

#1115 rendered each terminal dump into one in-memory buffer, and the
rendered document is O(nodes x depth), not proportional to the source
as its comment claimed. A 16 KB file of nested parens went from 12.7 MB
resident, streamed, to 545 MB. `print_to_stdout` now emits every 64 KiB
through an injectable sink, taking the stdout lock at the first
emission and holding it to the last so a multi-chunk document stays
contiguous. The same file now peaks at 21.3 MB, and a 16-worker
DeepSpeech dump falls from 430 MB to 269 MB. Output is byte-identical.

The library half of #1115 was also untested: reverting all five files
to the unbuffered form failed nothing, because `print_to_stdout`
hard-coded its destination where `write_flushed` is generic over its
sink. It now takes a `ColorSink`, and write-count tests fail under both
a write-through revert and a dropped chunk flush.

`write_flushed`'s doc claimed the explicit flush folds a full disk back
into the returned `Result`. It does — and the walk consumer then
printed the error and dropped it, so `metrics -O json --output-dir`
onto a read-only directory wrote nothing and exited 0. That is the
mirror of the read-failure gap #1098 just closed, so it uses the same
seam: write failures are tallied and `enforce_complete_walk` covers
both. `BrokenPipe` still exits 0.

`check`'s "not optional" flush was decoration — `BufWriter::drop`
flushes, and the adjacent `drop(stderr)` is what orders the streams.
Perturbation confirms neither line alone is load-bearing. The redundant
flush is gone and the comment now names the real cost: a panic before
the drop loses the report, because the drop skips its flush when
panicking.

Holding the stdout lock across banner and tree fixed a real
interleaving under `--jobs N` (5 interleavings in 3 runs before, 0
after) but shipped with no test; it has one now.

`vcs_command.rs` sat at exactly its baselined 815 sloc, where the next
comment line would go red for everyone. The CBOR-to-file arm was
triplicated verbatim across the three vcs files while its error message
already lived in `formats.rs`; hoisting that and its three siblings
takes the file to 787, under the hard limit with real margin.
Record the nine issues fixed in this batch. The two exit-code entries
are called out as CI-affecting: a run that previously tolerated an
unreadable input, or an unwritable output, now fails.
`ColorSink::exclusive` hands back a `StdoutLock`, which is the right
guard only for a writer pointed at stdout. The impl sat on
`BufferWriter`, and `BufferWriter::stderr` has the identical type, so a
future stderr caller would have been handed stdout's lock — excluding
the wrong writers while leaving two stderr renders free to interleave.

A `StdoutSink` newtype makes that pairing unrepresentable rather than
merely unused today.
@codecov

codecov Bot commented Jul 31, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.65579% with 41 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.15%. Comparing base (53e4c62) to head (6543d00).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/preproc.rs 84.90% 14 Missing and 2 partials ⚠️
src/output/color.rs 91.61% 3 Missing and 11 partials ⚠️
src/checker.rs 96.90% 6 Missing ⚠️
src/metrics/loc.rs 98.87% 0 Missing and 1 partial ⚠️
src/ops.rs 99.02% 0 Missing and 1 partial ⚠️
src/output/dump.rs 90.90% 0 Missing and 1 partial ⚠️
src/output/dump_metrics.rs 75.00% 0 Missing and 1 partial ⚠️
src/output/dump_ops.rs 75.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1134      +/-   ##
==========================================
- Coverage   98.18%   98.15%   -0.03%     
==========================================
  Files         272      276       +4     
  Lines       68345    69223     +878     
  Branches    67915    68793     +878     
==========================================
+ Hits        67107    67949     +842     
- Misses        848      873      +25     
- Partials      390      401      +11     
Flag Coverage Δ
rust 98.14% <96.65%> (-0.03%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/c_macro.rs 98.12% <100.00%> (ø)
src/checker/rust.rs 92.85% <100.00%> (ø)
src/function.rs 87.50% <100.00%> (-0.31%) ⬇️
src/int_hash.rs 98.85% <100.00%> (+0.23%) ⬆️
src/metrics/halstead.rs 99.90% <100.00%> (+<0.01%) ⬆️
src/metrics/loc/line_set.rs 100.00% <100.00%> (ø)
src/metrics/loc/shared.rs 100.00% <100.00%> (ø)
src/node.rs 95.79% <100.00%> (+0.17%) ⬆️
src/node/parser_cache.rs 100.00% <100.00%> (ø)
src/observation.rs 100.00% <100.00%> (ø)
... and 14 more

... and 14 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Patch coverage on #1134 trailed project coverage (96.19% against
98.15%), so the added-but-uncovered lines were enumerated and triaged
rather than chased as a number. Of 85 such lines, most are not worth
covering and some cannot be: ten in `preproc.rs` are a fallback the
code itself documents as unreachable, four more are defensive early
returns, and nine sit inside `#[cfg(test)]` as `assert_eq!` message
arguments, which Rust evaluates only when the assertion fails — they
are uncovered precisely because the tests pass.

What was worth covering is the output path, where a defect reaches
users as a corrupt document. `dump_aggregate` now round-trips through
all four formats in both pretty and compact spellings, with the TOML
`files` wrapper asserted by deserializing through a struct that names
the key, so dropping the wrapper fails where a bare validity check
would not. `dump_csv_aggregate` is pinned to emit the shared header
exactly once for many files, which is the corruption the function
exists to prevent. The pretty flag gets its own test because both
spellings parse back to the same value: the discriminating check is
that exactly one of them is multi-line.

Also cover `ChunkedSink`'s `WriteColor` delegation, added here for
bounded terminal output, and `LineSet`'s hand-written `Debug`.

Every assertion was perturbation-verified. One did not survive that:
asserting the color test with `contains("\u{1b}[")` still passed when
`set_color` dropped its spec, because `reset` emits an escape of its
own. The assertion is now positional — an escape before the styled
word and another between the words — so all three delegated methods
fail independently.

Uncovered added lines drop from 85 to 55.
@dekobon
dekobon merged commit 92707de into main Jul 31, 2026
44 checks passed
@dekobon
dekobon deleted the fix/batch-2026-07-30 branch July 31, 2026 19:06
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