Repository navigation
Move Lite's data out of the installer's install root (#1832) - #1842
Conversation
Lite's data root was %LOCALAPPDATA%\PerformanceMonitorLite, which is also Velopack's install root. Re-running Setup.exe over an existing install renames that folder aside and deletes it, so an installer-based upgrade destroyed the DuckDB store, the Parquet archive, the logs, and settings.json before the new build ever ran. In-app updates were always safe (they replace current\ in place), which is why the loss looked random. Data now lives in the sibling %LOCALAPPDATA%\PerformanceMonitorLite-Data and DataRootMigration moves it there on first start, before anything reads settings or opens the store. It is an explicit allow-list, not "move the folder contents": Velopack's own Update.exe, current\ and packages\ share the old folder and must stay. Nothing in the new root is overwritten and nothing is deleted; a DATA-MOVED.txt signpost is left behind. Second defect, same failure: App.LoadAlertSettings read the four webhook secrets from Credential Manager at the tail of its settings.json parse, behind the "no settings.json" early exit. With settings.json gone the Settings window's webhook boxes rendered empty while the credentials still existed, and Save wrote the blanks back through SaveWebhookUrl, which deletes on blank. The reads now run first and unconditionally. Also: one tray balloon per launch when an update is found, pointing at Help > About (the safe in-place route); the title-bar suffix was the only signal. README, docs, and the bug-report template updated to the new path, with an explicit warning that upgrading a pre-fix install via Setup.exe still loses data one final time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| foreach (var (name, isDirectory) in pending) | ||
| { | ||
| var source = Path.Combine(legacyRoot, name); | ||
| var target = Path.Combine(newRoot, name); | ||
|
|
||
| if (isDirectory ? Directory.Exists(target) : File.Exists(target)) | ||
| { | ||
| kept.Add(name); | ||
| continue; | ||
| } |
There was a problem hiding this comment.
Correctness: a single failed per-artifact move permanently strands data in the directory the next Setup.exe run deletes — the exact failure class this PR exists to prevent.
The "already there → keep, don't overwrite" check here (and the HasCompleteStore short-circuit above at lines 132-141) can't distinguish "this artifact was already fully migrated" from "something else created an empty/partial artifact at the target path after a previous move attempt failed." Once anything exists at target, this loop marks it kept and never retries the source, forever.
Concretely, with monitor.duckdb: if Directory.Move/File.Move for monitor.duckdb fails once (the PR description's own motivating case — "a locked 125 MB file, a killed process"), App.DatabasePath still points at the new root, and DuckDB auto-creates a fresh empty database file there the moment the app opens it this session. On the next launch, HasCompleteStore(newRoot) now finds monitor.duckdb present (the fresh empty one) — so if config/settings.json also happens to be there, the whole function short-circuits to AlreadyMigrated and never looks at the legacy root again. The real multi-GB store with all monitoring history sits untouched in the legacy install directory forever, and the next Setup.exe upgrade deletes it — full data loss from a single transient lock, silently.
A narrower but more likely trigger: if config specifically fails to move, App.xaml.cs unconditionally runs Directory.CreateDirectory(ConfigDirectory) + ConfigSeeder.SeedMissing(...) right after Migrate() returns (lines ~390-399), regardless of migration.Failed. That creates a config folder at the new root this same session, seeded only with the two default files ConfigSeeder knows about (not settings.json). Every future launch then sees config already existing at target and treats it as kept — the user's real settings.json (alert thresholds, SMTP config, etc.) never migrates and is deleted by the next installer run, while the app silently runs on defaults from the seeded stand-in.
None of the DataRootMigrationTests cover "one artifact's move fails while others succeed, then something recreates an empty version of it at the target before the next Migrate() call" — worth a test, and probably worth having Migrate treat a Failed item from a prior attempt as needing a real overwrite-if-target-is-just-the-seeded-default check, or at minimum have HasCompleteStore/the per-item kept check consult something more durable (e.g. the marker file, or a size/content check) than bare existence.
Review: #1842 — Move Lite's data out of the installer's install rootScope check: This PR is correctly Lite-only. Darling has no analogous exposure — its Windows service stores data under Correctness — one significant issue (inline comment on
|
Both are mine, both from the #1842 rescue path added earlier in this branch. The "moved it to the quarantine" log fired unconditionally after the rescue loop, so a run where EVERY rescue failed still claimed the move - naming an empty list and pointing at an empty folder. This log is the only channel that early, and an operator who reads it and finds nothing there stops looking, with their data still in the folder Setup.exe deletes. Each arm is now gated on its own list, and the all-failed case says so plainly. The DATA-MOVED.txt marker listed only `moved`, so a mixed run - store migrates, config collides and is quarantined - left a permanent signpost naming the store and omitting config entirely. Someone reads that file precisely because they are hunting one artifact by name, so the omission reads as proof it was never touched. Rescued names now appear under their own heading with the quarantine path, and the already-populated branch (where nothing goes live) writes them there rather than mislabeling them as moved. Red-watched: restoring the unconditional log and the moved-only marker turns exactly the two new tests red. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Lite's data root was
%LOCALAPPDATA%\PerformanceMonitorLite— which is also Velopack's install root (vpk pack -u PerformanceMonitorLite, no--installto). Re-runningSetup.exeover an existing install renames that folder aside and deletes it, so an installer-based upgrade destroyed the DuckDB store, the Parquet archive, the logs, andsettings.jsonbefore any code from the new build ran. In-app updates were always safe — Velopack replaces thecurrent\subfolder in place — which is why the loss looked random rather than reproducible.1. New data root.
%LOCALAPPDATA%\PerformanceMonitorLite-Data, a literal sibling. Local, not roaming: the DuckDB store must not be dragged across a roaming profile. Every path App derived from the old root moves with it —config\,archive\,logs\,monitor.duckdb(+.wal, + DuckDB's.tmpscratch dir), andalert_state.json. Those are the only paths anything derives fromApp.DataDirectory;Environment.SpecialFolder.LocalApplicationDataappears exactly twice inLite/, both inApp.xaml.cs.2. Startup migration (
Lite/Services/DataRootMigration.cs), running before anything reads settings or opens the store. Same-volumeDirectory.Move/File.Move, so a multi-GB store transfers instantly. Nothing already in the new root is overwritten, nothing anywhere is deleted, and aDATA-MOVED.txtsignpost is left in the old root.3. Settings-save tripwire (second defect, same failure).
App.LoadAlertSettingsread the four webhook secrets out of Credential Manager at the tail of itssettings.jsonparse — behindif (!File.Exists(path)) return;. Withsettings.jsongone (exactly what the wipe produced) the load returned before those reads, so the Settings window's webhook boxes rendered empty while the credentials were still in the store; Save then wrote the blanks back throughSaveWebhookUrl, which deletes on blank. The secrets that survived the data loss were destroyed by opening the settings window afterwards. The reads now run first and unconditionally. Save is otherwise unchanged — blank still means delete, which is how you clear a webhook on purpose.4. Update discoverability. One tray balloon per launch when an update is found, pointing at Help > About and saying that route updates in place and keeps your data. The title-bar suffix was the only signal, and a title bar nobody reads is how installs both stayed on old builds and got "upgraded" by re-downloading
Setup.exe. Nothing else about the update UX changed.5. Docs.
README.mdnow states the real location and carries an explicit warning that on pre-fix versionsSetup.exedeletes local data (upgrade in-app or via ZIP). Three other stale references fixed:docs/how-collection-works.md(which also named a database file,pm_lite.duckdb, that has not existed for some time) and the bug-report issue template's log path. The CHANGELOG[Unreleased]entry doubles as the public advisory, including the one-time caveat that reaching this version viaSetup.exestill loses data once, because the installer deletes before the migrating code runs.Two deliberate design points
The migration is an explicit allow-list, not "move the folder contents." The old root also holds Velopack's
Update.exe,current\,packages\andvelopack.log— confirmed present in a real install. Moving those would break the installed app and its updater.The gate is per-artifact rather than the specified all-or-nothing pair. The brief said migrate only when the new root lacks both
config\settings.jsonandmonitor.duckdb. That strands data on an interrupted run: ifconfig\moves and the store then fails (a locked 125 MB file, a killed process), the new root hassettings.json, the gate never fires again, and the store sits permanently in the folder the nextSetup.exedeletes — the exact failure this PR exists to fix. So each artifact moves only when the target does not exist, and the "both roots complete" case still short-circuits to a single log line with the old root untouched. Every case in the brief behaves as specified; the interrupted run additionally self-heals. Covered byMigrate_FinishesAnInterruptedRun_WithoutOverwritingWhatAlreadyLanded.Test plan
dotnet build Lite.Tests -t:Rebuild: Build succeeded, 0 Warning(s), 0 Error(s) (repo enforces zero warnings).Lite.Testssuite: 1637 passed, 0 failed.DataRootMigrationTests(temp dirs): no legacy root → no-op and the new root is not even created; legacy root holding only updater files → no-op; populated legacy + absent new → everything ours moves with contents verified, updater files intact, old root not deleted, marker written and naming the new path; both roots complete → nothing moves, no marker, and the skip is logged rather than silent; partial legacy → moves only what exists; interrupted run → the store follows while the already-landed config is kept and reported inKept; self-move guarded.AlertSettingsCredentialLoadTests: all four credential keys are read withsettings.jsonabsent and with it corrupt, landing on the staticsSettingsWindowbinds its boxes to; the settings-present path still parses; the [QUESTION] Connection is broken: need PM restart ? #1506 legacy-plaintext-URL migration still overrides the stored value. The internal overload takes the credential writer as a required parameter specifically so a test cannot silently overwrite the operator's real webhook URL — the tests that must not write pass aFailOnWritethat asserts.Collection: [], key never requested).Migrate_FinishesAnInterruptedRun...red.current\,packages\,Update.exe,velopack.log→ 4 tests red.AppAlertSettingsTestsjoined to a shared collection with the new class: both write the App webhook statics and xUnit runs classes in parallel, so this prevents a flake the new tests would otherwise have introduced.Installer.Tests(DB-touching), per instructions.🤖 Generated with Claude Code