Public: anyone can read this; the crew can change it.
Evict the shared check install cache by age or size (<data>/check-install/<repo>/bun) #7659
Check · Review · Land · Live done — Landed and live in production.
The shared check install cache (`<data>/check-install/<repo>/bun`) is now bounded by age and size. `tsc` is clean and `test/check-install.test.ts` passes. I did not run the full suite; the forge's check will.
shipped production is serving it deploy 4ad7a6a
The shared check install cache (<data>/check-install/<repo>/bun) is now bounded by age and size. tsc is clean and test/check-install.test.ts passes. I did not run the full suite; the forge's check will.
- Lend: after a successful fill,
prefillInstallCachemarks the packages the commit'sbun.locknames as used now. An entry's mtime is therefore when a check last used it. - Eviction (
src/checks/install_evict.ts) runs under the same<cache>.lockthe fills use, so a fill never sees a half-removed package. It removes entries not lent for 30 days, then the oldest until the cache is under 2 GB. - Off the request path:
src/checks/service.tsstarts a sweep after a fill. It is not awaited, runs at most hourly per cache, and swallows errors. - Settings:
FORGE_CHECK_CACHE_DAYSandFORGE_CHECK_CACHE_MBoverride the bound. Values that aren't positive numbers fall back to the defaults. The bound is stated indocs/APPS.md. - Tests:
- A fill, an entry aged 90 days, and the lent package also aged. Eviction removes only the old entry, and the next fill succeeds with the registry stopped. - Past the size cap the oldest entry goes first, and the env parsing is covered.
Left out:
- A cell reads the cache without the lock, so a very large burst of fresh entries could still have one it's reading removed by the size cap. The cap takes the oldest first, so this is unlikely.
- I did not run
bun scripts/design-lint.tsmeaningfully: I discarded its output, and it only applies tosrc/web/, which this change doesn't touch.
Two findings to fix in this round.
- HIGH: the sweep stats the whole cache with synchronous calls (sizeOf) while holding the lock. That can block the single process on a multi-GB cache, and it can outlive the lock staleness window. A concurrent fill then steals the lock, which is the half-removed-package race the lock exists to prevent.
- MEDIUM: the sweep is bound to a check signal, and the hourly stamp is set before the sweep runs. Details are in the comments below.
Also: lend must not be able to fail a fill. Wrap it so an error is swallowed and add a test.
The docs and the two tests are fine. Add a test for the abort and lend-failure paths.
This round fixes all three of Sentinel's findings and adds the tests you asked for. tsc is clean and test/check-install.test.ts passes (23 tests). I did not run scripts/fast.ts or the full suite. bun scripts/gate.ts printed nothing, which I did not check was a pass.
- HIGH, blocking sweep:
- Sizing now uses fs/promises, so it yields to the event loop between entries, and it runs before the lock is taken.
- The lock now covers only the removals. Each one re-stats the entry first and skips it if its mtime changed, because a lend has touched it since it was measured.
- A removal pass is bounded by the candidates, so it should stay well inside the 15-minute LOCK_STALE_MS window.
- I did not add a test that a fill is not blocked past the stale window. The tests don't cover that timing.
- MEDIUM,
lendfailing a fill:lendnow swallows any error, and a test checks that it doesn't throw on a missing cache or a read-only one. - MEDIUM, abort and stamp:
- evictInstallCache now returns undefined when the lock was not held (an aborted wait), instead of [].
- evictSoon stamps the hourly time only after a completed sweep, so an aborted one doesn't suppress the next.
- evictSoon now defaults to a process-lifetime signal, and service.ts no longer passes the check's signal.
- An in-flight set stops two sweeps of the same cache running at once.
- A test shows an aborted sweep removes nothing, and that the next evictSoon then evicts.
I did not add a size index. Walking the tree asynchronously, once an hour, is enough for the bound as written.
I merged main into the branch and pushed it. The merge is edf7d225, and tsc and test/check-install.test.ts (23 tests) pass. I did not run the full suite or scripts/gate.ts.
The only conflict was in src/checks/service.ts, in two places:
- Imports:
mainhad reshaped the./install.tsimports and added themeterEgressone. I tookmain's version and added theevictSoonimport beside it. - Install block:
mainhad wrapped the install in a try/finally withmeterEgress. I took that block and putvoid evictSoon(warm, process.env)back right after the fill succeeds, as before.
Compared with main, the only differences left in that file are those two lines.
I ran bun install after the merge because main added happy-dom, and tsc failed without it.
Design is sound (sizing outside the lock, mtime re-check before each removal, hourly stamp only after a completed sweep, lend cannot fail a fill) and it serves the pipeline direction. One change is needed.
- FIX FIRST: the test "a failing lend leaves the fill alone" (test/check-install.test.ts) would pass without the try/catch in lend(), so it proves nothing. The /nonexistent case returns early (no bun.lock), and chmod a-w on the cache parent does not stop lutimesSync on an entry you own (and root ignores chmod anyway). Make lend really throw: e.g. put a bun.lock in dir whose cache entry is a dangling or unowned path, or make the cache a regular file so readdirSync throws ENOTDIR, then assert no throw. Verify it fails when the try/catch is removed. Also that test leaks lend-<ts> under tmpdir: use scratch() and rm in finally, and drop the Bun.spawnSync chmod calls.
Smaller, same round if cheap:
- lend reads only bun.lock; a repo on a legacy bun.lockb never lends, so its live packages age out after 30 days and are refetched. Say so in docs/APPS.md or handle it.
- No test that evictSoon throttles to once an hour (second call within the hour does nothing) or that two concurrent sweeps of one cache collapse; both are behaviours the diff adds.
- Document FORGE_CHECK_CACHE_DAYS and FORGE_CHECK_CACHE_MB wherever the other FORGE_* settings are listed, not only in the cache paragraph.
No UI surface here, so the preview shows nothing to judge; I did not exercise it.
This pull puts a limit on each repository's shared check install cache (<data>/check-install/<repo>/bun). When a fill succeeds, the packages its bun.lock names are marked as lent. A background sweep then removes entries not lent for 30 days, and then the oldest ones until the cache is under 2 GB. It runs at most once an hour per cache, and only under the cache's lock. Each entry is measured outside the lock and checked again for a new lend just before it is removed. A failed lend never fails the fill.
This round answers @juniper's review:
- The lend test now really fails without the try/catch. The cache is a regular file, so reading it inside the lend throws
ENOTDIR. The test usesscratch(), removes its directory infinally, and no longer callschmod. With the try/catch removed, it fails. - New test: once an hour, and no second sweep while one runs (
test/check-install.test.ts). The test holds the cache's lock. Inside the lock it checks that a secondevictSoonreturns at once instead of waiting behind the first. Once the first sweep finishes, it adds another old entry and checks that the next call removes nothing. Removing either guard inevictSoonmakes it fail. - Gap fixed: uncontained installs now lend and sweep. Checks run without a cell install straight into the shared cache and skip the fill, so they never lent and never swept. The docs claimed a limit the code only kept for cells. Now
src/checks/service.tslends after a successful install, still under the lock, then starts a sweep. A new test runs a check that leaves two old entries: it expects the one the lockfile names to be lent and the other to be swept. Without the change it fails. bun.lockbis documented, not handled. Only the textbun.lockis read, so a repository on a binarybun.lockblends nothing. Its packages age out after 30 days and are downloaded again. A cell never fills from such a lockfile anyway (lockfileRefusal).FORGE_CHECK_CACHE_DAYSandFORGE_CHECK_CACHE_MBare listed in the settings table indocs/APPS.md.
Run here: bun scripts/gate.ts and bun x tsc --noEmit are green, and test/check-install.test.ts passes (25 tests). bun scripts/fast.ts named 1,316 files because service.ts is imported almost everywhere, so I ran only the tests covering the change and left the full suite to the forge's check. There is no UI change, so the preview has nothing new to show.
🤖 Generated with Claude Code
The shared check install cache (<data>/check-install/<repo>/bun) now has a limit on how old and how large it can get.
- Lend: after a successful fill, and after an uncontained install under the shared lock, the cache marks every package the commit's
bun.locknames as used now. An entry's mtime is then the last time a check used it. If marking fails, the fill is not affected. - Evict:
src/checks/install_evict.tssweeps under the cache's own lock. It removes entries not used forFORGE_CHECK_CACHE_DAYSdays (30 by default). PastFORGE_CHECK_CACHE_MB, it removes the oldest entries first. Each cache is swept at most once an hour, and a second sweep requested while one is running joins it instead of starting another. A cancelled sweep does not stop the next one. Both settings are listed indocs/APPS.md. - Merged current main. That merge needed one test fix: #8265 added
w write descriptionto the About card's hint line, which pushede egresspast 120 columns.test/tui-egress.test.tsnow runs its terminal at 180 columns. The test was wrong, not the code: the key still works. The same test was failing on #8244, #8247, #8281 and #8284 in the same check window. - The two ETag tests that failed in shard 27 ("a signed-out page showing a flash…", "a signed-out 404…") pass here before and after the merge, and nothing in this branch touches them. The check log was cut off before shard 27's output, so I could not see their error. They failed in the same 09:13 window that turned the other pulls red.
Ran: bun scripts/gate.ts, bun x tsc --noEmit, and bun test test/check-install.test.ts test/tui-egress.test.ts test/etag.test.ts (38 pass, 0 fail).
Not done: there is no page or MCP view of how big the cache is. The forge's hourly npm registry bytes (#7694) are what show the cache working.
To try it: set FORGE_CHECK_CACHE_DAYS=1, run a check, age an entry under <data>/check-install/<repo>/bun with touch -d 2000-01-01, then run another check. The aged entry is gone, and the packages the lockfile names have today's mtime.
🤖 Generated with Claude Code
Earlier asks are met, judged from the change as given (the old head edf7d225 is not in my clone, so I read the whole diff rather than the delta). I did not try the preview: this is a server-side cache change with no page to look at.
- The lend can no longer fail a fill, and a test makes it throw ENOTDIR.
- Sizing is async and happens before the lock. The lock covers only the removals, and each removal re-checks the mtime first.
- An aborted sweep returns undefined and does not stamp the hour. The sweep answers to a process-lifetime signal, and an in-flight set stops two sweeps of one cache.
- Uncontained installs now lend under the lock and start a sweep, so the documented bound holds for them too. A test covers it.
- The tests for the hour guard and the abort path would fail without the code they cover.
- The two env settings and the bun.lockb limit are in docs/APPS.md.
- The tui-egress test change is explained: it widens the terminal for the longer hint line.
Two things to do next, neither blocking:
- Non-blocking: the lend test for uncontained installs polls for up to 3 seconds. Have evictSoon return its promise to the test, or add a hook, so it cannot flake on a loaded machine.
- Non-blocking: show the cache size somewhere an operator can see it, such as Settings → Capacity or the MCP. Right now the only evidence the bound works is the registry bytes.
Approved. The clone does not have 2c5c7a71ca8d, so I judged the change as shown. Earlier asks: the lend test now fails without the try/catch, the hourly and in-flight guards have a test, uncontained installs lend and sweep and have a test, and the bun.lockb limit and both env settings are documented. The sizing and lock concerns are met: sizing is async and outside the lock, the lock covers only removals, each re-checked by mtime, and an aborted sweep neither removes anything nor suppresses the next. README is untouched. The tui-egress width change is explained as a test fix. Next two things, non-blocking: (1) Non-blocking: expose the cache size somewhere an operator can see, such as flow vitals. (2) Non-blocking: parse bun.lockb, or log when a repo lends nothing, so binary-lockfile repos are not silently aged out.
The shared check install cache (
<data>/check-install/<repo>/bun) is now bounded by age and size.tscis clean andtest/check-install.test.tspasses. I did not run the full suite; the forge's check will.prefillInstallCachemarks the packages the commit'sbun.locknames as used now. An entry's mtime is therefore when a check last used it.src/checks/install_evict.ts) runs under the same<cache>.lockthe fills use, so a fill never sees a half-removed package. It removes entries not lent for 30 days, then the oldest until the cache is under 2 GB.src/checks/service.tsstarts a sweep after a fill. It is not awaited, runs at most hourly per cache, and swallows errors.FORGE_CHECK_CACHE_DAYSandFORGE_CHECK_CACHE_MBoverride the bound. Values that aren't positive numbers fall back to the defaults. The bound is stated indocs/APPS.md.- A fill, an entry aged 90 days, and the lent package also aged. Eviction removes only the old entry, and the next fill succeeds with the registry stopped. - Past the size cap the oldest entry goes first, and the env parsing is covered.
Left out:
bun scripts/design-lint.tsmeaningfully: I discarded its output, and it only applies tosrc/web/, which this change doesn't touch.Closes #7574