Skip to content

fix(staged): cap the private npm cache so ~/.staged/packages stops growing - #1070

Open
matt2e wants to merge 2 commits into
mainfrom
staged-packages-grow-forever
Open

matt2e wants to merge 2 commits into
mainfrom
staged-packages-grow-forever

Conversation

@matt2e

@matt2e matt2e commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Nothing ever deleted from the private npm download cache at ~/.staged/packages/npm-prefix/cache/_cacache. Every floating bridge install added the new version's tarballs beside the old ones, and npm left an orphaned body behind for each revalidated package document, so the cache grew by one to two gigabytes a month.

  • Add managed_acp_tools/cache.rs with the single definition of the cache path, a 1 GiB cap and prune_npm_cache_at. The prune sweeps _cacache.trash-* leftovers from an interrupted run, sums file sizes without following symlinks, and renames an oversized _cacache aside before deleting it so npm never sees a half-deleted cache. It is best-effort and logs failures at warn level.
  • Run the prune from the reconcile epilogue on every pass, inside the locked scope, on the blocking pool.
  • Lock Doctor's npm-backed fixes and updates, and the deprecated-bridge fix, with a new NpmWriteLock (tool-install mutex, then the packages flock). Each fix ends in finish_npm_fix, which prunes under the still-held lock whether the command passed or failed, so builds that manage no bridges (and never reconcile) are still capped.
  • Refuse to prune unless npm-prefix and npm-prefix/cache are real directories, so a symlinked cache (for example one pointing at ~/.npm) is never sized or wiped. A symlinked packages root is still capped.
  • Log when a Doctor fix waits for the npm write lock and when it acquires it.
  • Use the cache path helper in the bridge installer and the managed npm env pairs.

No fields are added to state.json or any stored model; the only new field is the in-memory packages_root on the lock guard. node/, tools/, _logs and npm-prefix/lib are left alone.

Tests

All in temporary directories: caches under, at and over the cap; survival of everything else under the packages root; leftover trash removal (including a symlinked trash entry); a missing, plain-file or symlinked _cacache; symlinked cache and npm-prefix left alone; a symlinked packages root still capped; pruning on a failed pass; the epilogue waiting for another descriptor's flock; the write lock pruning while holding both locks; and finish_npm_fix pruning for passing and failing fixes while returning each result unchanged.

Not covered by a test: that run_doctor_fix and run_doctor_update call finish_npm_fix, since those spawn real commands against the real packages root.

🤖 Generated with Claude Code

matt2e and others added 2 commits November 13, 2026 15:37
…owing

Nothing ever deleted from the npm download cache at
~/.staged/packages/npm-prefix/cache/_cacache. Every floating bridge
install added the new version's tarballs beside all the old ones, and npm
left an orphaned body behind for each package document it revalidated, so
the cache grew by one to two gigabytes a month.

The reconcile epilogue now wipes `_cacache` whole once it holds more than
1 GiB. The cache has no correctness role, so the only cost is one
re-download of the bridges' tarballs on the next pass.

- Add `managed_acp_tools/cache.rs` with the single definition of the
  cache path, the 1 GiB cap and `prune_npm_cache_at`. The prune removes
  `_cacache.trash-*` leftovers from an interrupted run, sums file sizes
  without following symlinks, and renames an oversized `_cacache` aside
  before deleting it so npm never sees a half-deleted cache. It is
  best-effort and logs failures at warn level.
- Call it from `finish_reconcile_at` inside the locked scope, after the
  outcome is recorded, on the blocking pool. It runs on every pass, not
  only successful ones, so a bridge that keeps failing cannot switch the
  cleanup off.
- Use the cache path helper in the bridge installer and in the managed
  npm env pairs.
- Lock Doctor's npm-backed fixes and updates, and the deprecated-bridge
  fix, with a new `NpmWriteLock` that takes the tool-install mutex and
  then the packages flock. It is taken only after the managed Node
  runtime ensure returns, because that call takes the same flock. A fix
  can now wait behind another instance's in-flight install.
- The `npm view` and registry probes stay unlocked: one that races a wipe
  fails once and already reports the latest version as unknown.
- Describe the cap in the `managed_acp_tools` layout comment and the
  reconciler header.

No fields are added to `state.json`, and `node/`, `tools/`, `_logs` and
`npm-prefix/lib` are left alone.

Tests cover a cache under, at and over the cap, survival of everything
else under the packages root, leftover trash removal, a missing cache, a
`_cacache` that is a plain file or a symlink, symlinks inside the cache,
pruning on a failed pass, and the epilogue waiting for a second
descriptor's flock before it prunes. All use temporary directories.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…nside the packages tree

Follow-up to fca2ddb, addressing the review of that commit.

Restricted builds never capped the cache. The reconcile loop returns
before its first pass when the managed set is empty, so with the
`no-managed-acp-tools` feature, with `STAGED_ACP_TOOLS_DIR` set, or on an
unsupported target the epilogue's prune never ran, while Doctor's
`npm install -g` fixes and updates kept filling the same cache.

- `NpmWriteLock` now remembers its packages root and gains
  `prune_npm_cache`, which applies the 1 GiB cap while the lock is held.
- Doctor's npm-backed fixes and updates, and the deprecated-bridge fix,
  end in a new `finish_npm_fix`. It prunes under the still-held lock,
  after a failed command as well as a successful one, and returns the
  fix's own result untouched. The prune is best-effort and returns
  nothing, so it cannot fail a fix.
- The spawn-blocking wrapper around the prune moves into `cache.rs` as
  `prune_npm_cache_on_blocking_pool`, shared with the reconcile epilogue.

The prune could leave the packages tree. Only the final `_cacache`
component was checked for a symlink, so a `cache` or `npm-prefix` linked
elsewhere sent the leftover sweep and the wipe to the link target, for
example a `~/.npm` that the user's own npm writes without any Staged
lock. `prune_npm_cache_at` now stops, with a warning, unless both are
real directories. A missing component stays a silent no-op, and a
symlinked packages root is still capped because the whole tree and its
lock file sit behind it.

The wait for the npm write lock was silent. `prepare_npm_fix` now logs at
info level when it starts waiting and when it has the lock, with the
check id and the elapsed time. The first line is written before the
managed Node runtime ensure, because that call can queue behind the same
flock. Locking behaviour is unchanged and no timeout is added.

The reconciler header, the `managed_acp_tools` layout comment and the
cache module docs now say that "silent when there is nothing to manage"
includes the cache cap, and that Doctor's fixes cover it there.

Tests, all in temporary directories:

- a symlinked `cache`, a symlinked `npm-prefix` and a plain-file `cache`
  are left alone, including the linked cache's trash leftover
- a symlinked packages root is still capped
- a `_cacache.trash-*` symlink is unlinked and its target survives
- the write lock prunes an oversized cache and still holds both locks
  afterwards, and leaves a cache within the cap alone
- `finish_npm_fix` prunes at the real cap for a passing and a failing
  fix and returns each result unchanged, leaves a cache exactly at the
  cap, and passes a result through when no lock is held. A sparse file
  stands in for a cache over 1 GiB.

`lock_npm_writes_at` becomes `pub(crate)` and is re-exported under
`cfg(test)` so the Doctor tests can lock a temporary root. The one new
field is the in-memory `packages_root` on the lock guard; nothing is
added to `state.json` or any stored model.

Not covered by a test: that `run_doctor_fix` and `run_doctor_update`
call `finish_npm_fix`. Those commands resolve the real packages root and
spawn real commands, so that wiring is checked by reading only.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-13T07:08:45.930736Z 4b23d16 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

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