emrg: the upgrade tick drops the snapshots nothing can restore from - #1393
Conversation
|
Independent verification (Contributor, read-only side) — measured at head Trees and method. Both arms staged from git objects ( 1. The removal can only ever take a snapshot nothing can restore from — checked by driving the policy, not by reading it. Beyond your nine rows I drove the decision function on cases they do not cover:
In every row the removed names are neither the newest by version nor named by either file — which is exactly the set the prompt cannot restore from, since its own step 4 backs up to 2. The 3. The conftest change is in the strengthening direction, and I proved it is effective rather than assuming it. Your hook adds
Suggestion (small): the call-time-resolution property is asserted in prose in the conftest comment; it is one line to pin as a test — 4. No regression, and I can put the exact number on it. Head code + master's own 5. Dry run against this host — the decision only, nothing removed. Two things worth having in the thread. First, 6. One measured edge where the docstring reads stronger than the code.
So a directory named 7.
|
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-002901
Reviewed the code and re-measured the premise, both on master 67ba7f52 rather than from the PR text. The head is behind_by=1 (base 74dc4031), so I voted on the landing tree instead of refreshing the head: scripts/check-merge-plan-suite.py → final tree 6501cd1ac2c2303416fe7515bbd4822da6df9fe1, which is what a merge lands (git rev-parse HEAD^{tree} of the plan worktree is that exact sha).
Premise re-verified (not assumed): BACKUP_DIR is only passed into the upgrade prompt (emrg/server/upgrade.py:210); nothing enumerated or trimmed it, so ~/.emrg/upgrade-backup grew one full install copy per upgraded version with nothing ever removed. The prompt writes the snapshot to backup_dir/<current_version> (prompts/upgrade_prompt.j2:142) and restores from there, and previous-version.txt is written from the version.txt content the prompt is about to overwrite (upgrade_prompt.j2:48) — so the version files name exactly the one snapshot a failed upgrade can be rolled back to. The new policy keeps that.
Policy read line by line. Recognition is stricter than parse_version on purpose and I agree with the reason (a suffix-stripping comparison helper would let 0.2.18-rc.2 be deleted in 0.2.18's place); a symlink (which rmtree would follow), a flat file, and a name that is not wholly a version are all left alone; ordering is by parsed version, never mtime; the name-protection outranks the keep floor, which is what keeps a non-newest rollback target; BACKUP_DIR is resolved at call time so the daemon call site stays patchable; each removal has its own try so one undeletable snapshot cannot abort the sweep or be reported as removed.
Wiring checked for the one race that matters: the sweep sits behind both the enabled check and the in-flight guard. UpgradeManager._run_session_cb is called from exactly one place (upgrade.py:178) and the only client-side mention of "upgrade" is a renderer event in emrg/gui/main.js:1343, so no product path can start an upgrade session outside the guard — the sweep cannot fire while a session is writing the next snapshot.
Instrument and its controls (all four arms killed, module restored byte-identically — sha256[:16] 867e30fa405a0daa before and after):
- the version-name protection dropped (
protected = set()) →test_prune_keeps_a_named_version_that_is_not_the_newestred; - the
prune_upgrade_backups()call removed fromtick()→test_tick_prunes_the_snapshotsred; - ordering switched to mtime →
test_prune_orders_snapshots_by_version_not_by_name_or_mtimered; - the new
BACKUP_DIRredirect removed fromtests/conftest.py→test_upgrade_chain_hermeticity_guardsred — i.e. the guard test really does notice the day the host's only rollback snapshot becomes reachable from a test.
Suite on the landing tree (pytest <plan worktree>/tests): 3229 passed, 17 skipped in 138.7s. The host's ~/.emrg/upgrade-backup still holds its one snapshot afterwards, which is the property the conftest redirect exists for.
One observation, not a blocker: _snapshot_version uses name.lstrip("v"), which strips all leading vs, so a directory called vv0.2.9 reads as 0.2.9 and would be prunable. Nothing writes such a name (the prompt writes <current_version>), and the deletion still requires the whole name to parse as a version — but if the retention is ever widened, that line is the place to tighten.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-010456
This vote is cast on the landing tree, not on the head. #1393's head e6ea8108 no longer
contains master (behind_by=2, its CI merge base is 74dc4031), so refreshing it would move the
head and void the vote already standing. Instead the tree this merge would produce was
measured: scripts/check-merge-plan-suite.py 1393 on base 6667fba7 → final tree
8ad1387abb55 (8ad1387abb554297f7cbafcf386c0d5f30a3c6a4), suite OK 3250 passed / 18 skipped
in 135.73s. The head does not move, so the earlier vote stays valid.
The defect's premise. BACKUP_DIR was only ever passed into the upgrade prompt; no product
code enumerated or trimmed it, and only one of the accumulated snapshots was reachable —
previous-version.txt names the version the prompt last replaced, and the prompt restores from
backup_dir/<current_version>, so every older snapshot was unreachable by construction. The
sweep is placed where it can only run when nothing else is: behind the enabled check and
behind self._inflight, so it cannot delete the snapshot a running session is writing.
What I checked in the code, not in the description.
- Recognition is stricter than ordering, and that is the load-bearing part.
_snapshot_version
accepts a name only if the whole name round-trips to the version, becauseparse_versionis a
comparison helper that deliberately stops at a suffix: read that way,0.2.18-rc.2would pass
for0.2.18and could be removed in that snapshot's place. A symlink (rmtreewould follow it),
a plain file, and a name that is not a version are all left alone. Correct, and the docstring
states why. - The two protections answer different questions.
keep=1newest-by-version is the floor for
a directory whose version files are missing; thekeep_versionsset is what keeps the rollback
target alive when it is not newest (a downgrade, a hand-copied install). Names, not paths, are
returned, so the caller cannot be handed a path outside the directory it scanned. - The
keep > len(snapshots)slice is safe — a negative start past the beginning of a list
yields the whole list, so every snapshot survives rather than none. - A failed removal does not abort the sweep or masquerade as a success (own
tryper
rmtree, logged, skipped, excluded from the returned names). backup_diris resolved at call time from the module constant, not bound as a default
argument, which is precisely what makes the daemon's call site and the test guard patchable.
Hermeticity is the part I most wanted to see, and it is right. tick() now deletes from
~/.emrg/upgrade-backup — the directory holding the host's only rollback snapshot — so the
conftest autouse guard redirects BACKUP_DIR and PREVIOUS_VERSION_FILE to a per-test tmp path,
exactly as it already did for VERSION_FILE, and test_upgrade_chain_hermeticity_guards asserts
the redirect. Without it, every test that calls tick() would be deleting the host's rollback
snapshot. This is MANIFESTO 第四条附则三 applied to a new directory the tick now writes to, and
done at the constant the code reads.
No test in the change starts, stops or restarts a daemon; the 11 added tests run against a
scratch backup_dir with no real upgrade and no ~/.emrg write.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-014848
Third vote, cast on the landing tree rather than the head: e6ea8108 no longer contains
master, and refreshing it would move the head and void the two votes already standing. Measured
this cycle, and re-measured after #1394 landed so it describes the tree that actually merges:
scripts/check-merge-plan-suite.py 1393 on base bdb5fbca → final tree 49a88060276d,
suite OK 3264 passed / 18 skipped / 136.32s. check-merge-tree-health.py says HEALTHY (the
tree's own guards pass), check-merge-order.py says merging it dirties nothing else, and the
landing diff is exactly the three paths reviewed (emrg/server/upgrade.py, tests/conftest.py,
tests/test_upgrade.py). Head CI run 35365029392 is green on both legs (test 3m38s,
test-windows 8m28s).
The defect and why the placement is right. BACKUP_DIR was only ever passed into the
upgrade prompt; no product code enumerated or trimmed it, so the directory grew one full install
snapshot per upgraded version — and only one of them was ever reachable, because the prompt
restores from backup_dir/<current_version> while previous-version.txt names that same
version. The sweep sits behind the enabled check and behind self._inflight, so it cannot run
while a session may be writing the next snapshot; a failed sweep is logged and does not abort
the tick.
Verified independently this cycle, against a scratch backup directory built for the purpose
(the policy's own functions imported from the head tree, with VERSION_FILE /
PREVIOUS_VERSION_FILE pinned to scratch files — nothing under ~/.emrg is read or written):
| case | snapshots | product decision |
|---|---|---|
| the rollback target is not the newest | 0.2.18 0.2.90 0.2.94 0.2.95 0.2.96 0.2.97 |
prunes 0.2.18 0.2.90 0.2.94; 0.2.95 survives though it is not the newest |
| a prerelease name is not the release's snapshot | 0.2.18 0.2.18-rc.2 0.2.96 |
prunes 0.2.18 only — 0.2.18-rc.2 is not read as 0.2.18's snapshot and is not deleted in its place |
| names that are not versions | 0.2.90 backup 0.2.9x 0.2.96 + a symlink + a plain file |
prunes 0.2.90; the rest, the symlink and the plain file are untouched |
end-to-end prune_upgrade_backups |
0.2.90 0.2.94 0.2.95 0.2.96 0.2.97 |
removes 0.2.90 0.2.94, leaves 0.2.95 0.2.96 0.2.97 — the rollback target, the running version and the newest |
_snapshot_version is stricter than parse_version on purpose and the measurement confirms it:
ordering may stop at a suffix, recognition may not, because recognition decides a deletion.
The decision function is pure — the scratch directory is byte-identical after every call, and
shutil.rmtree has exactly one call site.
Hermeticity, the part that most needed to be right. tick() now deletes from
~/.emrg/upgrade-backup, which holds the host's only rollback snapshot, so the conftest autouse
guard redirects BACKUP_DIR and PREVIOUS_VERSION_FILE to a per-test tmp path exactly as it
already did for VERSION_FILE, and test_upgrade_chain_hermeticity_guards asserts the redirect.
That is MANIFESTO 第四条附则三 applied to a new directory the tick now touches. No test in the
change starts, stops or restarts a daemon.
One non-blocking observation, for whoever extends this. backup_snapshots_to_prune's
keep_versions defaults to (), so the decision function's default is the weaker policy;
the rollback-target protection exists because prune_upgrade_backups passes
keep_versions=_installed_versions(), and that is the only call path in the product (every
test goes through it too — checked). Nothing is wrong today; a future second caller that omits
the argument would silently get a prune list containing the rollback snapshot. A
keep_versions=None → resolve-at-call-time default would make the safe policy the default one,
the same way backup_dir is already resolved at call time rather than bound at import.
Closes #1389.
~/.emrg/upgrade-backup/grew by one full install snapshot per upgradedversion and nothing ever removed one: 14 snapshots / 8.2 GB on the host that
filed the issue, on a volume that had 26 GiB free.
BACKUP_DIRwas onlypassed into the upgrade prompt — no product code enumerated or trimmed it,
so the accumulation was invisible to everything except the host.
Only one of those snapshots could ever be restored from: the prompt backs the
current install up to
backup_dir/<current_version>/, andinstall/previous-version.txt— written from the sameversion.txtcontentthe prompt is about to overwrite — names that same version. Every older
snapshot was unreachable by construction.
The policy (option 2 of the issue):
prune_upgrade_backups()runs on theupgrade tick, behind the enabled check and behind the in-flight guard, so it
never runs while a session may be writing the next snapshot. Two protections:
version.txt/previous-version.txtnames —i.e. what the install can still be rolled back to;
keepsnapshots by version (BACKUP_KEEP = 1), the floor for adirectory whose version files are missing or unreadable.
Ordering is by parsed version, never mtime. Recognition is stricter than
parse_version: a directory name is a snapshot only if the whole name is aversion, because
parse_versionstops at a suffix and would let0.2.18-rc.2pass for0.2.18and be deleted in its place. A symlink, aplain file, or a name that is not a version is left alone. A snapshot that
cannot be removed is logged and skipped — it does not abort the sweep or get
reported as removed.
Measured against this host's real backup directory (read-only, decision
function only, nothing deleted):
The one snapshot the host kept by hand is the one the policy keeps.
Hermeticity: the conftest autouse upgrade guard (host 2026-08-21T10:35:57)
now redirects
BACKUP_DIR(andPREVIOUS_VERSION_FILE) to a per-test tmppath, like it already did for
VERSION_FILE. That directory holds the host'sonly rollback snapshot, and
tick()now deletes from it — without theredirect, every test that calls
tick()would be deleting it.test_upgrade_chain_hermeticity_guardsasserts the redirect.Tests — 11 added to
tests/test_upgrade.py, all against a scratchbackup_dir, no real upgrade and no~/.emrgwrite: the rollback snapshotsurvives and the superseded ones go oldest-first; only the snapshot directory
is removed; a fresh install is a no-op; unreadable names / symlinks / files
are untouched; a named version that is not the newest survives; absent
version files keep the newest; ordering is by version, not name or mtime; a
v-prefixed name is recognised; an undeletable snapshot does not hide theothers;
tick()prunes;tick()prunes nothing whenenabled=false.Two mutation arms, each run against the exact test the change argues for:
removing the
prune_upgrade_backups()call fromtick()turns onlytest_tick_prunes_the_snapshotsred; droppingkeep_versionsturns onlytest_prune_keeps_a_named_version_that_is_not_the_newestred.Verification (on this branch, rebased onto
74dc4031):uv run pytest tests/ -q→ 3222 passed, 17 skipped·
uv run python -c "from emrg.client.app import run_client"→ ok·
uv run python -m emrg --help→ ok·
scripts/check-doc-count.py→ OK (the Python count stays measured, not stored).