fix(saves): a copy that fails part way leaves the target's save as it was #277
No reviewers
Labels
No labels
area/accessibility
area/audio
area/input
area/install
area/menu
area/renderer
area/saves
area/upscaling
beanstalk: approved @ 1697603
beanstalk: approved @ 2c5a62e
beanstalk: approved @ 4fd82b7
beanstalk: approved @ 64d6d09
beanstalk: approved @ 6f91fae
beanstalk: approved @ 70a78f9
beanstalk: approved @ 8d361dc
beanstalk: approved @ 9d8356e
beanstalk: approved @ abf2227
beanstalk: approved @ d82f202
beanstalk: approved @ e5f1e40
beanstalk: approved @ ee8ab5f
ci: approved
good first issue
gpu/amd
gpu/igpu
gpu/intel
gpu/nvidia
help wanted
kind
bug
kind
chore
kind
docs
kind
feature
kind
performance
kind
question
needs measurements
platform/linux
platform/macos
platform/steam-deck
platform/windows
preview
priority
critical
priority
high
priority
low
regression
status
blocked
status
confirmed
status
duplicate
status
invalid
status
needs-info
status
wontfix
testing wanted
ci: approved
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Longshot!277
Loading…
Reference in a new issue
No description provided.
Delete branch "jarynclouatre/Longshot:fix/save-copy-staged"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Why
A save import or export that fails part way (a full disk, a file that can't be written) left the target's save broken.
copy_savesinscripts/oot_saves.pytook the backup, deleted the target'sslot_*folders and then copied the new save in. When that copy failed, the old slots were already gone and half of the new save was left in their place; the game then saw the partial save, and the only way back was copying the backup in by hand.What changed
scripts/oot_saves.py,copy_saves): the backup is taken as before. The new save is then copied whole into a hidden folder inside the target (.<name>-incoming-*; beside it when the target doesn't exist yet). Only after that copy worked does the newswap_inmove the target's old slots and loose files aside and the new ones in. These are renames on one disk; if one fails, the moves are undone, so the target has either all of its old save or all of the new one. Each move back is tried on its own, so one that fails too (a file in a new slot held open without delete sharing, on Windows) does not stop the others; the old entries that can't go back stay in the kept.<name>-old-*folder, and the error names them, that folder and the backup. Staging inside the target keeps the renames on one disk when the save folder links to another disk (a save moved to an SD card) and works when the folder above it can't be written, as the old code did.--keep-settingsis unchanged:option.savisn't in the copy and stays where it is.remove_tree): after a good swap the old entries' folder is deleted with a handler that clears a read-only bit and tries again (Windows refuses to delete a read-only file, which left the folder in the target, wheresave_problemsthen refuses the save). A folder that still can't be deleted is named after the copy, to delete by hand; the copy itself counts.tests/test_oot_saves.py): import and export, each with a slot copy that writes part of a slot and then fails with ENOSPC, anaccount.savcopy that fails, and the last rename into the target failing. Each time the target must be byte for byte as before with nothing left beside or in it, and the same copy must work afterwards. Another test copies into a save folder that links to a folder on another disk (/dev/shm, when it is another disk), one that links on the same disk, and one whose parent folder is read-only. A forward failure followed by a failing move back (mocked, and on Windows also a real file held open with Python'sopen, which doesn't share delete): every other old entry is back in the target, the oldslot_00is in the kept folder, and the error names it and the backup. A read-only file in an old slot (stat.S_IREAD; real on Windows), and deletes refused once and for good (mockedos.unlink).docs/saves-script.md): one line in "Every copy" for the staged copy.Flags and settings
None.
Measurements
main:
d9648863(2026-11-13)this PR: fix/save-copy-staged @ 5cde8d6e, rebased onto main
514591a3as 2968265d after the runs (then1fce28d5, the commit message reworded;6ea909e4adds the review's two fixes on top, measured below) (main's 11 changed files sinced9648863don't include the save script, its test or its doc; the save tests pass again at 2968265d)Hardware: none used (game-free checks on a Linux x86-64 PC, Python 3.14.4)
Scene: none, the game was not run
Method:
python3 -I -B tests/run_all.py, two runs each, interleaved (PR, main, PR, main); both runs of a side gave the same counts. This PR'stests/test_oot_saves.pyalso run against main'sscripts/oot_saves.py.How to reproduce: the commands above in a checkout of each commit; for the second, copy this PR's
tests/test_oot_saves.pyover main'sd9648863tests/run_all.py(run 1, run 2)test_bake_portability.py,test_menu_i18n.pytest_oot_saves.py6ea909e4:tests/run_all.py6ea909e4:test_oot_saves.py1fce28d5: 2 errors (the move back raises, the held-open one with WinError 5), 3 failures (.<name>-old-*left in the target)6ea909e4: 4 passtest_bake_portability.pyfails here for a missing Python module (zstandard), on main as well.test_menu_i18n.pyfails on main itself: a pt_BR hint (the testing tools' row) is no longer a text ofvk_menu.cpp.Testing
Tested: the game-free suite on a Linux PC, as in Measurements, including a real copy across two disks (
/tmpand/dev/shm) through a linked save folder. On Windows 11 (native, Python 3.14.10):python -I -B tests/test_oot_saves.py -v Copy.test_rollback_that_fails_too Copy.test_rollback_that_fails_too_with_a_file_held_open Copy.test_read_only_file_in_the_old_save Copy.test_old_save_that_cannot_be_deleted, against 1fce28d5's script and this PR's.Not tested: the rest of
test_oot_saves.pyon Windows (its folder discovery is the Linux one,RUN_ALL_SKIP), and a copy into a real game folder on Windows. Every rename stays inside the target folder, so on one volume; a running game is refused before any of it, as before.Needs other testers: None.
Screenshots
Nothing on screen.
Risk and rollback
A copy now needs room for the new save on the target's disk before the old one is moved aside. Before, the old slots were deleted first, so a nearly full disk that held the new save only after that deletion now fails with the target unchanged. If the script is killed in the middle, a hidden
.<name>-incoming-*or.<name>-old-*folder can be left in the save folder; the game reads onlyslot_*and the two.savfiles, and the next copy's backup carries it along. The save checks (save_problems, run on the source ofimport hookshotand by the launcher's Validate) call any other name "not a save file", so an import from a Hookshot folder left that way is refused, naming the folder, until it is deleted. A move back that fails leaves the target partly new on purpose, with the old entries in the kept folder and the error saying where they and the backup are. Reverting the commits puts the old order back.Reviewer notes
Issues
None.
Checklist
For everyone
type(scope): description).main.python -I -B tests/run_all.pyrun and passing (any failure that also fails onmainis named above).third_party/untouched (a new folder with aPROVENANCE.mdinstead).If applicable
mainand passing here.tests/that reads changed source updated in this PR, not weakened.2968265ddf1fce28d5daWIP: fix(saves): a copy that fails part way leaves the target's save as it wasto fix(saves): a copy that fails part way leaves the target's save as it wasRequesting changes at
1fce28d5daafter native Windows Python 3.14.7 checks using authored files only.The primary improvement works: two main/PR pairs of direct copy_saves checks cover normal replacement, keep-settings, a new target, partial-slot ENOSPC, loose-file ENOSPC, a final rename failure and a native Windows target handle without FILE_SHARE_DELETE. The PR passes all 14 scenarios. Main's partial slot/loose-file failures leave the target changed. No real saves, game artifacts, whole filesystem fill or ACL changes were used.
Two further Windows checks expose gaps:
[P1] A rollback error prevents all remaining old entries from being restored (scripts/oot_saves.py:439). After slot_00 is placed, hold its progress.sav with CreateFileW sharing READ|WRITE but not DELETE; then inject a failure placing option.sav, as in the PR's forward-rename failure test. The reverse move of slot_00 raises WinError 5. Execution never reaches the loop restoring moved, so the active target contains only the new slot_00, with the old account/options/slots hidden in .target-old-*. The backup is intact, but the target is neither the whole old save nor the whole new one. A temporary lock should not stop restoration of every independent entry. Make rollback best-effort across all entries, preserve recovery material when it cannot finish, and report the rollback failure/recovery paths explicitly. Add a two-failure regression; the current test covers only a forward failure followed by fully successful rollback.
[P2] Successful cleanup hides a read-only-file failure (scripts/oot_saves.py:444). Mark just old slot_02/progress.sav read-only, then copy normally. Backup and every rename work, but rmtree silently leaves .target-old-/slot_02/progress.sav inside the target while copy_saves prints Done. save_problems(target) then reports '.target-old- is not a save file', so that folder can no longer be imported as a Hookshot save without manual repair. Handle read-only cleanup or report an incomplete cleanup and its location; add a native Windows authored regression. Unlike the documented killed-process case, this occurs on an ordinary successful return.
I did not run the Linux-discovery suite on Windows, where it is declared unsupported. This review exercises the portable production copy function directly. No independent full-suite or power-loss claim is made.
@ -416,0 +436,4 @@placed.append(n)except BaseException:for n in reversed(placed):os.replace(os.path.join(dst, n), os.path.join(incoming, n))[P1] A second failure here aborts rollback before any of
movedis restored. Reproduced natively on Windows: after incoming slot_00 is placed, open its progress.sav without FILE_SHARE_DELETE; fail the later option.sav placement. This reverse rename gets WinError 5, leaving only new slot_00 active while the old account/options/slots remain hidden in aside. The backup is intact, but the target is partial. Attempt the independent restorations even after a rollback move fails, preserve recovery material, and report the incomplete rollback and recovery paths. Please add a forward-failure plus rollback-failure regression.@ -416,0 +441,4 @@os.replace(os.path.join(aside, n), os.path.join(dst, n))os.rmdir(aside)raiseshutil.rmtree(aside, ignore_errors=True)[P2]
ignore_errors=Truemakes an ordinary successful copy leave an invalid save directory on Windows. Reproduced with only old slot_02/progress.sav marked read-only: all renames succeed, but this cleanup leaves.target-old-*/slot_02/progress.savand the script prints Done.save_problems(target)then rejects the hidden directory, blocking a later Hookshot import. Handle read-only-file removal or report cleanup failure and its location rather than silently claiming completion; please add an authored Windows case.Thanks, both reproduced and fixed in
6ea909e4.Rollback: each move back is now tried on its own, so a failed one no longer stops the rest. An old entry that can't go back (its name still taken by the new one that couldn't move out, or its own move failing) stays in the
.<name>-old-*folder, which is then kept, and the script fails with the names, that folder and the backup's path. When everything went back the error is the original one, as before. Regression:test_rollback_that_fails_too(forward failure onoption.sav, then the move back of the newslot_00refused; mocked, runs everywhere) andtest_rollback_that_fails_too_with_a_file_held_open(Windows only: the newslot_00/progress.savopened with Python'sopen, which doesn't share delete, before the forward failure). Both check thataccount.sav,option.savandslot_01are the old ones again, the oldslot_00is in the kept folder, and the error names it and the backup.Cleanup: the old entries' folder is deleted by
remove_tree, whose handler clears the read-only bit and tries again (onexcon 3.12+,onerrorbefore). If something is still left, the script says the copy worked and names the folder to delete by hand instead of a plain Done. Regression:test_read_only_file_in_the_old_save(oldslot_01/progress.savset tostat.S_IREAD; afterwards the target holds only the new save andsave_problemsfinds nothing) andtest_old_save_that_cannot_be_deleted(deletes refused once, then always, through a mockedos.unlink).Native Windows 11 (10.0.26320), python.org Python 3.14.10, those four tests only (the rest of the file uses the Linux discovery): at
1fce28d5, 2 errors (the held-open one raises WinError 5 from the move back) and 3 failures (.<name>-old-*left in the target); at6ea909e4, all 4 pass. Linux:test_oot_saves.py53 pass, 1 skipped (the Windows-only one);tests/run_all.py452 passed, 2 failed (test_bake_portability.py,test_menu_i18n.py, both failing on main), 79 skipped. The description's What changed, Measurements, Testing and Risk are updated.Approved follow-up6ea909e4 after inspecting the recovery/cleanup change and independently exercising the four portable follow-up regression methods on native Windows Python3.14.7.
Two sequential interleaved original-head1fce28d5 / follow-up6ea909e4 pairs, using the exact same new tests against each production module:
This includes a real native open file without delete sharing, not only a mocked rollback error. The tests verify independent old entries are restored, the blocked old slot remains recoverable, the backup is intact and its recovery paths appear in the error. The read-only-file case now removes the stale folder and passes save_problems. A persistent cleanup failure is explicitly reported instead of being silently treated as a clean result.
My two prior blocking findings are resolved. Persistent I/O failure still prevents a wholly restored target, as expected; the new behavior makes recovery best-effort and reports what remains rather than claiming guaranteed atomicity. I did not run the Linux-discovery suite on Windows, perform a power-loss test or repeat the full Longshot suite. All data in this independent check was authored fixture content; no real save or installed player changed.