Repository navigation
perf: overlap the OSV query with extraction, and scan files once - #75
Conversation
Measured against a loopback registry, a 200-file review spends 717 ms on the wall clock and 45 ms of CPU. The gap is two things: a blocking OSV round trip in the critical path, and a serial evaluation loop in the ci lane. 1. The advisory query no longer blocks the review. resolve_advisories returns Ready for everything answerable without the network (recall hit, policy switch, warm cache, an ecosystem with no coverage) and Pending with the OSV request already running otherwise, so the tarball fetch, extraction and diff all proceed while the network works. join collects it at scoring time. The upstream disclosure behaviour is preserved verbatim: an Err stays a HIGH finding, an unverified report the operator accepted stays a MEDIUM one, and fail_closed is still checked before any stale fallback is served. 2. The ci lane reviews packages in parallel. One worker per package, capped at eight, each with its own ReviewContext and its own SQLite handle, since neither is shareable. Findings are emitted in candidate order rather than completion order, because CI output is diffed between runs. The budget check still refuses before any work starts. 3. Each file is read once through a 64 KiB window instead of being buffered whole for a hash, a NUL scan and a classify. The digest is unchanged, because it decides whether a file is compared at all, and peak memory stops scaling with the largest file in the package. The UTF-8 carry across the window boundary completes by the sequence's real length, and a carry still open at EOF is judged malformed, so a file ending mid-code-point no longer reads as text. 4. The dev profile emits line tables rather than full DWARF, and emits none for dependencies. Cargo's default is opt-level 0 with debug = 2, which at this crate's size is what accumulates a hundred stale incremental caches. Cargo.lock does not move. PERF.md carries the measurements, the commands that produced them, and what is still not known. scripts/perf_harness.py reproduces the runtime numbers and scripts/perf_watchdog.sh bounds the process count of a test run.
kridaydave
left a comment
There was a problem hiding this comment.
Reviewed against head 6141dba, checked out locally (git rev-parse HEAD matches gh pr view --json headRefOid). Single commit, so every finding attributes to it.
Gate: ./scripts/verify.sh → PASS Format,Clippy,Tests,References, exit 0 (792 lib tests). Toolchain caveat: no rustup on this box, so the 1.97.1 pin is inert and this built with Fedora 1.98.1.
Blockers
1. CHANGELOG.md is not updated. The PR touches 12 files; CHANGELOG.md is not among them. AGENTS.md: "After each merged PR, add an entry to CHANGELOG.md under [Unreleased] in the same branch."
2. The digest is load-bearing and has no test — I confirmed a surviving mutant.
scan_file (src/diff.rs:414) is new and replaces fs::read + hash + classify. The PR body states "The digest is unchanged, because it decides whether a file is compared at all" — that decision is src/diff.rs:120, if base_meta.hash != target_meta.hash. A digest that covered fewer bytes than it read would make base and target agree on a changed file and silently skip the comparison; nothing downstream would notice.
I mutated it to confirm the suite is blind:
// src/diff.rs:433, mutated
if len <= SCAN_CHUNK_BYTES as u64 {
hasher.update(chunk);
}Result: 792 passed, 0 failed. The whole suite is green with a digest that only covers the first 64 KiB window. Reverted and re-ran to confirm the restore (clean vs HEAD, 792 pass).
chunked_reads_agree_with_a_single_read_on_the_classification (src/diff.rs:676) asserts only kind, never hash; the sole hash token in the test module is a struct literal at src/diff.rs:735. One assertion closes it — on a file spanning several windows:
assert_eq!(scan_file(&p).unwrap().hash, Sha256::digest(&bytes).into());AGENTS.md: "A patch with no test that fails without it is a guess."
3. PERF.md is newly wrong about the tree it describes. Added whole by this PR (345 lines, no deletions), and:
- cites
src/sandbox.rsfive times (lines 254, 255, 259, 260, 342) — that file does not exist in this tree; - asserts "concurrency anywhere in
src/| none, no rayon, no par_iter" (line 90), which this PR contradicts; - opens with "Nothing here has been fixed" (line 3) while implementing items 1, 2 and 4 of its own §7;
- §7 item 3 promises "Add a
[profile.dev]… so old session caches are evicted rather than accumulating. Reclaims most of the 1.9 GB." TheCargo.tomladded setsdebugonly — noincremental = false, no cache bound. Smaller debuginfo shrinks each of the 101 caches; it does not stop them accumulating. The PR body's causal claim ("debug = 2… is what accumulates a hundred stale incremental caches") does not hold either — unboundedincrementalretention is what does that.
scripts/check-references.sh only resolves citations under references/, so none of this is caught by the gate.
Suggestions
4. The CI lane no longer short-circuits on the first package error. evaluate_candidates (src/ci.rs:423) drains slots in candidate order after every worker finishes, so a failure in package 3 does not stop package 97 from being reviewed. Previously the serial ? aborted at the first failure, so later packages were never fetched and never wrote to the store. On a failing run the store now receives record_verified and advisory-cache writes from packages the serial lane never reached. Both are evidence-only (clean = 0, idempotent), so this is low risk — but it silently widens what a failed run mutates in the trust anchor, and that deserves an explicit decision rather than falling out of the threading change.
5. A PendingAdvisory is dropped unjoined on every early-exit path between src/review.rs:130 and src/review.rs:282 — ctx.fetch_tarball(...)? (138), the checksum check (140), extract_for_ecosystem (150), prepare_extracted_root (164), store.record_verified(...)? (178), and baseline resolution. Each fires an OSV query whose answer and cache write are discarded. The doc comment at src/advisory.rs:164 covers thread safety but not the wasted round trip and lost cache fill.
6. The AUR lane is left serial. evaluate_aur_ci_diff (src/ci.rs:492) still loops evaluate_package one at a time, and PERF.md §7 item 2 named both loops. AUR entries are capped at 4096 and each does a git archive, so the worst case is real. The PR body's "The ci lane reviews packages in parallel" reads as unqualified.
7. Eight concurrent registry clients, no rate limiting or jitter. MAX_CI_REVIEW_WORKERS = 8 (src/ci.rs:359) means up to ~24 in-flight requests against the configured base, which --registry allows to be arbitrary. Fine for npm/crates.io; a small self-hosted mirror will see a burst.
8. Two expect() calls in new production code (src/ci.rs:472, src/ci.rs:483). Permitted by the repo's lint config (only unwrap is disallowed), but both assert an invariant that holds only because std::thread::scope re-panics before slots is drained. The message "every index is claimed before the scope ends" is load-bearing; a line on why it cannot fire would save the next reader the trace.
9. Public API break, no in-tree callers. pub fn fetch_advisories is gone, replaced by resolve_advisories returning a new AdvisoryResolution enum. src/lib.rs:7 re-exports pub mod advisory, so any downstream embedder breaks. Grep found no callers outside advisory.rs. Acceptable for a CLI-first crate; worth a CHANGELOG line once #1 is done.
Residual risk I could not close
- No mutation testing. AGENTS.md requires
--mutantsfor work touching extraction, integrity and policy;src/diff.rsandsrc/advisory.rsboth qualify, and blocker #2 is exactly the class of survivor it exists to catch. TheUtf8Scancarry (src/diff.rs:344–378) is intricate enough that I would expect more survivors there. - I traced the
Utf8Scancarry by hand and believe it correct —tailfrom anerror_len() == Noneerror is at most 3, sopending[..tail]cannot overrun[u8; 4];pending[0]is always a valid lead byte becauseerror_len() == Noneimplies a valid prefix; and thelen <= MAX_DIFF_FILE_BYTESskip beforefeedis safe because an over-cap file classifiesOpaqueTooLargeregardless. Reasoning, not a test run. - Toolchain mismatch, as above: 1.98.1 locally vs the pinned 1.97.1 in CI.
- The gate does not run the two dogfood jobs (this repo's own
Cargo.lockandpackage-lock.json); those need the release binary.
What is good
The advisory split is a clean design. Splitting fallback_or_fail from the policy check so fail_closed is captured at resolve_advisories and applied at join keeps the security ordering in one place, and moving the cache write into join genuinely is the right call — the worker never needs the connection, and the write it enables costs less than the reopen it avoids. The coverage_unknown policy-based distinction between "operator chose this" and "coverage hole discovered at runtime" is the right shape and is tested from both sides. The Pending drop-is-safe contract is documented and covered by a test. The windowed scan's NUL/UTF-8 short-circuit past the diff cap is correct for the reason stated in the comment.
Three blockers from the review of 6141dba, plus the suggestions that were cheap and correct. 1. CHANGELOG.md gained an [Unreleased] entry covering all four changes, including the fetch_advisories -> resolve_advisories API break, the CI drain-all-candidates behaviour change, and the AUR half staying serial. AGENTS.md requires this per merged PR. 2. The digest is now pinned. The review found a surviving mutant: guarding hasher.update behind len <= SCAN_CHUNK_BYTES left 792 tests green with a digest that only covered the first window, which would make base and target agree on a changed file and silently skip the comparison, since the hash decides whether a file is compared at all. the_windowed_digest_equals_the_whole_file_digest compares the windowed digest against Sha256::digest of the whole buffer at sizes crossing the window boundary at every offset of one, plus zero and an exact multiple. Verified both directions: fails under the mutant, passes on the real code. 3. PERF.md no longer lies about the tree it describes. It cited src/sandbox.rs five times — that file does not exist here — claimed zero concurrency in src/ which this branch contradicts, opened with 'nothing here has been fixed' while implementing items 1, 2 and 4, and promised item 3 would evict stale incremental caches when debug = line-tables-only only shrinks them. Each item now carries an IMPLEMENTED / PARTIALLY IMPLEMENTED / NOT APPLICABLE marker and the item-3 entry says what the diff does and does not deliver. Suggestions: the drain-all-candidates decision is documented in code as a deliberate choice with its evidence; the unjoined-PendingAdvisory cost on early-exit paths, the registry burst at eight workers, and the AUR lane still being serial are each documented where a reader will hit them.
|
Review addressed in a17a87c. All three blockers and every suggestion. Blocker 1 — CHANGELOG. Blocker 2 — the digest mutant. Confirmed and killed. Blocker 3 — PERF.md. Fixed on every count: the Suggestions.
Residual risk still open: mutation testing was not run on this branch — it was killed mid-run before these fixes and has not been re-run. Your mutant is closed by hand-verification above, but AGENTS.md wants |
# Conflicts: # references/drift.md
kridaydave
left a comment
There was a problem hiding this comment.
Verified against a17a87c, checked out locally. Gate: ./scripts/verify.sh → PASS Format,Clippy,Tests,References, exit 0 (793 lib tests). Restored clean vs HEAD before the final run.
Blockers — all three closed
1. CHANGELOG. Present and specific: the [Unreleased] entry names the fetch_advisories → resolve_advisories break and its blast radius, states that every security ordering is unchanged, and covers the drain-all and AUR-serial behaviour changes. This is the right level of detail for a breaking-change entry.
2. Digest mutant — killed, and I re-ran the check myself. the_windowed_digest_equals_the_whole_file_digest (src/diff.rs:722) asserts scan_file().hash against Sha256::digest of the whole buffer across 14 sizes: zero, an exact window multiple, and n, 2n, 2n+1 around each of SCAN_CHUNK_BYTES - 1, SCAN_CHUNK_BYTES, SCAN_CHUNK_BYTES + 1. I re-applied the same mutant and ran the gate:
test diff::tests::the_windowed_digest_equals_the_whole_file_digest ... FAILED
assertion `left == right` failed: digest differs from the whole-buffer hash at 131073 bytes
test result: FAILED. 792 passed; 1 failed
Fails for the stated reason, on a size that crosses the boundary, and the rest of the suite still passes under the mutant — so it is precisely scoped rather than incidentally green. Restored, confirmed clean vs a17a87c, gate green at 793.
3. PERF.md. Fixed on every count I raised. The five src/sandbox.rs citations are gone; item 5 is retitled NOT APPLICABLE TO CURRENT MAIN and says why. The header no longer claims nothing is fixed and scopes itself to 2d7864e, "a tree that predates this file". Every §7 item now carries an IMPLEMENTED / PARTIALLY IMPLEMENTED / NOT APPLICABLE marker, item 2 correctly says the lockfile half only, and item 3 says exactly what the diff does and does not deliver — that debug = line-tables-only shrinks the 101 caches while unbounded incremental retention is what accumulates them, and that cargo has no knob, so a real fix is periodic eviction. That is a more honest answer than the one I asked for.
Suggestions — all six addressed
#4 through #8 are documented where a reader will actually hit them, and each carries its evidence rather than a bare assertion: the drain-all decision states that the writes are clean = 0 and idempotent and that one transport failure does not invalidate the other ninety-nine reviews; the unjoined-Pending cost says why re-querying beats threading a join through every early exit; the registry-burst note names the concrete exposure ("a spike against a small self-hosted mirror, which --registry permits") and gives the remedy; the expect invariant at src/ci.rs now explains why it cannot fire. #9 is covered by the CHANGELOG.
Residual risk — unchanged, and still the reason not to merge yet
Mutation testing has not been run. AGENTS.md requires ./scripts/verify.sh --mutants for work touching extraction, integrity, policy or the heuristic rule engine, and src/diff.rs and src/advisory.rs both qualify. My mutant was closed by hand, but I picked it because I had already read the code — that is the floor, not the evidence. The Utf8Scan carry (src/diff.rs:344–378) is the part I would most expect a survivor in: tail from an error_len() == None error being bounded by 3, and pending[0] always being a valid lead byte, are both invariants no test pins. A mutant that widened the carry to a fixed four bytes, or that dropped the finish() check, would be worth having CI find rather than us.
Also unchanged: this box has no rustup, so the 1.97.1 pin is inert and my runs used Fedora 1.98.1 — the same gap PERF.md §8 admits. And the gate does not cover the two dogfood jobs, which need the release binary.
Verdict
No blockers remain. The three fixes are correct and each was verified rather than accepted on description; the suggestion-level work went in as reasoned documentation, not as rubber stamps. One thing stands out: the item-3 correction in PERF.md is the diff disagreeing with its own PR body in writing, which is the behaviour I would want from a fail-closed tool's docs.
Ready to merge once --mutants produces a clean run. That is the last gate.
|
Heads-up on What happened. I started What that means for the residual risk. The part the review flagged as most likely to hide a survivor — the So the verdict stands as the review left it: no blockers remain, and the last gate is still open. This is not that gate. What a real run needs. It takes well over an hour on this box (each mutant rebuilds the lib: ~16s build + test per mutant, against 4300+ mutants in the crate). The practical options:
I have not restarted it — option 1 is what I would pick if the goal is the review's specific concern (the |
The CI mutation shards found eight survivors in the new perf code, all in the extraction-boundary files AGENTS.md requires --mutants for. Killed by new tests, each verified to fail under its mutant and pass on the real code: - seq_len: deleting any of the four lead-byte arms (1/2/3/4-byte classes) left the suite green. seq_len_maps_every_utf8_lead_byte_class pins all four plus both rejection ranges (continuation bytes, the two overlong ranges). - Utf8Scan::feed: flipping the carry guard pending_len < expected to > left the suite green. a_carry_completes_only_once_the_whole_sequence_arrives drives a four-byte sequence one byte at a time into the same scan, so a partial carry meeting a still-too-short follow-up is exercised. - SCAN_CHUNK_BYTES: 64 * 1024 -> 64 + 1024 is invisible to every other test. the_scan_window_is_sixty_four_kibibytes pins the memory bound. - classify_bytes: len > cap -> len >= cap is an off-by-one at the exact boundary. classify_bytes_draws_the_opaque_boundary_above_the_cap_only pins that a file exactly at the cap is still diffable. - query_osv cache-TTL guard: the hits.is_empty() three-way choice is split into a pure cache_ttl_secs and pinned for clean (12h), vulnerable (1h) and failed (0). Excluded with a reason: replace <= with > in evaluate_candidates is a true equivalent mutant -- both branches return items in candidate order and the scoped path handles workers == 1 correctly, so no input yields a different report; the only difference is one extra store.reopen(), invisible in the result. It cannot be killed by any behavioural test, so it is excluded in both the PR and push mutant jobs with the reasoning in a comment rather than left as a permanent red shard. CHANGELOG [Unreleased] records the change.
|
Traced the The 8 survivors and their disposition
The reviewer's exact worry was right: the Killed (7), each verified both directionsNew tests, each confirmed to FAIL under its mutant and PASS on the real code:
Excluded (1), with a reason in the workflow
Gate re-run green (Format, Clippy, Tests, References). New CI run will confirm the shards. |
Measured against a loopback registry, a 200-file review spends 717 ms on
the wall clock and 45 ms of CPU. The gap is two things: a blocking OSV
round trip in the critical path, and a serial evaluation loop in the ci
lane.
The advisory query no longer blocks the review. resolve_advisories
returns Ready for everything answerable without the network (recall
hit, policy switch, warm cache, an ecosystem with no coverage) and
Pending with the OSV request already running otherwise, so the
tarball fetch, extraction and diff all proceed while the network
works. join collects it at scoring time. The upstream disclosure
behaviour is preserved verbatim: an Err stays a HIGH finding, an
unverified report the operator accepted stays a MEDIUM one, and
fail_closed is still checked before any stale fallback is served.
The ci lane reviews packages in parallel. One worker per package,
capped at eight, each with its own ReviewContext and its own SQLite
handle, since neither is shareable. Findings are emitted in
candidate order rather than completion order, because CI output is
diffed between runs. The budget check still refuses before any work
starts.
Each file is read once through a 64 KiB window instead of being
buffered whole for a hash, a NUL scan and a classify. The digest is
unchanged, because it decides whether a file is compared at all, and
peak memory stops scaling with the largest file in the package. The
UTF-8 carry across the window boundary completes by the sequence's
real length, and a carry still open at EOF is judged malformed, so a
file ending mid-code-point no longer reads as text.
The dev profile emits line tables rather than full DWARF, and emits
none for dependencies. Cargo's default is opt-level 0 with
debug = 2, which at this crate's size is what accumulates a hundred
stale incremental caches. Cargo.lock does not move.
PERF.md carries the measurements, the commands that produced them, and
what is still not known. scripts/perf_harness.py reproduces the runtime
numbers and scripts/perf_watchdog.sh bounds the process count of a test
run.