Skip to content

Speed up hosted and vendored scans: concurrent API requests, parallel crawl, single-pass rewriters - #257

Merged
Mikola Lysenko (mikolalysenko) merged 238 commits into
mainfrom
perf/scan-concurrency
Sep 27, 2026
Merged

Mikola Lysenko (mikolalysenko) merged 238 commits into
mainfrom
perf/scan-concurrency

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Hosted and vendored scans spent most of their time waiting on HTTPS requests sent one at a time, and in per-ecosystem work that re-read, re-parsed and re-wrote the same files. This PR:

  • runs the network phases concurrently, with results still folded in input order;
  • makes the crawlers and lock rewriters single-pass;
  • memoizes per-run parses and prefetches and pre-stages vendored downloads from an exact plan;
  • ships a small set of approved behavior changes, listed below.

Everything outside those changes is byte-identical to main: stdout, stderr, lockfiles, ledgers, vendored artifacts and exit codes. Every work package was A/B-gated for this, and every commit went through adversarial review (see Review).

Results

These are replay-proxy runs: recorded API responses served locally, with a fixed per-request latency. Release builds; wall time with instructions retired in parentheses; median of 2 interleaved reps. "Original" is the binary before this campaign, "main" is current main (3efdc31d, including #256), and "this PR" is the head (98d4b1d5).

depscan (100 ms/request)

Run Original main This PR vs main
hosted --dry-run 17.0 s (18.5 G) 17.2 s (18.6 G) 2.8 s (10.4 G) 6.2×
vendored, fresh 47.3 s (29.0 G) 47.4 s (28.9 G) 3.9 s (10.6 G) 12.2×
vendored, idempotent re-run 16.3 s (29.1 G) 16.3 s (29.1 G) 1.2 s (3.7 G) 13.6×

Polyglot monorepo (0 ms and 100 ms/request; original and main at 100 ms ran once, at 2-5 min per step)

Run Original main This PR vs main
hosted dry-run, 0 ms 27.5 s (290 G) 7.2 s (95 G) 1.7 s (26 G) 4.3×
hosted real run, 0 ms 27.2 s (290 G) 7.4 s (95 G) 1.7 s (27 G) 4.3×
vendored fresh, 0 ms 52.0 s (328 G) 50.3 s (369 G) 7.1 s (110 G) 7.1×
vendored re-run, 0 ms 16.5 s (216 G) 18.6 s (247 G) 2.8 s (34 G) 6.7×
hosted dry-run, 100 ms 104.1 s 85.5 s 4.7 s 18.3×
hosted real run, 100 ms 148.0 s 122.8 s 6.0 s 20.4×
vendored fresh, 100 ms 314.1 s 318.3 s 14.6 s 21.8×
vendored re-run, 100 ms 116.0 s 117.7 s 5.5 s 21.4×

The polyglot monorepo has ~158k files, 8 ecosystems and 560 patched packages, and exercises every service-backed backend at once. Per-ecosystem fixtures (cargo zed, maven, nuget, composer, uv, poetry, go, gem) were A/B'd in the batch comments, with the same byte-equality checks. For example, cargo hosted dry-run went from 204 G to 2.7 G instructions, and maven and nuget vendored re-runs dropped 72–93 %.

Output equality against main (this run; stdout canonicalized with timestamps masked and purl/path arrays compared as sets):

  • depscan hosted, and polyglot hosted dry and real runs: stdout, stderr and the full work-tree digest are identical.
  • Vendored runs: stderr, locks, manifests, the file list and every vendored artifact are identical. Markers are identical with vendoredAt masked. The polyglot state.json differs because of ledger v2 (X11).
  • Vendored stdout differs only by documented changes:
    • refuse-locally-first moves records to the download phase: 3 on depscan and 80 on polyglot, each with the same code and text as main's vendor failed event;
    • the deferred fetch (X1b) drops vendor_fetched_missing events.
  • No undocumented difference was found.
  • Request counts: batch POSTs go 56 → 12 (depscan) and 147 → 30 (polyglot); by-package GETs are unchanged. Every run had 0 replay misses and 0 unknown purls, and exit codes match main.
  • Every commit before the approved changes was A/B'd against the previous head. Stdout, stderr, tree digest, file list and request counts were identical across 11 hosted/vendored fixtures in 9 ecosystems.

What changed

Phase 2: network, crawl, rewriters

  • utils/concurrent.rs is an ordered-concurrency helper (buffered, never buffer_unordered). Results, warnings and errors are folded in input order, and --debug lines are held back so they print in serial order.
  • These run concurrently: batch POSTs, by-package GETs, hosted GET /patches/view, download views, VEX record fetches and lockfile-only pristine fetches. The batch loop reproduces the 401/403 proxy fallback exactly, at any chunk.
  • The npm crawl does a parallel gather and then an ordered merge, on a 4-thread walk pool (SOCKET_WALK_THREADS). The nine crawlers run concurrently and merge in a fixed order. Directory listings, gem env, composer home and the site-packages query are asked once.
  • Each lock is parsed once, indexed and spliced in one pass. This covers pnpm, npm, yarn classic/berry, bun, composer, uv/pylock/PEP 723, poetry/pdm, go.mod/go.sum and Cargo.lock. The old rewriters are kept as test oracles.
  • Telemetry is sent in the background and flushed before the next stdout write.

Phase 3: per-ecosystem work

  • Per-run parse memos for Cargo.lock/config, composer.lock, packages.lock.json, the pypi locks, the JS lockfiles and the hatch ledger. The cargo in-sync re-run and the pnpm lock split are memoized too, and the group-committed ledger is edited in place.
  • Lazy vendored sources: a fetched package is extracted only where something reads it. Zip members are inflated on a bounded pool, and jars, nupkgs and wheels are rebuilt from memory. sha2 uses the aarch64 SHA-256 instructions.
  • The cargo hosted rewriter searches only up to the next [[package]] block (zed: 204 G → 2.7 G). Maven coordinates come from the ~/.m2 path.
  • Hosted redirect confirmation makes one Aho-Corasick pass per lock text. Independent hosted rewriter groups run concurrently, and hosted scan --vex reuses scan's npm crawl.

Exact grant plan and vendored prefetch

  • The vendor prefetch plans downloads through each backend's own pre-flight gates. It requests exactly what the serial loop consumes: depscan fresh sends main's 71 grants, not 74. Parity tests pin the plan against the loop, refusal for refusal.
  • The plan covers every service-backed ecosystem (cargo, composer, gem, golang, maven, nuget, pypi, npm). Its window slow-starts at 4 and grows to the API cap; a 128 MiB budget stops new downloads from starting.
  • Verified archives are pre-staged next to each backend's stage and claimed with one rename. Stale pre-stage trees are swept at the start of the next wet loop.
  • Writes and wiring stay serial in record order, and the outage circuit breaker is replayed at consumption time.

Polyglot monorepo work (pp1–pp3)

  • pp1: vendored CPU memos (vendored re-run −50 % instructions).
  • pp2: presence probe, parallel rewriter groups, walk pool, adaptive API windows (hosted wet at 100 ms: 18.4 s → 5.8 s).
  • pp3: cross-ecosystem prefetch, pre-staging and early lock refusals (vendored fresh at 100 ms: 107 s → 15 s).

Tooling: scripts/perf/ holds the record/replay benchmark harness.

Behavior changes (approved)

Details and measurements are in the batch comments (X-series, pp2, pp3, batch 500). Each change is in CHANGELOG and CLI_CONTRACT.

  • Deferred pristine fetch (X1b): a purl the ledger already covers, or a cargo crate the patch service serves, is not downloaded up front.
    • An in-sync --offline or network-less re-run now succeeds with already_vendored and exit 0.
    • Re-runs no longer report vendor_fetched_missing.
    • cargo and uv send fewer requests.
  • fsync policy (X12): vendored artifacts are written without a per-file fsync. One durability barrier runs before every durable commit point and at the end of the run. Lockfiles and ledgers stay durable. On Windows, each artifact is still fsynced at write time.
  • Per-run group commit with journal replay (V2): lock, manifest, config and ledger edits are committed once per run through .socket/vendor/.commit-journal.json. A crash leaves the pre-run wiring, or the next locked command rolls it forward.
    • A failed commit write now fails the run with the new top-level error vendor_commit_failed (exit 1). It replaces the per-purl vendor_state_write_failed / redirect_ledger_write_failed events.
    • After such a failure, locks and ledgers stay pre-run, and artifacts written in the loop remain until the next run.
    • vendor_stale_artifact_removed events now follow the per-package events.
  • Ledger schema version 2 (X11): whole-file wiring snapshots (maven, nuget, pylock/PEP 723/hatch) are stored as {snapshot, ops} edits of the record's own original.
    • Version 1 ledgers are still read and revert byte for byte. Other ledgers stay byte-identical v1.
    • state.json shrinks: maven 29.8 → 15.1 MB, pylock 43.9 → 22.0 MB.
  • Maven coordinates from the ~/.m2 path (MVN-1): canonical POMs are not opened once their top-level group directory is confirmed. A hand-placed POM that disagrees with its path reports the path's coordinates.
  • -e <eco> crawl scope (MVN-4): without --prune/--sync, only the named ecosystems are crawled. lockfileOnlyPackages counts only them.
  • API concurrency 32, adaptive: authenticated patch-API windows are n/4 of a step's requests, clamped to 8..32 (main sends them one at a time). VEX record fetches go up to 10, and the public proxy stays at 4. SOCKET_API_CONCURRENCY (1–32) overrides the cap; 1 is strictly serial, and on the proxy it can only lower the cap. A tight descriptor limit falls back to serial.
  • CACHEDIR.TAG pruning: a node_modules inside or below a directory with a signed CACHEDIR.TAG is no longer crawled. The scan root is always crawled.
    • This affects scan, scan --prune / --sync, and discovery in apply, rollback, remove, repair, vendor, get and vex.
    • Under --prune / --sync, such a package counts as not installed and is garbage-collected unless a lockfile resolves it.
  • Bounded 429/503 retry, and new --json warnings: every patch-API JSON call retries a 429/503 up to 3 times (SOCKET_API_MAX_RETRIES, 0–10).
    • Backoff honors Retry-After. A value over 30 s gives up at once. Without one, it waits 0.5 / 1 / 2 s with jitter.
    • All retries in a run end within a 60 s wall-clock window.
    • Never retried: 401/403, which still drive the proxy fallback, and the proxy's permanent 503 "Patch API is not configured".
    • New error suffixes say why retrying stopped: (gave up after 3 retries), (Retry-After <n> s exceeds the 30 s retry cap) or (the run's 60 s retry window has closed).
    • New additive run-level --json warnings: api_batch_failed and patch_details_failed.
    • On a token-less legacy proxy without POST /patch/batch, a package still throttled after its retries fails its whole batch query instead of being skipped silently.
  • Refuse locally first (narrowed): on scan --mode vendored and get --mode vendored, some lock-text refusals now happen before the package's view and pristine source are fetched. These are the pnpm, yarn classic and yarn berry lock-text gates, and cargo's locked_version_mismatch when it is the first refusal.
    • Scope: only packages the vendor loop would hand to its backend, meaning installed, or resolved by the lock to a verifiable source. A package absent from the lock and not installed keeps its skipped / package_not_installed event, as before.
    • Each such package becomes a download.patches[] record with action: "failed" and the backend's errorCode and error. download.downloaded drops and download.failed rises by the same number.
    • vendor.events loses their failed events (and, for lockfile-only packages, their vendor_fetched_missing events), and vendor.summary.failed drops.
    • The nested vendor.status becomes success when these were the vendor step's only failures. The exit code and the top-level status do not change.
    • Because no view is fetched, the lock refusal now wins over outcomes that come from the view (paid 403, failed view fetch, no-applicable-files).
  • Batch size 500: when neither --batch-size nor SOCKET_BATCH_SIZE is set, a batch holds up to 500 purls on the authenticated API. That's the server's own MAX_PURLS_PER_BATCH. The public proxy keeps 100.
    • A chunk whose body would exceed 256 KiB is split deterministically.
    • A mid-run downgrade to the proxy keeps the chunks already built. The test pins authenticated [500] and then proxy [500, 500, 1].
    • Output is unchanged; only the batch request count and the --help default change (depscan 56 → 12 batch POSTs).

Disclosed test edits. No existing test was ignored or deleted. Existing test edits pin an approved change:

  • The two ledger-write-failure tests in covgap_commands_vendor.rs now expect the new vendor_commit_failed envelope error instead of a per-purl failed event (V2). The redirect test no longer asserts that the artifact is absent, because it is written before the commit fails. It asserts instead that nothing is committed or rewired and no journal remains.
  • The v1 legacy-ledger fixture for cargo was regenerated with the pre-batch binary to match main's Wire vendored cargo patches through Cargo.toml and tag the copy #254 cargo wiring.
  • The batch-size change made mechanical edits (batch_size: 100 → Some(100), and the parser default from 100 to None).
  • The yarn cacheKey refusal tests again assert that the vendor step emits nothing for the refused package.

Known follow-ups

  • The by-package GETs could start while later batches are still in flight. That would save about 0.5 s at 100 ms, but it has trade-offs, so it is not done here.
  • Fix Cargo hosted workspace redirects #256 (on main) added about 1.5 G instructions to the cargo zed hosted dry run. That cost is on main too; it is not a regression from this PR.
  • Pre-existing silent skips, unchanged here: the baseline view in scan/discovery.rs, and .ok()?? in repair_vendor.rs.
  • Nested-project discovery for monorepos is planned as a separate PR.
  • The crash/journal and fsync semantics are covered by unit, failpoint and injection tests, not by a live crash fixture.

Test plan

  • cargo clippy --workspace --all-targets -- -D warnings is clean.
  • cargo test --workspace --no-fail-fast on the head: 9,326 passed, 0 failed, 136 ignored (265 test binaries).
  • New tests cover:
    • ordering under reversed latencies, and proxy fallback at any chunk;
    • crawler, rewriter, lazy-source and group equivalence against oracles;
    • plan-vs-loop parity for every npm flavor;
    • failpoint crash tests at the durability barrier and inside the group commit;
    • journal replay;
    • ledger v1 ↔ v2 round trips;
    • retry policy and window;
    • the early-refusal scope;
    • batch sizing, including the mid-run downgrade, verified by mutation.
  • External check: depscan's 25-job cross-OS Maven matrix passed against f9fc6765 with real mvn.
  • CI: all 417 checks pass on 98d4b1d5. Two Poetry native cells hit a production-API/network error; the same binary had passed them on 7d5d414e, and they passed on re-run.

Review

Each work package went through one to three adversarial reviewers, checking behavior equivalence, concurrency and test integrity. The retry and batch-size changes each had a dedicated reviewer, including mutation tests. Every finding was either fixed in a commit or refuted with evidence; the batch comments list them. The branch merged main (#256) as a merge commit and was never force-pushed.

🤖 Generated with Claude Code

`ordered_concurrent` / `map_ordered_concurrent` wrap
`stream::iter(..).map(f).buffered(limit)`: at most `limit` requests in
flight, results yielded in input order, nothing started until polled.
The serial patch-API loops can adopt it and fold results exactly as
before. `API_CONCURRENCY` (8) and `PROXY_API_CONCURRENCY` (4) carry the
per-client caps. futures-util was already in the lock; it is now a
direct dependency of core and the CLI.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
New subprocess suite for the three patch-API loops `scan` drives (batch
POSTs, per-package detail GETs, hosted record views). Every mock answers
later requests first, so an implementation that folds in completion
order, or lets a discarded response leak in, changes the output:

- batch: a 401 on the first chunk sends that chunk and all later ones
  to the proxy with one auth request and one warning; a 401 on chunk 3
  of 6 folds 0-2 from the auth API and replays 3-5 on the proxy; per
  batch 500 warnings print in chunk order; the all-failed error carries
  the last chunk's error.
- details: partial-failure warnings print in package order and the
  whole human preview equals a zero-latency run; the all-failed error
  names the last package.
- hosted wet run: record_fetch_failed warnings keep confirmed order and
  stdout, lockfile and ledger equal a zero-latency run.

The suite passes against the current serial loops (checked with the
baseline binary) and is the oracle for making them concurrent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`fetch_patch_details` awaited one `by-package` GET per package with
patches (74 on depscan, ~10 s of serial round trips). The queries now
run through `ordered_concurrent` (8 in flight, 4 on the public proxy)
and are consumed in `packages` order, so `results`, `failures`, the
warn-after loop and the all-failed rule see exactly what the serial
loop produced. `ApiClient::uses_public_proxy` picks the cap.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The batch loop POSTed one chunk at a time (56 chunks on depscan). Chunks
now run through `ordered_concurrent` and are consumed strictly in chunk
order, so per-batch warnings, `batch_error_count`, `last_batch_error`
and the paid-access flag fold as before.

The authenticated-to-proxy downgrade keeps the serial loop's exact
sequence: the first chunk goes alone (a stale token still costs the
auth API one request), and at the first consumed chunk k whose error is
a fallback candidate — any index — the window is dropped, responses for
chunks past k are discarded unfolded, the same warning prints, chunk k
is retried on the proxy and the rest continue there (4 in flight).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A wet hosted run fetched `patches/view/{uuid}` for every confirmed
redirect one at a time (74 on depscan, ~9 s). The views now run through
`ordered_concurrent` and are consumed in `confirmed` order, so `records`
(newest wins) and the `record_fetch_failed` warnings are unchanged. The
ledger re-fetch on idempotent re-runs is deliberately kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Scan awaited each telemetry POST inline (150-300 ms typical, up to the
5 s budget on a bad network) before carrying on. Its three events now
go through `spawn_patch_scanned` / `spawn_patch_scan_failed`: the event
is built and its endpoint resolved where it fires (same body, timestamp,
env reads and "Sending telemetry" debug line), and only the POST runs in
a background task. `scan::run` awaits `PendingTelemetry::flush` before
returning, so every event is still delivered, or given up on within the
same 2 s connect / 5 s request budget, before the process exits. The
inline trackers and every other command are unchanged.

Tests: core unit tests pin that a background send posts the same bytes
and headers as an inline one and that flush waits for it; telemetry_e2e
pins that each scan terminal (success, empty crawl, all batches failed)
delivers its one event and stays alive until the slow endpoint answers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The background send was only awaited after run_scan returned, so a
process killed after the event fired but before that flush lost it:
`scan | head` / `scan | true` dies of SIGPIPE on its first result write
(main restores SIG_DFL), and a Ctrl-C at a confirm prompt or a CI
SIGTERM had the same effect. The inline send it replaced had always
landed before any output.

`PendingTelemetry::flush` now drains (`&mut self`), and scan flushes at
the first output point after each event fires: right after the send on
the empty-crawl and all-batches-failed terminals (they print at once),
at the start of the human section (before the table, prompts and every
human exit), before the plain `--json` envelope, and inside
`discover_selected` right after the detail fetches (before its error
line and whatever the `--apply`, hosted and vendored `--json` arms
print next). The send still overlaps the by-package detail fetches on
those arms; the flush at the end of `run` stays as the exit backstop.

Under `--debug` this also puts the human path's "Telemetry sent" line
back ahead of the per-package detail warnings, as in the inline order.

Tests: telemetry_e2e runs each JSON terminal with stdout closed before
the child writes and requires the event delivered (red on the previous
commit: SIGPIPE, 0 events); a core unit test pins that flush drains and
that sends started after it join the next flush.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`track_patch_scan_failed` and `spawn_patch_scan_failed` each spelled out
the `{"fallback_to_proxy": ...}` literal; build it in one place, as
`patch_scanned_metadata` already is for the success event, so the inline
and background paths cannot drift. The inline trackers stay: they are
public API of the published core crate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
No production caller used `map_ordered_concurrent`: every API loop
consumes `ordered_concurrent` directly. Move it into the tests module
so it no longer ships as unused public API.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… + ordered merge

`crawl_all` and the workspace roots walk made one `spawn_blocking` round
trip per readdir, stat and package.json read, strictly in sequence. Both
now run as one blocking-pool task: directory I/O is gathered in parallel
(rayon, already in the dependency graph via qbsdiff) into per-root event
trees that record the sequential visit order, and a single-threaded merge
replays them so the order-dependent `seen` dedup and the store entries'
`identity_seen` decisions see exactly the state the old walk saw — same
packages, same paths, same order.

Two probes are answered from listings the walk reads anyway, only where
that is provably the same answer:
- the roots walk skips the `is_dir(child/node_modules)` stat when the
  child's complete listing holds nothing that could alias `node_modules`
  on a case-insensitive filesystem (a listed dir still stats: a
  readable-but-unsearchable parent lists kinds while stats fail);
- a store entry's `node_modules` existence probe is the readdir the scan
  needs next; a dir that does not open falls back to the stat.

FIFO-safe package.json reads (read_regular_to_string_sync), the
NESTED_STORE depth/dir caps (kept sequential: the budget order decides
survivors), symlink-not-traversed rules and lossy-vs-raw name joins are
unchanged. The previous async implementation is kept verbatim as a
#[cfg(test)] oracle; a randomized fixture test (flat/nested/legacy stores,
scoped, live/dangling/store symlinks, duplicate identities, aliases,
broken/BOM/FIFO/dir package.json, unreadable and unsearchable dirs,
node_modules case variants) plus a kitchen-sink tree assert identical
roots, crawl output, find_by_purls results and store enumeration.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ly listed names

The resolver opened `<nm>/<target>/package.json` for every pending
target in every visited node_modules — targets × dirs failed opens, each
its own spawn_blocking hop — then listed the same dir again for the
descent. Both passes now run as one blocking-pool task: each dequeued
dir is listed once, a target is probed there only when the listing could
hold its first path component, the surviving probes run in parallel and
fold back in target order, and the same listing drives the descent
(whose per-entry stats also run in parallel, appended in listing order).

The name filter is a strict superset: it only engages for a complete,
all-ASCII listing and matches ASCII-case-insensitively (APFS/NTFS),
and components a filesystem can resolve to a differently spelled entry
(non-ASCII, `~` 8.3 aliases, trailing dot/space) are always probed. BFS
root-first order, every-copy collection, the name+version identity
check, the pass-2 fallback and the store-entry name filter are
unchanged. `.pnpm` entry names are still filtered after the
`node_modules` stat, not before: an entry without one is a nested host
whose synthesized children can match, so the stat decides the result.

The oracle equivalence suite (now also covering case-variant package and
scope dirs) asserts identical find_by_purls maps on every generated root.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`crawl_all_ecosystems` awaited each crawler in turn, and the crawlers
that block (maven's walkdir walk + POM reads, `gem env`, the python
site-packages probe, `composer global config home`) did so inline on the
async task. The crawlers are independent — none prints, none mutates
shared state — so they are now joined, with every blocking walk or
subprocess moved onto the blocking pool, and their results are consumed
in the fixed Npm, Pypi, Cargo, Gem, Golang, Maven, Composer, Nuget, Deno
order, so packages and counts are exactly the serial run's. The joined
futures are heap-allocated from a non-async constructor so the caller's
poll frame does not grow by their combined size (Windows main-stack
budget).

`gem env gemdir` and `gem env gempath` run concurrently but are still two
calls consumed gemdir-then-gempath (no single-call merge: platform path
separators). A polyglot `--global-prefix` test pins the joined output
against the serial sequence.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The previous commit fanned each visited dir's probes and descent stats
out to the rayon pool separately, one injection per dir: on a deep
pnpm tree the per-dir handoff latency outweighed the parallelism, and
`apply --dry-run` on a large monorepo ran slower than the async walk.

A visit's reads depend only on the dir and the fixed target list, never
on what earlier dirs resolved, so the walk now proceeds level by level
(exactly the FIFO queue's order: everything a dir enqueues lands behind
the rest of its level). Each level's visits — listing, filtered probes,
nested-dir discovery with the virtual store's entries returned whole —
are gathered in one parallel pass, then the order-dependent part (folding
matches into the result, the unmatched-name store filter, next-level
order) is replayed sequentially in queue order. Output is unchanged;
the oracle equivalence suite still covers it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ix targets

Symlinks, FIFOs and permission stripping are generated on Unix only, so
the fields that record them are never read elsewhere.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…'s use block

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The parallel roots walk recursed once per directory level on rayon and
blocking-pool threads (2 MiB stacks), where the old async walk recursed
through boxed futures on the 8 MiB main thread. A deep enough directory
chain (reachable under Linux's 4096-byte PATH_MAX, and deeper on Windows
long paths) aborted the scan with a stack overflow the old walk
survived.

Read the tree one level at a time, each level's dirs in parallel, record
each dir's child range, then emit with an explicit stack in the same
depth-first order. Stack use no longer grows with depth; a new test runs
a 400-deep chain on 256 KiB walk threads (the recursive walk overflowed
there).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… budget

Two properties of the old sequential async walk did not survive the
move to parallel sync walks on rayon's global pool:

- Stack: the recursive node_modules gather ran on 2 MiB worker threads
  instead of the 8 MiB main thread. The npm walks now run on a dedicated
  walk pool whose threads get the main thread's 8 MiB.
- Descriptors: every walker treats a failed read_dir/open, EMFILE
  included, as an absent dir, and the old crawl held one descriptor at
  a time with the nine crawlers run back to back. With one walk thread
  per CPU plus concurrent crawlers, depscan lost packages silently
  below `ulimit -n 24` (5349 of 5520 at 20) where the old crawl was
  intact down to 14. Under a soft RLIMIT_NOFILE below 128 the walk pool
  now gets one thread and the crawlers run one at a time (the old
  descriptor profile); above it the pool is capped at half of what is
  left after a 64-descriptor reserve. depscan now matches the baseline
  byte-for-byte at every limit from 16 to 256.

New tests: pool sizing, a 4 MiB frame fitting on a walk thread, and an
e2e scan under `ulimit -n 16` that must match the ample-limit JSON (the
one-thread-per-CPU pool lost most of that tree there).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The hosted pnpm rewriter re-parsed every lock (entries, the early
shrinkwrap sniff, the residual gate) and rebuilt the whole lock string
once per dep: O(deps x lock) work that cost ~430 ms of critical-path CPU
on depscan's 2 MB lock with 74 redirected deps.

Each lock is now parsed and key-indexed once; a dep's instances are
found by binary search, the residual gate judges each instance on its
post-splice body, and committed splices are applied in one pass at the
end. A later dep that hits an already-spliced entry (a duplicate
name@version override) folds the pending splices in and re-indexes
first, so it re-reads the rewritten text exactly as before, and the
vendored-marker scan runs over the post-splice text the same way.

Output bytes, the FileEdit list (order and original fragments),
warnings and refusals are unchanged: the previous implementation is kept
as a test oracle and compared on a depscan-sized synthetic lock set, on
300 randomized mixes of every lock flavor, and on duplicate-override and
peer-suffixed multi-instance cases. depscan wet run: pnpm-lock.yaml and
redirect-state.json byte-identical.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rently

Deciding which pypi deps need hosted wheel metadata ran a full
`rewrite_python_lock` (parse, a second parse for the source-scope check
on script locks, mutate, serialize) per dep per lock, only to test the
result for `Some`. The rewrite's refusal and not-applicable checks now
live in one planning step that `rewrite_python_lock` and a new
`PythonLockProbe` share: the probe parses each lock once and answers
exactly `matches!(rewrite_python_lock(..), Ok(Some(_)))` per dep, and
the rewrite no longer re-parses the lock for the scope check.

The qualifying wheels are then downloaded through an ordered
`buffered(8)` stream and folded in dep order, so `python_metadata`, the
withheld artifacts and the `python_metadata_unavailable` skips are
unchanged. The stream is inlined here (futures-util added with the same
workspace spec as the scan-concurrency branch); it moves onto the shared
ordered-concurrency helper once that lands.

New tests: a probe/rewrite equivalence sweep over every lock shape and
outcome, and a hosted scan whose slow first failure must still be
reported before a fast second one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… lock

The npm package-lock rewriter re-derived every `packages` entry's
identity (the `node_modules/` key split plus the `name`/`version`
lookups) for every dep, and the classic yarn.lock rewriter re-split
every block's key patterns for every dep: O(deps x entries) work that
dominated both rewriters' CPU.

Each identity is now computed once per lock. npm entries keep theirs by
map position (a rewrite only touches `resolved`/`integrity`, never a
key, `name` or `version`); a yarn block's key and sole real package are
recomputed whenever this run rewrites that block, so later deps still
see its current text.

Output bytes, FileEdits and warnings are unchanged: both previous
implementations are kept as test oracles and compared on 400 randomized
locks each (aliases, links, bundled copies, workspaces, v1/v2
dependency trees, alias-only and fork-substitution yarn keys, CRLF and
mixed line endings, duplicate overrides). Rewrite-phase CPU on the
lockfile-only benches: npm-socket 103 -> 61 ms, yarn-strapi 49 -> 32 ms
(whole-process medians).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The indexed rewriter judges residuals inline, so the boundary test over
`pnpm_unrewritten_instances` now covers only the test-only reference.
Feed the same boundary locks through `rewrite_registry_redirect`: hosted,
longer-version, scoped and snapshot keys never count, v6 nested-paren and
v5 `_` instances are repointed, and only the unparseable instance is
named in the refusal. The helper's doc comment now says what it is.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each in-flight wheel download buffers the whole wheel under its own body
timeout and retry budget, so memory and link sharing scale with the
limit; 4 keeps the overlapped round trips while halving that. The
comment records what concurrency changes that output cannot see (status
line names the awaited dep, debug lines interleave, Retry-After pauses
one fetch). The order test now also records request arrivals and fails
if `bbb` is not requested before `aaa`'s delayed response is due, so a
regression to serial fetching is caught.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
scan time is dominated by API round trips, so live timings are noisy and
unrepeatable. scripts/perf/replay.py stands in for api.socket.dev,
patch.socket.dev and the public proxy: `record` forwards and stores every
response, `replay` serves only from the store with a fixed or recorded
per-request latency (plus optional per-connection latency) and reports
request counts per endpoint, max in-flight, connections and network span.
Batch POSTs replay per purl, so a build that changes chunking or order
still gets identical answers. The listener skips HTTPServer's getfqdn(),
which stalls ~35 s under the macOS sandbox.

scripts/perf/bench.sh drives it: `record`, `replay`, and `ab`, which runs
BASE and NEW interleaved against one store and fails unless every run's
stdout sha256 and exit code match the first BASE run. Stores hold real
API responses (possibly paid-patch data), so bench.sh refuses a store
path inside the repository.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Syntax-checks replay.py (py_compile) and bench.sh (bash -n), then drives
the harness against a local upstream stub: record-then-replay with batch
re-assembly across chunks and orders, miss/unknown-purl accounting,
--fill, a 502 (never stored) for an unreachable upstream, per-request
latency with max in-flight, the getfqdn-free bind, the in-repo store
refusal, and an end-to-end `bench.sh ab` pass and sha-mismatch failure
with fake CLI binaries. Picked up by the existing
`unittest discover -s scripts/tests` CI step.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…current helper

The inline stream::iter().buffered() from the wheel-metadata fan-out
predates utils::concurrent landing; route it through ordered_concurrent
with the same limit (4) and the same in-order fold.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… too

0ef24902 flushed the scan event before the first stdout write after it
fires, but stderr raises SIGPIPE just as well (main restores SIG_DFL).
Two stderr writers could run in that window with the send still in
flight: the lenient redirect-ledger load's "Warning: <corrupt ledger>"
(non-hosted JSON and human paths, before discover_selected or the human
flush) and, on the report-only JSON arm, the GC and VEX build ahead of
the envelope. The inline send it replaced was always delivered first.

The ledger load is inlined at its scan call site so the send is flushed
right before its warning (only when it warns, so the overlap with the
detail fetches is kept), and the JSON arm flushes before the GC/VEX
step instead of just before the envelope. The --apply arm's warnings
already follow discover_selected's flush.

Test: telemetry_e2e runs a scan over a malformed redirect ledger with
stderr closed and requires the event delivered (red before: SIGPIPE,
0 events). It uses a well-shaped token so the token-shape warning does
not kill the child before the event fires.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
scan now sends patch_scanned / patch_scan_failed through the spawn_*
variants, which leaves the inline trackers without an in-tree caller.
They stay: socket-patch-core is published to crates.io, removing a pub
fn is a breaking change there, and every other event keeps its inline
tracker. The doc comments now say so, so a later cleanup does not read
them as leftovers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
batch_fallback_mid_run_replays_from_the_failing_chunk checked the folded
uuids and the proxied tail, but not that chunks 4-5 were ever sent to
the authenticated API. A serial loop (or a window of 1) would never
request them and still pass. Assert all 6 authenticated requests: chunk
0 alone, then the whole 1..6 window in flight, so the discard path
really runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On the public proxy scan runs up to PROXY_API_CONCURRENCY (4) batch
windows at once. Each window's search_patches_batch degrades to the
legacy per-package GETs (10 at a time) when /patch/batch rejects the
chunk: a 400 from one exotic purl such as pkg:jsr, or an old proxy with
no batch route. So a polyglot project on the proxy could put 4 x 10
by-package GETs in flight where the serial loop peaked at 10. That
path swallows per-purl errors as "no patches", so extra load that
saturates the proxy could change which packages come back.

The client now holds a semaphore of PROXY_BATCH_PATH_CONCURRENCY (10)
slots, shared by clones. Every proxy /patch/batch POST and every legacy
per-package GET takes a slot, so all concurrent batch calls on one
client stay within the old peak. A single call never waits: its groups
of 10 fit the cap exactly as before. The authenticated API is untouched.

Test: four concurrent batch calls of 10 purls each, all rejected with
400, keep at most 10 by-package GETs in flight and still reach 10 (red
without the slots: 40).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…can't spawn

When the walk pool could not be built (the OS refusing threads: a tight
RLIMIT_NPROC or cgroup pids.max, or a huge RAYON_NUM_THREADS), run_walk
ran the walk on the calling thread and the first parallel iterator then
tried to build rayon's global pool, which needs the same refused threads
and panics (exit 101) where the sequential walk succeeded.

- Retry the pool build with half the threads on each failure, down to 1.
- Route every parallel gather through walk_pool::par_map, which maps
  sequentially (in order) on a thread outside any rayon pool, so the
  no-pool fallback never reaches the global pool.
- RAYON_NUM_THREADS can lower the walk thread count but no longer raise
  it past available_parallelism.

Tests: halving build, par_map's sequential/ordered contract, run_walk's
no-pool path, and the randomized oracle comparison with the pool off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

WP-P2 pushed: cfeafb0c..5492794c (19 commits cherry-picked from perf/pp2, no conflicts)

Items

  • H1: Aho-Corasick presence probe. Hosted redirect confirmation makes one multi-needle pass per lock text instead of one scan per needle.
  • H4: parallel rewriter groups. Independent hosted rewriter groups run concurrently. A group runs inline if the OS refuses its thread. A group oracle pins the cross-rewriter reads.
  • H3: embedded --vex reuses the crawl snapshot. Hosted scan --vex answers its copy lookup from scan's npm crawl. It walks node_modules again after a waiting prompt.
  • DC-1: walk pool 4 / perf-cores + SOCKET_WALK_THREADS. The node_modules and Maven walks run on 4 threads by default (fewer on machines with fewer performance cores). SOCKET_WALK_THREADS overrides this.
  • H2-code: adaptive windows. Each patch-API window is sized from its own request count.

Before → after (PR head cfeafb0c → 5492794c, release builds, replayed stores, single runs)

run before after
polyglot hosted dry Dmitri Iouchtchenko (@0) ms 3.61 s / 40.2 G instr 2.03 s / 24.9 G
polyglot hosted wet Dmitri Iouchtchenko (@0) ms 3.45 s / 40.6 G 1.56 s / 25.2 G
polyglot vendored fresh Dmitri Iouchtchenko (@0) ms 8.74 s / 107.1 G 7.87 s / 100.8 G
polyglot vendored rerun Dmitri Iouchtchenko (@0) ms 3.69 s / 40.0 G 2.67 s / 33.9 G
polyglot hosted wet Devin Soni (@100) ms 18.42 s 5.82 s (concurrency 32)
depscan hosted dry ×2 Dmitri Iouchtchenko (@0) ms 1.77 / 1.46 s (11.7 / 10.5 G) 1.49 / 1.46 s (10.0 / 10.0 G)
depscan vendored fresh ×2 Dmitri Iouchtchenko (@0) ms 1.70 / 1.60 s 1.71 / 1.59 s (flat)

Equivalence gate. In every polyglot step at 0 ms (hdry, hwet, vfresh, vrerun), canonical stdout, the full work-tree digest, request count and per-ecosystem purls are identical to cfeafb0c. Depscan is identical too: hosted dry stdout, and vendored fresh stdout, lock, workspace, package.json, state.json, files and markers. The polyglot tree has 5 signed CACHEDIR.TAG dirs (target/, build/, tools/patcher/target/, two .venv/). None contains a node_modules, so the pruning is not expected to change output here, and it does not.

⚠️ Behavior changes (approved)

  • API concurrency 8 → 32. The window is adaptive: n/4 of a step's requests, clamped to 8..32. VEX record fetches go up to 10. The public proxy stays at 4. SOCKET_API_CONCURRENCY overrides the cap (1-32; on the proxy it can only lower it).
  • CACHEDIR.TAG pruning. A node_modules inside or below a directory with a signed CACHEDIR.TAG is no longer crawled. The scan root is always crawled. This reaches scan, scan --prune / --sync (such a package counts as not installed and is garbage-collected unless a lockfile resolves it), and the discovery of apply, rollback, remove, repair, vendor, get and vex.
  • Bounded 429/503 retry.
    • Policy: every patch-API JSON call retries up to 3 times. A Retry-After is honored; one over 30 s gives up at once, and one under the jittered first step (0, a past date) is floored to it. Without one, backoff is 0.5 / 1 / 2 s (steps capped at 8 s) with equal jitter. All retries in a run must end within a 60 s wall-clock window opened by the first retry, so parallel waits overlap instead of adding up.
    • SOCKET_API_MAX_RETRIES (0-10; 0 = old single attempt).
    • Never retried: 401/403, which still drive the proxy fallback, and the proxy's permanent 503 "Patch API is not configured" on any path.
    • New error text names why retrying stopped: (gave up after 3 retries), (Retry-After <n> s exceeds the 30 s retry cap), (the run's 60 s retry window has closed). An over-capacity 503 is the typed ApiError::ServiceUnavailable.
    • New additive --json run-level warnings: api_batch_failed and patch_details_failed.
    • Legacy proxy batch: on a token-less proxy without POST /patch/batch, a package still throttled after its retries fails its whole batch query instead of being skipped silently.
    • Listed under CHANGELOG "Changed (BREAKING)" and the semver note; the --json warnings are under "Added".

Tests

cargo clippy --workspace --all-targets -D warnings is clean. cargo test --workspace --no-fail-fast on the PR head: 9273 passed, 0 failed, 136 ignored.

Review notes

  • pp2: 3 adversarial reviewers, then a fixer.
  • The retry got a dedicated adversarial reviewer, then a fixer. Its fixes landed in fix(api): bound retries by a wall-clock window; never retry "not configured":
    • The summed 60 s budget starved concurrent requests; it is now a wall-clock window.
    • A Retry-After over the cap now gives up at once, and 0 / past dates are floored.
    • The "not configured" 503 is no longer retried or treated as throttling on per-package and view calls.
    • Throttle classification is typed instead of matched on a message prefix.
    • New test: a persistent 503 / 429 on the proxy batch makes zero per-package GETs.
  • Pushed commit subjects keep their original ! markers. fix(scan)! (the --json warnings) is additive and is documented under "Added"; history was not rewritten.
  • Known pre-existing follow-up, unchanged here: two paths still drop failures silently, the scan/discovery.rs baseline view and repair_vendor.rs's .ok()??.

🤖 Generated with Claude Code

#256 replaced the cargo manifest regex readers (CARGO_TOML_PACKAGE_RE,
CARGO_TOML_VERSION_VAL_RE, and the capturing CARGO_TOML_REGISTRY_VAL_RE)
with TOML-parsed values (cargo_toml_string / cargo_toml_inline_string),
so single-quoted literals, quoted keys, and trailing comments read the
same way everywhere. The textual merge was clean, but #257's
CargoRegistryPins (the one-scan-per-manifest pin extractor behind the
vendored cargo PIN_MEMO) still used the removed package regex and the
old capture group of the registry regex.

Resolutions:
- CargoRegistryPins::of now reads values exactly as #256's
  cargo_socket_registry_pin does: header-table `package`/`registry` via
  cargo_toml_string, dotted `<crate>.registry` via cargo_toml_string,
  inline tables via cargo_toml_inline_string (registry first, package
  parsed only when a Socket pin is present). cargo_socket_registry_pin
  (now #256's semantics) stays its #[cfg(test)] equivalence oracle.
- The V-7 pin-equivalence corpus gains #256's spellings (literal
  strings, quoted inline keys, commented header lines) so the oracle
  pins the new semantics.
- Everything else from #256 (workspace-inheritance resolution, (name,
  version) lock owners, validate_cargo_toml_pins, annotated registry
  headers in plan_cargo_config) merged untouched; #257's lock_block_end
  bounded search + oracle, parse memos, presence probe and parallel
  rewriter groups needed no change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Merged origin/main (#256) into this branch as 2b380e3. It's a merge commit, not a rebase or force-push.

CI was red because GitHub builds the PR merged with main. #256 removed CARGO_TOML_PACKAGE_RE and dropped the capture group from CARGO_TOML_REGISTRY_VAL_RE, and switched cargo manifest reads to parsing values as TOML (cargo_toml_string / cargo_toml_inline_string). The only code of this branch still on the old regexes was CargoRegistryPins::of, the one-pass-per-manifest pin extractor behind the vendored cargo PIN_MEMO.

Resolution:

  • CargoRegistryPins::of now reads values the same way Fix Cargo hosted workspace redirects #256's cargo_socket_registry_pin does. That covers header-table and dotted keys via cargo_toml_string, and inline tables via cargo_toml_inline_string. cargo_socket_registry_pin is still its #[cfg(test)] equivalence oracle, now with Fix Cargo hosted workspace redirects #256's semantics.
  • The V-7 pin-equivalence corpus now includes Fix Cargo hosted workspace redirects #256's spellings: literal strings, quoted inline keys, and a trailing comment on a header-table line.
  • Everything else from Fix Cargo hosted workspace redirects #256 merged untouched: workspace-inheritance resolution, (name, version) lock owners, validate_cargo_toml_pins, and annotated [registries.*] headers. This branch's lock_block_end bounded search and its oracle, the parse memos, the presence probe and the parallel rewriter groups needed no changes.

Verification:

🤖 Generated with Claude Code

…relude and a plan gate

The vendor loop only prefetched npm's service downloads, because only the
npm flavors could say, ahead of the loop, which packages they would ask
the patch service for. Every other backend decided that inline: cargo,
composer, gem, golang, maven, nuget and pypi each ran their coordinate
guards, lock/manifest reads, refusals and in-sync hot-path tests at the
top of `vendor_*`, interleaved with the service call.

Each of those prefixes is now one function the entry point runs first
(`cargo_prelude` + `cargo_wet_preflight`, `composer_prelude`,
`gem_prelude` + `gem_edits`, `go_prelude`, `maven_prelude`,
`nuget_prelude`, `pypi_prelude`), returning either the outcome the old
inline code returned (a refusal, the empty-patch no-op, the in-sync
result) or the values the rest of the body reads. Nothing moved relative
to anything observable: the same checks run in the same order with the
same outcomes; only the code is shaped so the same checks can be asked
without the rest of the call.

`service_preflight` per backend, and `vendor::service_preflight` over
them, answer "would a wet run with the service enabled ask the service
for this record?" by calling exactly those functions: past every refusal
raised before the first service call, and not answered by the hot path
or (pypi) a committed-wheel reuse. A download plan built from it never
names a package the loop refuses first.

Tests: per backend, an oracle over a mix of good, refused and no-op
records — the gate's verdicts, computed first like the plan, must equal
the grants the backend then requests, in order; and once vendored the
re-run must plan (and ask) nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…npm's

The vendored loop wires one package at a time, and only npm's grant +
archive round trips were fetched ahead of it. Every other ecosystem paid
them back to back inside the loop, ecosystem after ecosystem: on the
polyglot monorepo at 100 ms RTT, 408 non-npm downloads at 0.22-0.35 s
each were most of a ~120 s vendored scan.

The loop's download plan now covers every service-backed record, in loop
order. `plan_service_downloads` walks the sorted packages exactly as the
loop does — variant bases fanned out once, each variant through the same
installed-variant probe (a probe that would need a download not made yet
leaves the variant unplanned), the Bun refusal, the hosted-takeover gate
— and keeps only the records whose backend gate says they reach the
service: npm's one-read `preflight_packages` as before, every other
ecosystem's `service_preflight`. Records the ledger already holds at the
record's uuid are left out (the backends' in-sync hot paths answer them,
and proving that ahead of the loop would verify every committed artifact
twice); an unplanned record is simply fetched live. Every doubt resolves
to "not planned", never to a grant the loop does not ask for. The gates
run one at a time: several at once each hold their own parse of the
project's locks (a cargo gate clones the whole Cargo.lock document).

