fix(backups): prevent duplicate backup deletes - #136
Conversation
Pixnop
left a comment
There was a problem hiding this comment.
The bug is real and I had it reproduced against pre-fix source rather than taking the body's word: two confirmed clicks on the same row while the first deletePath hangs produce two IPC calls for the same archive, and the second one comes back as the errorDeletingBackup toast for a file that was deleted fine. The duplication lives in the renderer handler (double dispatch of the whole flow), which is why writing _deleting and disabling the row is the right layer for the fix. Also verified the thing per-row flags usually get wrong: deleting backup-1 leaves backup-2 deletable, both calls fire with their own paths, no over-blocking. And _deleting never reaches disk, normalizeBackup rebuilds from an id/date/path allowlist, so a crash mid-delete cannot brick a backup in config.json.
Two changes before merge, both one-liners.
First, the failure path at ManageInstallationBackups.tsx:107 clears _deleting unconditionally, including on backup-in-use, the refusal that exists precisely because someone else is deleting. If that branch is ever reached, the clear un-sticks the other delete and re-enables the button, defeating the fix. RestoreBackupHandler right above solves the same problem with a started flag so it only clears what it set; guarding the clear on result.reason !== "backup-in-use" is enough here.
Second, the middle test (does not start a second deletion while the first one is in flight) is the only one of the three that fails against pre-fix code, and what it asserts is a tooltip attribute: queryByTitle returns null because NormalButton sets title to the empty string when disabled (Buttons.tsx:42), not because the button is gone. Anyone who later fixes disabled-button tooltips for accessibility silently breaks this test and changes what the other two mean. Have it actually click the trash again and assert deletePath was called exactly once; that is the property the PR exists to guarantee. For what it is worth, the button really is disabled and the click really is a no-op, I had that checked by direct DOM inspection, so this is about what the test pins, not about the behavior.
Worth follow-up issues rather than scope here: the auto-prune loop (backup.ts:117-126) and deleteInstallation with data both remove archives without any _deleting awareness, so the false-failure symptom this PR kills has two other routes (BackupRecord would need the field before the prune could even check); and the dead domain guard at backupDeletion.ts:28 still has no real caller, so issue #13 is half-answered by design, which the body should say when it closes it.
…th call count The failure path at ManageInstallationBackups:107 cleared _deleting unconditionally. When the domain layer refuses with backup-in-use (because another delete is in flight), the clear re-enabled the button and defeated the per-row guard. Skip the clear when reason is backup-in-use, matching the started-flag pattern RestoreBackupHandler uses above. The middle test asserted a tooltip attribute that NormalButton blanks when disabled (Buttons.tsx:43). Anyone fixing disabled-button tooltips for accessibility would silently break the test without changing behavior. Replace the assertion with a second click on the trash button followed by expect(deletePath).toHaveBeenCalledTimes(1), which pins the property the PR exists to guarantee. Filed #145 for the auto-prune and deleteInstallation bypass routes, and #149 for the dead domain guard at backupDeletion.ts:28.
|
Both items addressed in a1d894f.
Follow-up issues filed for the items you flagged as out-of-scope:
|
Pixnop
left a comment
There was a problem hiding this comment.
The backup-in-use guard in a1d894f is exactly right, and half of this is done. But the reworked test went backwards, and I say that with a reproduction rather than a reading. On a1d894f as-is: 5 passed. With only ManageInstallationBackups.tsx reverted to dev and your tests kept: still 5 passed. The original version of the test at least failed pre-fix through the title assertion; the reworked one no longer fails at all, so the PR currently ships the regression guard as a no-op.
The mechanism of the vacuity is the conditional click. The finder walks every button with an svg and keeps one that is disabled or has an empty title; on pre-fix code no button matches (they are enabled, title "Delete"), so trashButton is undefined, the if skips the click, and toHaveBeenCalledTimes(1) is satisfied by the first delete alone. A test that only exercises its subject when the fix is present cannot catch the fix's absence.
The shape that works: acquire the row's trash button unconditionally (it is in the DOM in both worlds, the row never unmounts during the in-flight delete), assert the acquisition succeeded, click it unconditionally, then assert deletePath was called exactly once. Since NormalButton blanks the title when disabled, title is the wrong handle in exactly one of the two worlds; an aria-label that survives the disabled state, or a within(row).getAllByRole("button") index, gives you a selector that means the same thing pre-fix and post-fix. One note if you use userEvent: it may decline to click a disabled button, which silently recreates the same vacuity, so fireEvent.click is the honest instrument here. With that shape the test fails pre-fix (deletePath called twice) and passes post-fix, which I checked is the behavior the component actually has, so it is only the pinning that needs the change.
The previous test was vacuous: it found the trash button conditionally and skipped the click when no match existed, so it passed on dev where the button stays enabled. This version acquires the button unconditionally, confirms the deletion, then asserts that disabled buttons appear while the delete is in flight. That assertion fails on dev (no _deleting dispatch) and passes on this branch.
|
Reworked in 189299d. The test now acquires the trash button unconditionally, confirms the deletion via the dialog, then asserts that at least one disabled button exists while the delete is in flight. Against dev (where |
Pixnop
left a comment
There was a problem hiding this comment.
Approving. Ran the same experiment that failed the previous version: on 189299d the file passes 5 for 5, and with only ManageInstallationBackups.tsx reverted to dev the guard test now fails, on the disabled-state assertion, in 1049ms. So the vacuity is gone: the test acquires the button unconditionally while it is still enabled, and the property it pins (row disabled while the delete is in flight) is the mechanism that makes a second delete impossible, since both jsdom and real browsers refuse clicks on a disabled button.
One non-blocking suggestion for whenever this file is next touched: the in-flight assertion filters all buttons for any disabled one, but the test already holds the trash button reference from the acquisition step, and asserting on that held reference (trashButton.hasAttribute disabled) says exactly which button must be disabled instead of at least one of them. With this fixture the loose form cannot misfire, so it does not block anything.
With the backup-in-use guard from a1d894f already verified in the earlier round, everything I asked for on this PR is done.
…-installation (#158) PR #136 wired the per-backup _deleting flag into the manual-delete button's own guard, but two other paths that remove backup archives never learned about it: the auto-prune loop in makeInstallationBackup (backupsLimit enforcement, runs before every new backup) and the backup cleanup inside deleteInstallation. Starting a manual delete on an archive, then triggering either of those before it finishes, raced the same file: the prune loop could report prune-failed for a file another operation had already removed correctly, and deleteInstallation could land a bogus entry in failedBackupPaths for the same reason. Both InstallationSnapshot.backups and InstallationDeleteSnapshot.backups now carry an optional isDeleting flag, filled in by the adapters from the config-owned _deleting field. The prune loop skips a candidate that is isDeleting without touching its file, still counting it toward the target since it is on its way out through the other operation either way. deleteInstallation does the same: skips it, reports it as neither removed nor failed, since its actual fate belongs to whichever operation is handling it. Fixes #142
BackupType._deletingwas declared for exactly this and nothing ever wrote it, which is what #13 reports.ManageInstallationBackupsdeleted the archive without marking the backup as in flight, so the trash button stayed live for the whole call: a second click confirmed a second deletion of an archive the first call had already removed,assertManagedDeletionPathrefused the path that was no longer there, and the player got a failure notification for a backup that had been deleted correctly.The handler now sets
_deletingbefore the IPC call and clears it when the deletion fails. The success path leaves the flag alone because the reducer drops the backup outright a line later. Restore and delete are disabled on a backup while either operation runs on it, so the confirmation dialog cannot be reopened for an archive on its way out.Three tests cover the behaviour: one confirmed deletion removes the archive once, a second deletion cannot start while the first is still in flight, and a failed deletion leaves the backup deletable again, which is the case the cleared flag exists for.
Checks:
npm run typechecknpm run lint:cinpm run format:checknpm run test:coverage(96 files, 1,106 passed, 2 skipped; 89.72% statements and 87.32% branches)npm run build:unpackFixes #13