feat(storage): add rocm storage to see and reclaim disk space - #172
feat(storage): add rocm storage to see and reclaim disk space#172rominf wants to merge 6 commits into
Conversation
Every SDK install that resolves a new version creates a fresh multi-GB runtime tree, and nothing ever removed the old ones. Add `rocm storage`: - `report` (the default for the bare verb, with `--json`) sizes every managed runtime, the ROCm CLI cache and data folders, and — clearly labelled as shared with other tools — the uv and model caches. - `remove-old-installs` applies a keep-N-per-channel/format/family retention policy and delegates each removal to `uninstall_runtime`. - `remove-downloads` drops cached archives that can be fetched again. Sizing is best-effort: an unreadable path is reported with an unknown or partial size rather than failing the whole report. Closes #161 Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
…endering Covers keep-N grouping per channel/format/family, the force-keeps for the active, previous, default, and marker-referenced installs, the refusal to touch adopted/imported/read-only records or folders without a matching in-tree manifest, tolerant sizing of a missing folder, and that the downloaded-file plan never reaches model weights. Closes #161 Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
`treat_as_natural_language` gates the clap parse on a hard-coded list of top-level verbs, so `rocm storage ...` was being handed to the freeform planner and never reached its subcommands. Register it alongside the other structured verbs, and give `format_bytes` KiB/MiB tiers so cache sizes are readable rather than raw byte counts. Closes #161 Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
Closes #161 Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
The startup update check can provision a managed Python, so asking what is using disk space downloaded more of it. Exclude storage alongside the other self-referential commands. Refs #161 Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
volen-silo
left a comment
There was a problem hiding this comment.
Reviewed this one harder than usual given it permanently deletes multi-gigabyte trees. The safety machinery is genuinely well built — I tried to find a way to make it delete a runtime it promised to keep and could not. Selection is correctly split into a pure policy plus filesystem guards, the confirmation gate holds, and the ownership check is strong.
Two blocking issues though, and they pull in opposite directions: remove-old-installs currently deletes nothing on any real machine, and remove-downloads can delete something it shouldn't.
Blocking
1. remove-old-installs is inert on every real installation — it doesn't fix #161
apps/rocm/src/storage.rs:222 holds an install when manifest.runtime_id matches config.default_runtime_id. But runtime_id carries no version and no format — apps/rocm/src/therock.rs:942 and :1067:
runtime_id: format!("therock-{}:{}", channel.as_str(), resolution.family),So every install of a given channel+family shares one runtime_id. And activate_runtime sets it unconditionally (apps/rocm/src/main.rs:5810):
config.default_runtime_id = Some(manifest.runtime_id.clone());Installing or updating activates, so default_runtime_id is set on every real machine — and the Default hold then matches every install in the family. --keep becomes a no-op, including --keep 0. Against a fixture with six accumulated installs and a config.json written exactly as activate_runtime writes it:
$ rocm storage remove-old-installs --dry-run
Nothing would be removed.
Left alone:
- release-wheel-gfx110x-all-7-10-0: the configured default
- release-wheel-gfx110x-all-7-11-0: the configured default
- release-wheel-gfx110x-all-7-12-0: the configured default
- release-wheel-gfx110x-all-7-13-0: the configured default
- release-wheel-gfx110x-all-7-14-0: the rollback target for `rocm runtimes rollback`
- release-wheel-gfx110x-all-7-15-0: in use right now
Deleting only default_runtime_id from that same fixture and re-running removes 2 installs.
The reason this isn't caught: the "Verified behaviour" fixture in the PR description omits default_runtime_id, a state no real installation is ever in. And the single test that sets it (storage.rs:962) uses "therock-release:gfx120X-all" — a different family from the manifests under test — so the Default hold never fires in the suite. That's how 8 green tests and 21/21 green CI coexist with a feature that does nothing. This is precisely the rocm update --apply accumulation scenario the PR's own Root cause section describes.
Suggested fix: scope the Default hold to the install the default actually resolves to, reusing current_runtime_manifest / default_runtime_id_matches (main.rs:6844-6874), which already handles the "matches exactly one" case — or drop the Default hold entirely, since HoldReason::Active already covers the resolved install. Please add a regression test whose RetentionInputs carry default_runtime_id and active_runtime_key for the same family.
2. remove-downloads deletes through a symlinked cache root
apps/rocm/src/storage.rs:684 uses Path::exists, which follows symlinks, and :691 calls read_dir on the walk root, which also follows. Child entries are handled correctly — :698-704 uses symlink_metadata and refuses to descend into symlinked directories — but the two roots are never symlink_metadata'd. The entries collected are real files reached through the link, so remove_path deletes them for real.
Reproduced end to end with the victim directory entirely under /tmp:
$ ln -s /tmp/victim <cache_dir>/therock
$ rocm storage remove-downloads --yes
2 downloaded file(s) removed
# both files gone from /tmp/victim
The trigger is relocating the ROCm cache to a bigger disk with a symlink — a normal thing for exactly the user who is running a disk-space command. Dry-run does not protect them: it prints the paths through the link, so they look like ordinary cache paths and nothing appears wrong.
Fix: symlink_metadata each root and skip with a "Left alone:" reason if it's a symlink, or canonicalize and require containment under paths.cache_dir.
Worth fixing before merge
3. storage is missing from two more verb allowlists. chat_rocm_command_action_from_args (main.rs:9077-9226) has no "storage" arm, so the catch-all at :9224 bails; ensure_rocm_command_is_read_only (apps/rocmd/src/lib.rs:2366-2395) likewise. So an assistant tool call for storage report is rejected even though it's purely read-only. Fail-closed, so not a security hole — but this PR is itself the evidence for the footgun it flags: there are four parallel hand-maintained verb lists and it updated two.
4. measure_path reports a symlinked directory as ~17 bytes, marked complete. storage.rs:60-72: symlink_metadata on the walk root → is_dir() false → returns {bytes: <link length>, complete: true}. Meanwhile PathUsage::measure (:124) and build_report (:380) gate on path.exists(), which does follow — so the two halves disagree. A 2.9 MiB HF cache reports as 17 bytes with no "(part unreadable)" caveat. This hits exactly the shared caches the feature exists to surface, and feeds the "freeing about X" estimate via :610. Use fs::metadata for the root specifically; the descent at :87-94 is correct as written.
5. "removed X (N GiB)" prints even when the folder wasn't removed. storage.rs:801-810 discards RuntimeUninstallResult::removed_install_root. When uninstall_runtime's own re-evaluation returns Ok(false) the install root stays, but the registry entry is deleted unconditionally (main.rs:5902-5910) and the call still returns Ok. The user is told N GiB was freed; the tree remains, is now invisible to rocm storage report, and is un-prunable forever because its registry record is gone. The sibling command already handles this — RuntimesCommand::Uninstall at main.rs:5521-5529 prints folder_removed: no.
6. A corrupt config.json silently disables all four force-keeps. storage.rs:780 uses RocmCliConfig::load(&paths).unwrap_or_default(). load returns Ok(default()) only when the file is absent; an unparseable file returns Err, which unwrap_or_default() collapses into "nothing active, nothing previous, no default" — with no warning. ConfigCommand uses ? and surfaces the error (main.rs:7033). This is a fail-open on the only destructive command here, and it becomes the dominant residual risk once #1 is fixed.
7. Neither destructive verb records an audit event. record_cli_audit_event appears ~30× in main.rs including "runtime_uninstall" at :5540, but storage.rs has zero calls — calling crate::uninstall_runtime directly bypasses the dispatch arm that logs. Bulk-deleting several multi-GB installs leaves no trace while deleting one via rocm runtimes uninstall does.
Non-blocking
- The case-insensitivity test only covers one of four force-keeps (
storage.rs:957-963) — onlyactive_runtime_keydiffers in case. Replacingeq_ignore_ascii_casewith==on the Previous, Default and Marker guards leaves all 8 tests passing. - No test covers the deletion path or the safety gates. All 8 stop at plan building; nothing exercises
storage()(:778-831),approved()(:768-776),remove_path, oruninstall_runtime. Both #2 and #4 would have been caught. I verified the gates by hand — but nothing stops them regressing. treat_as_natural_language: I cross-checked all 30 clapCommandvariants plus thecomfy/modelsaliases againstSTRUCTURED(main.rs:16281-16320) — the list is currently complete,storagewas the only casualty. But the guard test atmain.rs:20381is itself a hardcoded 26-entry array, so the next forgotten verb is also forgotten in the test. IteratingCli::command().get_subcommands()+get_visible_aliases()would make the whole class CI-visible, and extended to the other two lists would have caught #3.--keepis unvalidated (main.rs:649-650, novalue_parserrange).--keep 0is accepted and intentional; today #1 makes it harmless, but once #1 is fixed it becomes a "delete everything not currently active" button behind a singley/N.- Retention orders by install time, not version (
storage.rs:277-282), so a deliberate downgrade makes the older version "most recent" and--keep 1deletes the newer one. Defensible, but undocumented in--helpand the README. Same for(channel, format, family)grouping meaning a user who's tried nightly keeps--keep× N. - TOCTOU: holds are computed at
storage.rs:792and never re-checked afterapproved()blocks on stdin. The ownership guard is re-checked insideuninstall_runtime; the holds aren't. Activating a runtime in another terminal while the prompt waits lets it be deleted. Cheap fix: re-rununconditional_holdper entry immediately before deleting. runtime_install_root_is_protected(crates/rocm-core/src/runtime.rs:365-393) is a denylist returning false for$HOMEexactly, and for/home,/mnt,/srv,/media; the Windows list at:375hardcodesC:. Not reachable today thanks to the manifest check, but this is the belt-and-braces guardstorage.rs:581newly leans on.format_bytes_for_user(main.rs:13544) is live viamain.rs:13409and printsKB/MB/GBfor base-1024 math while the newly-liveformat_bytesprintsKiB/MiB/GiB. Confirmedformat_byteshad zero call sites at the merge base, so "it was dead code" is accurate. Minor:{:.1}prints1_048_575as"1024.0 KiB".- Small accuracy note: the PR says the
proc_lifecyclefailures are "fixed by #169", but #169 is still open — reads as landed.
Verified clean
- The confirmation gate holds. Non-interactive without
--yes→ refuses, exit 1, nothing deleted.echo y |→ still refused (interactive_terminal()requires both stdin and stdout to be TTYs).--dry-run --yes→ exit 0, nothing deleted;dry_runshort-circuits at:794/:816beforeapproved()is called, so there's no precedence bug.confirm_uninstall()(main.rs:15040-15049) accepts only exacty/yes. - Reported == executed.
:792 → 793 → 801and:814 → 815 → 823print from and iterate the sameplanbinding — one plan-builder per verb, no separate dry-run computation that could drift. - All five guards in
unconditional_hold(:216-234) are load-bearing — disabling any one makes an existing test fail. Held entries are extracted before grouping and sorting (:263-272), so a tie ininstalled_at_unix_mscan't leak the active install into the removal set. Empty registry, single install, a default naming a nonexistent key, and negative/non-numeric--keepall behave. - The ownership guard is strong.
local_runtime_manifest_matches(main.rs:5991-6003) requires an in-tree.rocm-cli-runtime.jsonwhoseruntime_key,runtime_idandinstall_rootall match, withinstall_rootcompared viapaths_equivalentagainst the directory it was found in — so a copied or moved tree carrying a stale manifest does not look owned. This is what keeps the$HOMEgap above unreachable. - Symlink semantics in the deletion path itself are correct —
remove_path(main.rs:15105-15119) branches onis_symlink(), and both walks refuse to descend into child symlinks. Issues #2 and #4 are specifically about the roots. measure_pathis genuinely iterative (explicit stack,:76) withsaturating_addthroughout — no recursion or overflow risk.- Locally:
cargo fmt --all --checkclean;cargo clippy --locked --workspace --all-targets -- -D warningsclean;cargo test -p rocm --bin rocm storage::8/8;cargo xtask manifest --checkclean.cargo test --workspace --all-targetsshows only the two knownproc_lifecyclefailures. Leak scan clean; license header present; README accurate against the implemented surface.
Not verified: Windows behaviour (NTFS junctions vs. the symlink semantics in #2/#4 — worth confirming #2 specifically in the Windows lane), cargo xtask tpn --check (no cargo-about locally; CI's equivalent is green), and whether uv's hardlink mode makes measure_path double-count blocks across "ROCm installs" and "uv package cache".
All destructive testing used ROCM_CLI_*_DIR pointed at throwaway /tmp fixtures; no real install was touched.
… caches `remove-old-installs` removed nothing on any real machine. `runtime_id` is `therock-<channel>:<family>` with no version in it, so every install of one channel and family shares it, and installing or updating sets it as the default. Holding every install whose id matched the default therefore held the whole family and made `--keep` inert, including `--keep 0`. The default now only holds an install when it names exactly one, the same rule `current_runtime_manifest` already applies; when it is ambiguous the install in use is still held as active. `remove-downloads` could also delete files outside the cache. Both walk roots were reached with `Path::exists` and `read_dir`, which follow symlinks, so a cache relocated to a bigger disk with a link had its target contents collected and deleted for real — and the dry-run showed them as ordinary cache paths, so nothing looked wrong. Both roots are now checked with `symlink_metadata` and reported as left alone. Entries below the root were already handled correctly. Alongside those: - `measure_path` measured a symlinked root as the length of the link, so a multi-gigabyte shared cache reported as a few bytes marked complete, and disagreed with the `Path::exists` gates that do follow. The root now uses `metadata`; the descent is unchanged. - Removal reported "removed X (N GiB)" even when `uninstall_runtime` declined to delete the folder, leaving a tree that is invisible to the report and un-prunable. It now reports what actually happened. - A corrupt config no longer collapses into "nothing held" on the one command here that deletes; it fails loudly instead. - Both destructive verbs record an audit event, as `runtimes uninstall` does. - Holds are re-checked immediately before each deletion, so activating a runtime elsewhere while the prompt waits cannot lose it. - `storage` is added to the assistant read-only and command-action allowlists, so `storage report` is no longer rejected as unsupported. - `format_bytes` steps up instead of printing "1024.0 KiB". Tests cover the family-wide default id with an active key for the same family, case-insensitivity on all four force-keeps, both symlinked-root cases, the deletion path end to end, and the confirmation gate. Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
|
Thanks — this was an unusually useful review. Both blocking issues were real; I reproduced each one before touching anything, and confirmed the same reproduction behaves differently afterwards. All of it is in e941f83. 1. Reproduced first, with your fixture: six accumulated installs of one family and a The Default hold now only fires when Same fixture after the fix removes 2 installs, holding 7-14-0 as the rollback target and 7-15-0 as in use. Verified for real, not just Regression test is 2. Reproduced end to end under Both walk roots are now 3. 4. 5. "removed X (N GiB)" when the folder was not removed — fixed. 6. Corrupt 7. No audit event — fixed. Both destructive verbs now call Non-blocking:
PR description corrected on both counts: the "Verified behaviour" fixture now includes Verification. Still not verified by me: Windows behaviour for the two symlink fixes — the Windows lane is green, but it does not exercise a junction pointing at the cache root specifically, so your note stands; and whether CI: everything green except Leaving all of this for you to confirm rather than marking it resolved. |
|
Re-reviewed at e941f83. All seven blocking items are genuinely fixed. I checked each against the code rather than the description, and re-ran the verification locally rather than taking it on trust. Confirmed fixed
Verification I reproduced
Three small things, none blocking1. The The link is a few bytes and the 8 KiB behind it stays; 2. The plan and the run can disagree now that It fails safe and says so out loud, and it needs a config state 3. While you're here
On Nothing blocking left from my side. My CHANGES_REQUESTED is stale — happy to clear it. |
Summary
Adds
rocm storage: see what ROCm CLI is using on disk, and reclaim what it no longer needs.Root cause
Every SDK install that resolves a new version creates a fresh multi-gigabyte tree and nothing ever removes the old ones. Both the wheel and tarball paths always fold the version into the runtime key, so accumulation is unbounded, and
rocm update --applygoes through the same path — simply keeping up to date is how users hit this. The only pruning that existed anywhere was for log files and dash demo sessions. There was also no way to see what was consuming space.Design
Mirrors
RuntimesCommand/ServicesCommand, including theOption<Subcommand>shape so the bare verb is the read-only report. Named in plain English per the UX guidelines —gcandpruneare jargon, and the leaf verbs say "installs" rather than "runtimes".remove-old-runtimesandremove-downloaded-filesexist as aliases.Removal delegates to the existing
uninstall_runtimeper selected key, so config and marker cleanup stays in one place.remove-downloadsreusesUninstallPlan/remove_path, and both mutating verbs keep theinteractive_terminal()+confirm_uninstall()gate.Selection is split in two so the policy is testable without a filesystem:
select_runtimes_to_remove— pure. Groups by channel/format/family, orders by install time, and force-keeps the active install, the previous one (rollback depends on it), the configured default, and anything named by the active-install marker. Refusesread_onlyandimported_from.build_prune_plan— adds the filesystem guards:runtime_install_root_is_protectedfirst, thenshould_remove_runtime_install_root, which enforces the in-tree manifest check so we only ever delete trees we wrote.Every skip is printed under "Left alone:" with its reason.
Sizing is a non-following iterative walk: an unreadable subtree renders as "N or more (part unreadable)" and a missing root as "unknown size" — a report never fails because one path could not be read.
Shared caches (
uv, Hugging Face) are reported and labelled, never deleted. Model weights get no delete verb at all in this pass.Verified behaviour
Against a fixture of six accumulated installs of one family, with
config.jsonwritten exactly as
activate_runtimewrites it — that is,active_runtime_key,previous_runtime_keyand the family-widedefault_runtime_idall set,which is the state every real machine is in:
Re-running without
--dry-run --yesremoves exactly those two folders and theirregistry entries; the other four are untouched.
An earlier revision of this PR omitted
default_runtime_idfrom this fixture. Thatmattered:
runtime_idcarries no version, so the default matched the whole familyand held all of it, and the command removed nothing on any real machine. Fixed, with
a regression test carrying
default_runtime_idandactive_runtime_keyfor the samefamily.
A cache root that is a symlink is reported as left alone rather than followed;
deleting through a relocated cache is verified not to happen.
Without
--yesoutside a terminal both mutating verbs refuse and exit 1,removing nothing.
Two changes outside the new module
treat_as_natural_languagegates clap on a hard-coded verb list, sorocm storage --helpwas silently swallowed by the freeform planner untilstoragewas registered. This is a footgun for any future top-level verb.storageis now excluded alongsideupdate/bootstrap/completions.format_bytesalso gained KiB/MiB tiers — it was dead code with only a GiB tier, so a 39 MB cache printed as39496209 bytes. Note the repo has a near-duplicateformat_bytes_for_user; worth collapsing separately.For maintainer decision
--keep 2default (current plus one rollback target) is my judgement from the rollback feature, not a tuned value.rocm update --applyoffer to prune what it superseded? Update is the accumulation engine. I deliberately did not touch that path.remove-downloadsscope currently includes the signed metadata cache undercache_dir/therock. If that should survive, it is a one-line filter.data_dir/enginesare also large and still have no removal path — arguably a follow-up.Tests
Retention grouping per family; all four force-keeps, each checked for case-insensitive matching; adopted/imported/read-only never selected; prune refusing an install whose in-tree manifest is missing; report rendering; tolerant sizing of a missing folder; and the downloads plan never touching model files.
Added in review: a family-wide
default_runtime_idalongside an active key for the same family (the real-machine state, and the regression test for the inert---keepbug); an unambiguous default still holding its install; both symlinked-root cases (downloads plan and sizing); the deletion path end to end; and the confirmation gate.storageclassification is covered in both assistant verb allowlists.cargo test --workspace --all-targetsshows two failures inproc_lifecyclethat are unrelated to this PR — they reproduce on an unmodifiedmainon the same host. They are tracked in #168, with #169 open as the proposed fix; neither has landed. Everything else passes;cargo fmt --all --check, bothclippy -D warningsinvocations, andcargo xtask manifest --checkare clean.Non-goals
Three things surfaced in review are deliberately left out of this PR and tracked separately:
storagein all of them, but does not restructure them.runtime_install_root_is_protectedis a denylist with real gaps. Unreachable today behind the in-tree manifest check, but worth inverting.format_bytes_for_userprintsKB/MB/GBfor base-1024 arithmetic. It reaches unrelated call sites, so changing its output belongs in its own change.Not verified: Windows behaviour for the two symlink fixes (NTFS junction semantics differ; worth a look in the Windows lane), and whether
uv's hardlink mode makes the report double-count blocks between ROCm installs and the uv cache.Fixes #161