The prefetch itself:
- the window is the API's in-flight cap (was min(cap, 4)); a byte budget
  (128 MiB of fetched archives waiting for the loop) bounds memory: past
  it only the position the loop is at may start;
- a planned download may name a secondary artifact — gem's stub gemspec
  rides its `.gem` — fetched along with the archive only when the archive
  is ready and passes the same integrity checks `fetch_verified_archive`
  runs, and taken by `fetch_verified_secondary` in place of the live
  request, its debug lines held back until then.
Consumption stays serial in record order and the breaker is still folded
at the loop's own call, so outcomes, events and stderr are unchanged.

Polyglot (vs PR #257 head): vfresh/vrerun at 0 ms byte-identical stdout
and work-tree digest, 479 grants either way, replay misses 0; vfresh at
100 ms ~120 s -> 16.0 s.

Tests: budget and secondary-prefetch oracles against the serial loop (same
outcomes, same requests; no secondary for a tampered archive), and a
composer twin of the zero-grant e2e.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With every service download prefetched, the vendored loop's next floor
is its own local work, and for the directory-shaped backends most of it
is one step nothing overlapped: extracting the verified prebuilt archive
into the stage (cargo `.crate`, composer dist zip, golang module zip, the
gem's data.tar.gz) — about a second of the polyglot monorepo's loop at 0
ms, serial between packages.

Each backend's plan gate now also names what to do with its archive once
it lands (`vendor::prestage::PrestageRecipe`, built from the same copy dir
its stage derives from), and the prefetch task runs it on a small bounded
blocking pool (4) as the download completes — only for an archive that
passed the integrity checks `fetch_verified_archive` runs, exactly like
the gem stub. The tree lands in `<copy>.socket-prestage`, a sibling of
the backend's stage, never the stage itself; the backend claims it with
one rename right where it would have extracted (replacing stage litter as
its `remove_tree` would), and everything after — the tree check, the
tag, the swap, the wiring, the marker and the ledger — runs as before,
serially in record order. maven, nuget and pypi get the one pure step
they run on the archive bytes instead: the afterHash check of the
patched members, whose verdict rides the archive and is used only for
the exact file set it was computed against.

Nothing observable changes:
- a failed pre-stage (the extractor's own refusal included) stages
  nothing and hands the bytes back: the backend extracts live and reports
  its own words;
- a backend that runs without its pre-stage (its call skipped by the
  breaker, its plan position passed over) builds in its own stage
  undisturbed;
- an unclaimed tree is queued and removed, with the vendor levels it
  created, only by `prestage::settle` after the loop and the plan are
  done — never concurrently with the loop's own unwinds;
- dry runs build no plan, so nothing is staged.
Composer's intra-archive parallel inflate (CMP-1(2)) already exists.

Polyglot (vs PR #257 head): vfresh/vrerun at 0 ms byte-identical stdout
and work-tree digest, 479 grants, replay misses 0; vfresh at 0 ms
8.2 -> 7.8 s, at 100 ms 16.0 -> 15.1 s (on top of the prefetch).

Tests: the pre-stage lifecycle (claim, refused extraction, unclaimed
tree swept only by settle, verdict scoping), and cargo/composer oracles:
planned and unplanned runs leave the same outcome and the same tree, for
a good archive and one the extractor refuses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…and sources (approved behavior change)

USER-APPROVED BEHAVIOR CHANGE (perf plan V-4a / JS-2 generalized).

A vendored run fetched every selected patch's view, and the pristine
source of every lockfile-only package, before the vendor loop refused the
packages whose backend can never wire them — on the project's lock text
alone. On the polyglot monorepo that is 80 of 560 packages, refused again
on every re-run, each paying a view round trip (and 3 of them a registry
tarball) for a refusal that needed no network.

`vendor::lock_text_refusals` answers, with no view, which purls a backend
refuses on lock and manifest text alone, with the backend's exact code and
detail:
- npm in a pnpm / yarn classic / yarn berry project: the flavor's own
  pre-flight (V-1's gate) names the refused packages, and the backend
  itself — dry-run, no source, no patch content — supplies the words; its
  code must equal the pre-flight's, or nothing is refused early. Those
  backends evaluate every such gate before they read the package, dry run
  or not.
- cargo: the prelude's `locked_version_mismatch`, only when it is the
  crate's first refusal (an in-tree `cargo vendor` copy refuses first and
  stays the loop's).

What changes (documented in CHANGELOG [Unreleased] and CLI_CONTRACT.md):
- `scan --mode vendored` / `get --mode vendored`: such a package is
  refused in the download phase, after the Bun refusal and the ledger's
  already-vendored skip — `download.patches[]` records it `action:
  "failed"` with the backend's `errorCode`/`error`; `download.downloaded`
  drops and `download.failed` rises by their number; `vendor.events` loses
  their `failed` events (`vendor.summary.failed` drops by the same number)
  and, for lockfile-only packages, their `vendor_fetched_missing` events;
  no view, no pristine fetch. Exit code and `status` are unchanged. Purls
  the hosted redirect ledger claims (their takeover rewrites the lock) keep
  the loop's refusal, as does everything when that ledger is malformed.
- `vendor` (manifest-driven): the per-package `failed` events stay, but a
  lockfile-only package refused this way is deferred instead of fetched —
  no `vendor_fetched_missing` event, no registry request (with the registry
  unreachable, the gate's code replaces `vendor_fetch_failed`).

Observed on the polyglot fixture (vs the V-1+V-3 build): vfresh — download
downloaded 560->480, failed 0->80 (74 vendor_lock_entry_not_found, 4
vendor_override_conflict, 2 vendor_lock_entry_unsupported), vendor
summary.failed 81->1, 80 failed + 3 vendor_fetched_missing events gone,
requests 2250->2167 (80 views, 3 registry tarballs); vrerun — downloaded
81->1, failed 0->80, the same events gone, requests 791->708. Work-tree
digest byte-identical in both.

Tests updated (they pinned the old ordering):
- scan_vendor_e2e `exact_download_plan::a_package_the_loop_refuses_costs_zero_grants`
  (pkg-b's refusal is now a failed download record, and costs no view);
- e2e_yarn_legacy_cachekey_refusal_build `yarn{2,3}_{default,compression0}_*`
  (the berry cacheKey refusal fires in the download phase).
New: core oracles (the early refusal is exactly the backend's wet-run
refusal; package-lock refuses nothing early; cargo's refusal only when it
is the first), and a `vendor` e2e proving a refused lockfile-only package
is never fetched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-installed skip

V-4a moved every lock-text refusal ahead of the view fetch, including
packages the vendor loop never handed to a backend at all: a package
absent from the lock and not installed has no pristine source, so the loop
used to report it `skipped` / `package_not_installed` — and V-4a turned it
into a failed `vendor_lock_entry_not_found` (pnpm) or
`locked_version_mismatch` (cargo) download record, a new failure for a
package the user never had.

The early refusal now applies only where the loop would hand the package
to its backend (`vendor::lock_refusals_reaching_backend`): an installed
copy (the loop's own resolver + npm identity lookup, reusing scan's npm
crawl), or a verifiable registry resolution the pristine-source ladder
fetches (`pristine_fetch_is_verifiable`, the renamed cargo check — the
same entry choice as `fetch_pristine_package`). Everything else keeps
today's outcome and counts, on `scan`/`get --mode vendored` (both get
paths) and on manifest-driven `vendor`.

CHANGELOG and CLI_CONTRACT describe the final scope, and that a lock-text
refusal now takes precedence over view-derived outcomes (paid-access 403,
failed view, no applicable files).

Tests (fail with the narrowing bypassed): scan and exact-purl get over a
pnpm project (installed-unlocked and locked-uninstalled refused early,
absent-from-both skipped as before) and a cargo project (only a crate
installed at an unlocked version refused early), and `vendor` keeping the
not-installed skip for pnpm and cargo.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… path

npm_flavor::lock_text_refusals handed the backend an empty path as the
installed folder; an empty path resolves against the process cwd if
anything ever read it. Use a path inside a fresh private temp dir, and
skip the dry runs entirely when the pre-flight refuses nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…se refusal

The V-4a edit of the berry cacheKey refusal cases dropped the vendor-step
assertion along with the old per-package failed event. Restore it in its
new form: no vendor event for the refused package and a vendor summary
that does not count it again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e plan itself

The composer twin of the zero-grant e2e put the refused package first in
the loop order, where the prefetch passes it over before any request
whether the plan named it or not — it passed with the gate bypassed. The
refused package (now psr/http-message) sorts between the two granted
ones, behind a position the service answers, so a bypassed gate grants
it. A direct unit test of plan_service_downloads pins the plan too.
Both fail with the non-npm gate replaced by an unconditional plan entry
(3/3 e2e runs).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… loop

A run that crashed or was interrupted between pre-staging an archive and
prestage::settle left <copy>.socket-prestage trees under .socket/vendor
that only a later pre-stage of the same package replaced. Every wet vendor
loop (all sources, --offline included) now sweeps them first — under the
apply lock and before anything of its own is staged, so every one on disk
is stale — with the vendor levels only they kept alive. The sweep never
descends into a copy dir, and a dry run deletes nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…udget bounds

V-1 opened the prefetch reach straight to the API's in-flight cap (32)
for every ecosystem once the service answered, where the npm-only
prefetch had used 4 — so a service failing after a good start could face
up to 31 extra retry ladders. The reach now slow-starts: 4 after the
first answer, one more per good answer up to the cap, and back to 4 on an
availability failure (the barrier and the task breaker are unchanged).
Outcomes, events and stderr are unchanged: the plan stays advisory.

The module docs (and ARCHIVE_PREFETCH_BYTES) now say that the 128 MiB
budget bounds starting new downloads — the in-flight ones still land and
can carry the held bytes past it — and that pre-staged trees live on disk,
bounded by the plan and the pre-stage pool, not by size.

Tests: the reach arithmetic; a 32-wide window opens only 4 positions
while they are unanswered; good answers grow it to exactly the window.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…y lock refusal

The depscan vendored gate showed the one envelope difference the V-4a docs
did not name: with its 3 lock-text refusals moved to the download phase,
the vendor step has no failures left, so vendor.status goes
partialFailure -> success (the top-level status and the exit code stay
partial_failure/1). Say so, and name the human arm's message when every
selected package is refused this way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

WP-P3 pushed: vendored-loop prefetch across ecosystems, pre-staging, early lock refusals (2b380e3 → 35a42b0, fast-forward)

  • Per-backend gate refactor (9d4c024c): each service-backed backend (cargo, composer, gem, golang, maven, nuget, pypi) now has a pre-service prelude plus a service_preflight plan gate. The checks, their order and their outcomes are unchanged.
  • V-1: exact cross-ecosystem prefetch with a slow-start window (1074b871, 4738e4c5): the download plan now covers every service-backed record, not only npm, and each backend's own gate keeps out the packages that backend refuses first. That keeps the plan exact: a refused package gets no download grant. The prefetch window starts at 4, grows by one per good answer up to the API's in-flight cap, and drops back to 4 on an availability failure. A 128 MiB budget limits how many fetched archives wait for the loop: it stops new downloads from starting, but downloads already in flight still finish, so held bytes can go over it.
  • V-3: pre-staging (976f3e39, 8298526d): verified archives are extracted next to each backend's stage while the loop runs, and the backend claims the result with one rename. Trees left behind by a crashed or interrupted run are removed at the start of the next wet vendor loop. Dry runs delete nothing.
  • V-4a: early lock-text refusal (895dd939, narrowed in 0bc98bf8): the behavior change described below.

Before → after (replay harness, polyglot monorepo and depscan)

run PR head before (2b380e30) after (35a42b0c)
polyglot scan --mode vendored, fresh, 100 ms RTT 107.2 s 14.8 s
polyglot vendored fresh, 0 ms 8.26 s (2250 req) 7.01 s (2167 req)
polyglot vendored re-run, 0 ms 2.82 s (791 req) 2.89 s (708 req)
depscan vendored fresh, 100 ms (×2) 7.50 / 7.32 s (344 req) 4.24 / 4.20 s (341 req)

Output equality

  • V-1 + V-3 change nothing observable. Against this PR's previous head, polyglot fresh and re-run at 0 ms produce byte-identical work trees (digest abfa49ef5bc7 for both binaries, both steps), with 0 replay misses. Stdout is identical after sorting arrays. The only differences are the V-4a records listed below.
  • The depscan vendored fresh run (a copy containing only the lockfiles) produces an identical lock, workspace, package.json, ledger, vendored files and markers. Stdout differs only by the V-4a records listed below.

⚠️ Behavior change (approved): lock-text refusals happen before the download

On scan --mode vendored and get --mode vendored (both the search path and the uuid path), some packages are now refused before their patch view is fetched:

  • Which refusals: the pnpm, yarn classic and yarn berry lock-text gates, and cargo's locked_version_mismatch when it is the crate's first refusal. These packages are now refused before their patch view, and any pristine source, is fetched.
  • Which packages: only packages the vendor loop would actually hand to its backend. That means a package installed on disk, or one the lockfile resolves to a verifiable registry source.
  • What moves: each such package becomes a download.patches[] record with action: "failed" and the backend's exact errorCode and error.
  • Count changes: download.downloaded goes down and download.failed goes up by the number of these packages. vendor.events loses their failed events, and vendor.summary.failed drops by the same number. For lockfile-only packages, their vendor_fetched_missing events also disappear.
  • Nested status: vendor.status becomes success when these refusals were the vendor step's only failures (depscan: partialFailure → success).
  • What does not change: the exit code and the top-level status (partial_failure, exit 1).
  • What stays as it was: a package that is absent from the lock and not installed never reached a backend. It still downloads and stays skipped / package_not_installed, exactly as before. As a result, cargo refuses early only a crate that is installed at an unlocked version.
  • Also unchanged: purls claimed by the hosted redirect ledger, and all purls when that ledger is malformed; other lock flavors; --dry-run.
  • Precedence: because the view is never fetched, the lock refusal now wins over outcomes that come from the view (a paid-access 403, a failed view fetch, the no-applicable-files guardrail). The Bun refusal and the ledger's already-vendored skip still come first.
  • Manifest-driven vendor: the failed events stay. A refused lockfile-only package that the lock resolves is no longer fetched, so there is no vendor_fetched_missing event and no registry request. With the registry unreachable, the lock-gate code replaces vendor_fetch_failed.
  • Measured:
    • Polyglot (fresh and re-run): 80 packages move to the download phase (74 vendor_lock_entry_not_found, 4 vendor_override_conflict, 2 vendor_lock_entry_unsupported). For every one of them, the old vendor-loop refusal had the same code and wording. That saves 80 views and 3 registry tarballs per run.
    • Depscan: 3 packages move (2 vendor_lock_entry_unsupported, 1 vendor_override_conflict).

Tests

cargo test --workspace --no-fail-fast: 9316 passed, 0 failed (136 ignored); cargo clippy --workspace --all-targets -D warnings clean. The PR head is the exact commit that was tested.

New tests (10), each shown to fail when the fix it covers is removed:

  • V-4a scope: pnpm and cargo, across scan, exact-purl get and vendor.
  • The plan gate: plan_service_downloads directly, plus the composer zero-grant e2e, now with the refused package in the middle of the plan.
  • Stale pre-stage cleanup: a core unit test, and an e2e covering --offline and --dry-run.
  • Slow-start window: the window arithmetic, opening at 4 inside a 32-wide cap, and growing to exactly the cap.

The yarn cacheKey refusal tests again check that the vendor step emits nothing for the refused package.

Review

One adversarial reviewer raised 8 findings; all are addressed in the commits above:

  • the V-4a scope was too wide (narrowed);
  • the composer gate test never exercised the gate (fixed and unit-tested);
  • stale .socket-prestage trees were never removed (now cleaned up at loop start);
  • the prefetch window jumped from 4 to 32 (now slow-starts);
  • the byte budget and pre-stage limits were undocumented (documented);
  • the new precedence over view outcomes was undocumented (documented);
  • the lock-text dry run passed an empty installed path (fixed);
  • the yarn tests had lost their vendor-step assertion (restored).

🤖 Generated with Claude Code

…er-approved behavior change)

Unset, `--batch-size` / `SOCKET_BATCH_SIZE` now follows the endpoint the
run starts on: 500 purls per authenticated `patches/batch` POST (the
server's MAX_PURLS_PER_BATCH) and 100 on the public proxy, as before. A
given size still applies as-is on either endpoint, and 0 is still
floored to 1.

Chunks are also capped at a 256 KiB request body (the public proxy's
MAX_PATCH_PROXY_BODY_BYTES, the tightest batch route; the authenticated
API accepts 16 MiB): a chunk whose purls would serialize past it is split
greedily into consecutive smaller chunks, so the boundaries depend only on
the purls, their order and the two limits, and every chunk fits the proxy
should the run downgrade mid-run (which keeps the chunks already formed).
With a cap no chunk reaches, the chunking is exactly `chunks(batch_size)`.

Output is unchanged; the request count and shape change (polyglot
monorepo: 30 batch POSTs instead of 147; depscan: 12 instead of 56).

`ScanArgs::batch_size` becomes `Option<usize>` so an explicit value stays
distinguishable from none; the in-process tests that built `ScanArgs`
with `batch_size: 100` now say `Some(100)` (same explicit size, same
requests), and the parser snapshot asserts the unset default.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Pushed 35a42b0c..7d5d414e: WP-P4. It contains one commit, the approved batch-size change.

⚠️ Behavior change (approved): batch size 500 on the authenticated API

  • Default batch size: when neither --batch-size nor SOCKET_BATCH_SIZE is set, a batch holds up to 500 purls on the authenticated API. That's the server's own limit, MAX_PURLS_PER_BATCH in depscan batch.ts. The public proxy keeps 100.
  • Explicit values: an explicit value applies on both endpoints, and 0 still means 1.
  • Body-size cap: any chunk whose JSON body would exceed 256 KiB is split deterministically into consecutive chunks. 256 KiB is the public proxy's body limit. When no chunk reaches the cap, chunking is exactly the same as plain fixed-size chunking.
  • Mid-run downgrade: if a run falls back to the proxy after a 401/403, it keeps the chunks it has already built. The proxy has no purl-count limit of its own and forwards to the same 500-purl upstream, so this is safe. The reviewer confirmed it directly: authenticated [500], then proxy [500, 500, 1].
  • What changes for users: only the shape and count of batch requests, plus the --help default text. Output is unchanged. README, CLI_CONTRACT and CHANGELOG are updated.
Run, 0 ms Batch POSTs Canonical stdout Work-tree digest
polyglot hosted dry 147 → 30 identical identical
polyglot hosted wet 147 → 30 identical identical
depscan hosted dry 56 → 12 identical n/a

All non-batch requests are identical. depscan hosted dry at 100 ms: 3.30 s / 3.08 s → 2.68 s / 2.57 s.

Not done: DC-4, overlapping the crawl with the network

The plan assumed the list of batch purls was sorted, so non-npm chunks could go out while npm was still crawling. In fact, all_purls is in crawl order with npm first, and npm is the last crawler to finish on every benchmark. The most that could be overlapped is 18–84 ms, not the 1.5–2 s the plan projected, so this was dropped. The measurements are in the WP report.

A different overlap would be to start the by-package GETs while later batches are still in flight. That might save about 0.5 s at 100 ms, but it has trade-offs, so it is listed as a follow-up.

Tests: 9,325 passed, 0 failed, 136 ignored. Clippy is clean. There are 5 new unit tests and 4 new subprocess tests (scan_batch_sizing_e2e.rs). The only changes to existing tests are mechanical: batch_size: 100 became Some(100), and the parser's default assertion changed from 100 to None. One adversarial reviewer checked this against depscan's source and ran mutation tests. Verdict: push as-is.

🤖 Generated with Claude Code

1001 packages, the authenticated batch endpoint answers 401: the run
downgrades to the public proxy, which gets the run's own chunks
([500, 500, 1], the failed chunk retried as-is first) rather than a
100-purl re-chunk. The warning prints once and the run succeeds.
Verified by mutation: re-chunking at 100 on downgrade fails the test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikolalysenko
Mikola Lysenko (mikolalysenko) merged commit 8c381ec into main Sep 27, 2026
445 of 447 checks passed
@mikolalysenko
Mikola Lysenko (mikolalysenko) deleted the perf/scan-concurrency branch September 27, 2026 12:03
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request Sep 27, 2026
#257 landed the X1b deferred pristine fetch, which already refuses a
not-installed gem in `--vendor-source build` with `gem_spec_missing`
BEFORE downloading it, under the same scope this branch's gate had
(verifiable lock entry, no ledger entry). Take main's gate and drop
this branch's copy; keep the scope-guard suite, adapted to main's
behavior (the backend's own detail text, and the already-vendored
fresh-clone re-run now makes no registry request at all).

The other three fixes still apply and are ported onto #257's code:

* js7: the in-memory stager's view fetches now run through
  `ordered_concurrent`; the per-file `needs_blob` rule and the
  per-package drop reasons fold into that loop unchanged. The dropped
  records never reach `vendor_records`, so they stay out of the exact
  service-download plan and the group commit too (new test).
* order: `filter_to_installed_releases` still drains a HashMap on main;
  the purl sort now also orders #257's view prefetch plan, which is
  built from the same selection.
* req: untouched by #257.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request Sep 27, 2026
Bring in the concurrent scan work from #257. The Cargo member walk keeps
the tree abstraction the in-memory engine needs, and its disk tree reads
manifests through the new parse memo; the memory tree parses directly.
The in-memory Cargo.lock reader adopts the memoized Arc<DocumentMut>
that read_lock now returns.

Assisted-by: Claude Code:claude-opus-5-5
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request Sep 27, 2026
Brings in #257 (concurrent API requests, parallel crawl, single-pass
rewriters) and fits vlt support into its new structure with the same
output bytes, warnings and ledger edits.

- Hosted rewriters: the vlt rewriter is its own group in the parallel
  rewriter groups, right after the npm-family group, so edits and
  warnings keep the serial order. It gets the overrides minus the
  vlt-withheld uuids; vlt_drives is set once after the merge, and the
  group merge carries the vlt uuid sets. The group equivalence oracle
  now includes vlt (and a bun.lockb case). The hosted rewrite runs on
  the blocking pool with the vlt withholding. The confirmation probe
  searches a withheld dep's needles in every final text except
  vlt-lock.json, so the one-pass presence check still skips the stale
  vlt pin.
- Crawler: the `.vlt` store is walked by the new parallel scan (after
  the pnpm stores) and by the level-by-level resolver. Store entries
  carry their vlt- or pnpm-decoded name. Inside a store entry only real
  package dirs match, and a store copy an importer link already
  resolves to is not added again. The peer-variant finder is
  find_store_peer_variant_copies again. The sequential oracle gets the
  same vlt changes, and its random trees now include vlt stores.
- Vendored: vendor_vlt and stage_patch_dir take a PackageSource (lazy
  or deferred pristine sources). The download plan gets a vlt
  pre-flight (flavor change, lock analysis, wiring plan). The committed
  vlt dir artifact is staged in the new missing-purl local rung, only at
  the record's uuid. Links to vlt's own vendored dir are still dropped
  as pristine sources. strict tar extraction is threaded through
  walk_tar_gz. get's view prefetch skips vlt-refused purls.
- Under the group commit, a failed vlt-lock.json write is now
  vendor_commit_failed with nothing committed (the test is updated to
  that contract).

Assisted-by: Claude Code:claude-opus-5-5
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request Sep 29, 2026
* Pin the #257 oracle sweeps with golden digests

The randomized equivalence sweeps that compare the blocking-pool
crawlers and single-pass rewriters with their pre-#257 oracles now
also record, per case, a digest of the generated input and of the
output both implementations agreed on, in
crates/socket-patch-core/tests/equivalence/*.golden. The oracles
still run, so every recorded output is proven equal to the oracle's.
The next commit deletes the oracles and keeps the goldens.

The crawler goldens were blessed on Linux as a non-root user: the
sweeps strip permissions and plant symlinks, so they replay only
there (other platforms and root still run the sweeps, without the
golden comparison).

Also moves the crawler sweeps' xorshift test RNG to
crate::test_rng, and renames crawlers::oracle_support to
crawlers::test_tree.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9YdJqaGiT9Jf5LN1hDFhB

* Retire the #257 crawler and rewriter oracles

The equivalence sweeps now replay against the golden digests pinned
in the previous commit instead of re-running the verbatim previous
implementations: the crawler oracles (cargo, composer, go, maven,
npm, nuget, python), the hosted rewriter oracles and the reference
POM parser are deleted. Every sweep still generates the same inputs
from the same seeds, so a changed output is still caught per case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9YdJqaGiT9Jf5LN1hDFhB

* Pin the pdm and poetry rewrites by golden

The pdm plan, pdm and poetry parse-reuse rewrites were checked
against #257's verbatim fresh-parse implementations. Their sweeps
now record per-case digests, blessed while those oracles still
agreed with production, and the oracles are deleted.

Golden keys with whitespace are normalized so a case label can
never split a golden line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9YdJqaGiT9Jf5LN1hDFhB

* Merge 67 CLI test binaries into command suites

The subprocess-only integration tests for apply, get, remove,
rollback, repair, scan, vendor, update and the global CLI now build
as one binary per command instead of 67, so CI links and runs 58
fewer test binaries. Each file keeps its name as a module, and the
shared helpers are declared once per suite, so their self-tests run
once per suite instead of once per file.

Binaries a workflow names, the docker e2e suites, crawl_fd_limit_e2e
and the in-process suites (which share process-global notice and
config state) stay separate. Doc links to the moved files are
updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9YdJqaGiT9Jf5LN1hDFhB

* Point docs at the merged test suites

Rows that named a moved test binary now name its suite and module.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9YdJqaGiT9Jf5LN1hDFhB

* Keep the crawler oracles

A crawler's output follows the directory order the filesystem hands
back, which differs between filesystems: every crawler golden blessed
on ext4 failed when replayed on tmpfs, and macOS and Windows skipped
them. The per-call oracles walk the same tree as the crawler, so they
check the parallel walk on every platform; restore them and drop the
crawler goldens. The rewriter goldens are pure text and stay.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9YdJqaGiT9Jf5LN1hDFhB

---------

Co-authored-by: Claude <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko) pushed a commit that referenced this pull request Sep 29, 2026
Brings in #297, which groups the CLI tests into one binary per command
and replaces the #257 oracles with golden files. It merges cleanly;
this PR's test binaries stay at the top level, as most still do.
Clippy and the policy, in-memory, parity, e2e policy, parser, help,
scan and get suites pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKsyzefGhAnPkYmXCwq3H3
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