Skip to content

perf(VSY-45): Bound scratch scanning CPU while preserving complete size readings - #83

Open
vanillagreen-fleet-lanes[bot] wants to merge 5 commits into
mainfrom
vsy-45
Open

vanillagreen-fleet-lanes[bot] wants to merge 5 commits into
mainfrom
vsy-45

Conversation

@vanillagreen-fleet-lanes

Copy link
Copy Markdown
Contributor

Summary

  • Scratch directory measurement moves off the dashboard's own thread onto a worker thread. The traversal reads each directory in one listing, takes each entry's status synchronously, works for a slice, then rests for what that slice earned at its duty. A new setting, scratchDutyPercent, sets that share and defaults to 25.
  • A scan becomes due again only once the rescan interval has passed since the last one finished, measured on the monotonic clock. Measured from the attempt, a traversal longer than the interval is eligible again the instant it ends, which is why the traversal never paused.
  • Cooperative cancellation is removed: a scan the host gives up on is stopped by ending its thread. The build emits the worker as a second entry point, and the check contract requires both artifacts and runs bun run bench:scratch as a gate, so the processor bound cannot be deleted with every check green.

Context

  • D004: Bound scratch traversal with a duty cycle on its own thread — docs/decisions/D004-scratch-scan-duty.md

Completed Issues

  • Closes VSY-45 - Bound scratch scanning CPU while preserving complete size readings

QA Metrics

Performance QA at ca52326 against origin/main 1039bc5. Linux 6.19.14, 8 cores, Bun 1.4.2. Full percentile set is on the Linear issue.

Whole-application processor use, share of one core:

Scenario main this branch
Idle, scratch off 16.1% 15.6%
82,041-entry tree, rescanned back to back 137.9% 49.8%

Scan cost on the same tree, 5 runs, identical 202,320,253-byte total in every variant: 4868 ms of processor time per scan on main against 1406 ms here, a sustained 31.6% of one core.

Keyboard round-trip latency, 200 key samples per variant through a pseudo-terminal:

Variant main p50 / p95 this branch p50 / p95
Scratch off 11.46 / 21.74 ms 11.45 / 20.46 ms
Small tree, 1,801 entries 15.16 / 89.69 ms 14.79 / 84.47 ms
Large tree, default interval 12.60 / 53.82 ms 11.80 / 50.07 ms
Large tree, continuous rescan 7.42 / 42.48 ms 13.40 / 55.59 ms

The last row is the one trade this change makes. main's lower figure there is taken while it holds 1.4 cores: a saturated main thread keeps the renderer awake, so a keypress paints without a wake-up. This branch's figures in that row match its own figures with no scan running at all (13.40 against 11.45 at the median), so typing latency here does not move with the traversal. No project document sets a keyboard latency budget.

Foreground refresh is unchanged, measured rather than assumed: 16 collection samples per 16 s window in every variant of both builds, and the same rendered frame counts in both.

Size

771 production lines, 537 test lines, 0 render-mirror lines. The issue states no Expected delta, so the verdict is allowance_missing: the counts are reported, not judged.

Known residue

  • .github/workflows/ci.yml line 15 names the job Bun (lint, types, tests, build), which now omits the scan-bound step. That string is the commit-status context branch protection requires, so renaming it strands the required check until branch protection is updated in the same move. Left unchanged deliberately; both changes have to land together.
  • One python3 scripts/ci.py run exited 1 during development with its output discarded and could not be reproduced. Three later full runs, eight benchmark runs and this commit are clean. bun install --frozen-lockfile is the only non-deterministic step in the contract. Recorded rather than filed, because it is neither reproducible nor evidenced. Worth watching on CI.
  • A synchronous rewrite of scanRoot.walk would save about 16% of scan processor time for an identical reading. Declined at priority 4, estimate 2: it clears no filing bar and is too large to absorb here.

Test Plan

  • python3 scripts/ci.py — the full contract: install, lint, types, tests, build, both build artifacts present and non-empty, and the scan bound. Green in ~18 s, 525 tests across 56 files, plus 8 check-runner tests.
  • bun run bench:scratch — refuses a scan that reported a source error, a bounded scan whose total differs from a full one, a scan that asked for no rest, and a scan that finished in under 95% of the rest it asked for. Margin measured 1.31 to 1.37 against the 0.95 floor across runs; load can only widen it, because elapsed time contains the actual sleeps.
  • bun src/main.ts --once — still waits for a complete reading and still exits 2 on source errors.
  • Verified against deliberate breakage: removing the rest, dropping the wait, dropping the benchmark from the check list, dropping a build artifact, and narrowing script validation to one check each turn a check red.

Scratch traversal held 123 to 137 percent of one core because it submitted
one asynchronous status read per entry on the dashboard's own thread and
became eligible again the instant a scan longer than the rescan interval
finished. It now runs on a worker thread with synchronous batched reads,
rests for the share of each slice its duty does not grant, and measures
eligibility from completion on the monotonic clock. D004 records the
trade-off against a stored filesystem index.
Copilot AI balanced review requested due to automatic review settings September 15, 2026 05:54
@linear-code

linear-code Bot commented Sep 15, 2026

Copy link
Copy Markdown

VSY-45

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Release packages omit the required worker artifact, and the benchmark does not fail when worker-side throttling is bypassed.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Moves scratch-directory scanning to a duty-cycled worker thread while preserving complete readings and responsive collection.

Changes:

  • Adds configurable scratch-scan CPU duty cycling and completion-based scheduling.
  • Adds worker lifecycle, traversal, UI-state, and failure-handling tests.
  • Extends build, CI, benchmark, architecture, and changelog coverage.
File summaries
File Description
src/ui/storage-screen.tsx Unifies scratch status and empty-state rendering.
src/ui/storage-screen.test.tsx Tests scratch summary states.
src/ui/settings.ts Exposes the scan-share setting.
src/config/config.ts Defines and validates scan duty.
src/config/config.test.ts Tests duty bounds.
src/collect/settings.ts Marks duty as collection-affecting.
src/collect/scratch.ts Adds worker orchestration and scheduling.
src/collect/scratch.test.ts Tests collector behavior and failures.
src/collect/scratch-worker.ts Implements the worker entry point.
src/collect/scratch-worker.test.ts Tests worker lifecycle and replies.
src/collect/scratch-scan.ts Implements paced synchronous traversal.
src/collect/scratch-scan.test.ts Tests traversal and pacing.
scripts/ci.py Gates benchmark and build artifacts.
scripts/ci_test.py Tests the expanded CI contract.
scripts/bench-scratch.ts Measures scratch traversal behavior.
package.json Builds the worker and adds its benchmark.
docs/decisions/INDEX.md Indexes D004.
docs/decisions/D004-scratch-scan-duty.md Records the worker-duty decision.
docs/architecture/storage.md Documents scratch invariants.
DEVELOPMENT.md Updates build and benchmark guidance.
changelog.d/fixed/scratch-scan-failure.md Records failure preservation.
changelog.d/fixed/scratch-first-scan-line.md Records corrected empty-state messaging.
changelog.d/changed/scratch-scan-thread.md Announces worker-based scanning.
AGENTS.md Updates the full-check command description.
Review details
  • Files reviewed: 24/24 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/bench-scratch.ts
);
}
const full = await measure(100);
const bounded = await measure(defaults().scratchDutyPercent);
Comment thread src/collect/scratch.ts
Comment on lines +27 to +30
const candidates = ["./scratch-worker.ts", "./scratch-worker.js"].map(
(name) => new URL(name, import.meta.url),
);
const found = candidates.find((url) => existsSync(fileURLToPath(url)));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant