Skip to content

fix(packages): the upgrade marker outlives its snapshot and warns forever - #142

Merged
dipto0321 merged 1 commit into
mainfrom
fix/packages/stale-sentinel
Nov 2, 2026
Merged

dipto0321 merged 1 commit into
mainfrom
fix/packages/stale-sentinel

Conversation

@dipto0321

Copy link
Copy Markdown
Owner

Summary

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.

$ nodeup packages cleanup
⚠ Detected an interrupted upgrade (snapshot: .../snapshots/fnm-26.10.0.json, started: 2026-10-22T10:28:49+06:00).
⚠ To resume: `nodeup packages restore --from .../snapshots/fnm-26.10.0.json`
• No snapshot files found.

$ nodeup packages restore --from .../snapshots/fnm-26.10.0.json
Error: restore failed: read snapshot ...: no such file or directory

The marker lived at <DataDir>/upgrade-in-progress.json — outside
the snapshots directory — so nodeup packages cleanup never touched
it and the user had no way to dismiss it.

Root cause

Two gaps that combine into an unresolvable state:

  1. The marker outlives its snapshot. The marker is deliberately
    left behind when a migration fails (the bug(packages): restore aborts on first failed install; migration report never written #46 contract — the user
    must be able to replay it). But the snapshot it points at doesn't
    necessarily survive: v1.2.2's auto-reclaim (fix(packages): stop the snapshot leak — auto-reclaim + nodeup packages cleanup #130) deletes it once
    the migration has replayed the packages, and nodeup packages cleanup lets the user delete any snapshot by hand.

  2. The warning never checked whether its own advice was
    actionable.
    It printed the resume hint unconditionally, so it
    could never notice that the file it pointed at was gone.

The fix

  • The warning validates its own advice. Snapshot still present →
    genuine interrupted upgrade, warning and hint unchanged, marker 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's 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
    global npm packages.

Test hygiene 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, after this change, could
delete it. Running the suite silently wiped the marker on the
reporter's 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.
(A textual check would have been satisfied by the word
redirectSentinelDataDir appearing in a comment, so it parses the AST.)

Linked issues

Closes #141
Refs #46
Refs #128

Test plan

  • make ci green (vet + golangci-lint + go test -race ./...)
  • New tests in internal/packages/stale_sentinel_test.go and
    internal/cli/stale_sentinel_test.go cover the stale case, the
    live case, the empty-path case, the directory case, the
    compare-and-delete semantics, and a deterministic race test that
    uses a seam to land the concurrent write exactly between our read
    and our claim.
  • Verified end-to-end against the real reported state: planted
    the stale marker with its missing snapshot, confirmed the warning
    no longer fires and the marker is cleared, then confirmed the
    live case still warns with the resume hint and does not clear.
  • Guard test verified to actually fail when the redirect is
    removed from version_test.go.

…ever

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
@cocogitto-bot

cocogitto-bot Bot commented Nov 2, 2026

Copy link
Copy Markdown

✔️ 963aa31 - Conventional commits check succeeded.

@dipto0321
dipto0321 merged commit 0b7a502 into main Nov 2, 2026
11 checks passed
@dipto0321
dipto0321 deleted the fix/packages/stale-sentinel branch November 2, 2026 08:23
dipto0321 added a commit that referenced this pull request Nov 2, 2026
Cuts the v1.2.4 patch release carrying the stale upgrade-marker fix
(#141, merged in #142).

The npm wrapper's `binaryVersion` moves in lockstep with the package
version: it is the tag the postinstall script downloads from, so a
mismatch would 404 every install.

The CHANGELOG's [Unreleased] section becomes [1.2.4]; the release
checklist's wrapper-verification steps now take the version as a
variable so they don't need editing for every release.

Refs #141

Co-authored-by: dipto0321 <dipto@local>
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.

fix(packages): the interrupted-upgrade sentinel outlives its snapshot and warns forever about a dead file

1 participant