Repository navigation
fix(packages): stop the snapshot leak — auto-reclaim + nodeup packages cleanup - #130
Merged
Merged
Conversation
GoReleaser applies its own default ldflags only when builds[].ldflags is empty. Passing our ldflags through builds[].flags instead produced two -ldflags arguments on the go build command line, and Go takes the last one — so ours was silently dropped and the shipped binary reported GoReleaser's defaults (a full 40-char commit and the build date) instead of the short commit and commit date we wanted. Move the ldflags into the dedicated builds[].ldflags key, which keeps a single explicit -ldflags argument and preserves the metadata. Also clear the three deprecation warnings: - archives[].format -> archives[].formats (list form, v2.6) - archives[].format_overrides[].format -> .formats - brews: -> homebrew_casks: (v2.10) The archive names are unchanged, so the npm wrapper's archiveName() and the Homebrew/Scoop manifests still match. Closes #129
…nup` Nothing in nodeup ever deleted a snapshot file. The set only grew: one file per (manager, Node version) pair the user had ever installed, plus the orphan left behind by an upgrade that failed partway. The migration reports in <DataDir>/reports/ leaked the same way, with unbounded random-suffixed filenames. Two fixes. 1. Stop the leak automatically. The upgrade flow now records the paths it wrote and deletes them once the snapshots have been consumed — either because the migration replayed them onto the new Node version, or because there was nothing to migrate at all (every target was already installed, so the snapshots were redundant copies of the current state). A snapshot whose contents were NOT replayed survives: on the failure path it is the only record of the user's globals and the sentinel points straight at it. `--keep-snapshots` (or `packages.keep_snapshots` in the config) opts out of the automatic deletion entirely. 2. Give the user a manual lever: `nodeup packages cleanup`. It lists what's on disk with size and mtime, and lets the user pick individual files, several files, or all of them. `--list` shows what would be deleted and deletes nothing; `--reports` targets the migration reports instead; `--all --yes` deletes everything without prompting. The listing is deliberately file-based rather than built on ListSnapshots: that function skips files whose JSON body doesn't parse, so a snapshot truncated by a crash mid-write is invisible to it — and a cleanup command that can't see a file can't delete it. The new listing surfaces corrupt files, plus the ".json.tmp" siblings an interrupted atomic write leaves behind. Deletion is confined to its own directory. The target must resolve *inside* the snapshots (or reports) directory after symlink evaluation and must be a regular file, so a ".." path or a symlink pointing elsewhere is refused rather than followed. Three adjacent bugs fixed along the way: - The sentinel was armed even when there was nothing to install. With no install step and no restore step, restoreSucceeded never became true, so the sentinel was never cleared and every later `nodeup` invocation warned about a phantom "interrupted upgrade". - `nodeup packages snapshot` captured the LOWEST installed version's globals, not the active one — getCurrentVersion returned versions[0], and ListInstalled sorts ascending. On a machine with Node 18 and Node 20 that captured the (usually empty) 18 install under the 20.x filename, producing a snapshot useless as a restore source. It now asks Manager.Current() first and falls back to the highest installed version. - Snapshot writes were not atomic. A plain os.WriteFile truncates the destination first, so a crash mid-write left a truncated JSON file that no reader could parse — invisible to every code path, but still occupying disk. Writes now go through a sibling temp file and a rename. Also adds ui.Prompt.MultiSelect: huh's MultiSelect in FancyMode, and a line-based comma/space-separated number-or-name picker in PlainMode. Both degrade to "nothing selected" on EOF or a blank answer — the safe direction for a destructive prompt. Closes #128
README gains a "Reclaiming snapshot space" section covering `nodeup packages cleanup` and the `--keep-snapshots` opt-out, and the config example gains packages.keep_snapshots. SPEC records the new interface surface (`nodeup packages cleanup`, the --keep-snapshots flag, NODEUP_PACKAGES_KEEP_SNAPSHOTS) and four new invariants: - V22: an upgrade's snapshots are deleted iff the migration replayed them successfully (or the user opted out). A snapshot whose restore failed is never deleted — it is the rollback payload. - V23: snapshot writes are atomic. - V24: file deletion is confined to its own directory, after symlink evaluation. CLAUDE.md's file map gains cleanup.go and cleanup_snapshots.go. Part of #128.
Two lint failures on the new cleanup command: - `delete` shadowed the predeclared builtin (gocritic builtinShadow). Renamed to `removeFile`. - "cancelled" is a misspelling of the US spelling "canceled" (misspell linter). Co-Authored-By: puku-ai-2.8 <noreply@puku.sh>
|
✔️ 75e873c...091fb18 - Conventional commits check succeeded. |
dipto0321
added a commit
that referenced
this pull request
Nov 2, 2026
…ever (#142) The "Detected an interrupted upgrade" warning could fire on every nodeup invocation, pointing at a file that no longer existed and telling the user to run a command that was guaranteed to fail. The marker (<DataDir>/upgrade-in-progress.json) is deliberately left behind when a migration fails, so the user can replay it — that is the #46 contract. But the snapshot it points at does not necessarily survive: v1.2.2's auto-reclaim (#130) deletes it once the migration has replayed the packages, and `nodeup packages cleanup` lets the user delete any snapshot by hand. When that happened the marker outlived its payload, and the marker lives outside the snapshots directory, so `nodeup packages cleanup` never touched it and the user had no way to dismiss it. The warning now validates its own advice: - snapshot still present → genuine interrupted upgrade. Warning and resume hint unchanged; the marker is NOT cleared. - snapshot gone → the marker is stale. nodeup says so and clears it instead of printing a hint that cannot work. Clearing is race-safe. The marker is CLAIMED with an atomic rename before it is inspected, so a concurrent `nodeup upgrade` that arms a fresh marker in the window can never have it deleted out from under it — the fresh marker is put back untouched. Compare-then-delete would have a TOCTOU window there; the claim closes it. `nodeup packages cleanup --stale-sentinel` clears a stale marker on demand. It refuses to clear one whose snapshot still exists: that represents a genuine interrupted migration, and discarding it would lose the only breadcrumb telling the user how to replay their globals. Also fixes a test-hygiene wart this change exposed. A CLI test that built a root command ran against the developer's real $HOME, so it probed the real marker — and could now delete it. Running the suite silently wiped the marker on this machine. The test now redirects the data dir, and a new AST-based guard test fails any future test that constructs a root command without doing so. Closes #141 Co-authored-by: dipto0321 <dipto@local>
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Snapshot JSON files leaked forever.
nodeup upgradewrote one snapshot perinstalled Node version, and nothing in the codebase ever deleted one — the set
only grew, one file per
(manager, node version)pair the user had ever hadinstalled, plus the orphan left behind when an upgrade failed partway.
This PR fixes the leak at the source and gives the user explicit control
over whatever is left on disk.
deletes them in a deferred cleanup — but only once they have been
consumed. A snapshot whose contents were never replayed survives, because on
the failure path it is the only record of the user's globals and the
"interrupted upgrade" warning points straight at it.
nodeup packages cleanup. A new subcommand that lists the snapshot andmigration-report files on disk and lets the user delete them one by one
(multi-select) or all at once.
--listshows what would go without deletinganything,
--reportstargets the report files instead,--yesskips theconfirmation.
--keep-snapshots/packages.keep_snapshots/NODEUP_PACKAGES_KEEP_SNAPSHOTS. An opt-out from the automatic deletion,for users who want the snapshots kept for their own rollback tooling.
Closes #128
Closes #129
Adjacent bugs found during the audit
Three more leaks/bugs surfaced while tracing the snapshot lifecycle. All are
fixed here:
Phantom "interrupted upgrade" sentinel. The sentinel
(
<DataDir>/upgrade-in-progress.json) was armed even when there wasnothing to install.
restoreSucceededthen never became true, so thesentinel was never cleared — and every later
nodeupinvocation warnedabout an interrupted upgrade that never happened. The sentinel is now
armed only when there is actually something to install. (The live machine
had a stale sentinel from 2026-10-22 warning on every run.)
getCurrentVersioncaptured the wrong version. It returnedversions[0]— the lowest installed version, becauseListInstalledsorts ascending. On a machine with Node 18 and Node 20,
nodeup packages snapshotcaptured the (usually empty) 18 install and filed it under the20.x filename, producing a snapshot useless as a restore source. It now
prefers the manager's active version (
Manager.Current) and falls backto the highest installed version for managers that can't report one
(
nvm-windowsreturns "not implemented").Non-atomic snapshot writes. A crash mid-write left a truncated file
that
ListSnapshotssilently skipped — an invisible orphan that couldnever be cleaned up. Writes now go through a
.tmpfile plus a rename.Design notes
<DataDir>/snapshotsdirectly instead of going throughListSnapshots,so corrupt files and leftover
.json.tmpsiblings are visible anddeletable too — those are exactly the orphans most likely to be missed.
DeleteSnapshotFileresolves both the directory and the path through
filepath.Abs+filepath.EvalSymlinks, then uses afilepath.Rel-based containmentcheck. A
strings.HasPrefixcheck would wrongly treat/tmp/abas inside/tmp/a; the same guard is used for the tar-extraction path (issue bug(npm-wrapper): no zip-slip guard, Windows/ARM64 passes preinstall but 404s at download, partial state on failure #65).Symlinks and non-regular files are refused.
internal/ui. Theui.Promptinterface gainedMultiSelect, with a plain-mode implementation (comma/space-separatednumbers or labels, plus an
allkeyword) and ahuh-backed fancy-modeone.
internal/clinever importshuhdirectly, and a user abort in fancymode degrades to "nothing selected" rather than an error.
Test plan
go build ./...go vet ./...gofmt -l .go test ./...internal/packages/cleanup_test.go— listing (valid, corrupt,.tmp,non-JSON), deletion (removes, missing-is-success, refuses paths
outside the dir, refuses traversal, refuses symlinks),
isInsideDir,filename parsing, report-file listing/deletion, atomic write.
internal/cli/cleanup_snapshots_test.go—--listdeletes nothing,--all --yesdeletes everything, interactive selection deletes onlythe picked files, blank selection and declined confirmation are
no-ops,
--reportstargets the other directory, empty-dir case,formatBytes.internal/cli/upgrade_snapshots_test.go— reclaim removes the files itis handed, one undeletable file doesn't stop the rest, the
--keep-snapshotsflag exists and defaults to false,packages cleanupis wired into the command tree, a snapshot whose contentswere not replayed survives, and the snapshots and reports directories
are genuinely distinct.
internal/cli/packages_test.go—getCurrentVersionprefers the activeversion, falls back to the highest installed, errors when there are
none.
internal/ui/prompt_test.go— plain-mode multi-select: by number, bylabel,
all, blank, EOF, presentation ordering, no options, rendering.internal/config/*_test.go—packages.keep_snapshotsthroughSetByKey, defaults, full-file override, partial-file preservation.--listshowed all three filekinds; multi-selecting by number deleted exactly the picked file;
alldeleted the rest.