mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
Hold the game's profile lock while an import replaces the save.
The window asked whether the game runs before the player picked the save, so the game could start during the dialog or during the backup's copy and mount a save that was half replaced. The import now takes profile/instance.lock as the game does: a running game refuses it, and a game started in the middle is turned away by its own lock. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
@@ -346,6 +346,11 @@ zip's save replaces the whole save, every slot, as a save folder does (below): t
|
||||
to `saves/backup <date time>` first. It refuses while the game runs. A zip taken from the saves folder moves to
|
||||
`saves/imported <date time>`.
|
||||
|
||||
"While the game runs" is the game's own lock: the game holds `profile/instance.lock` from its start to its end
|
||||
(`runtime/fs_host.c` `fs_host_lock_profile`), and an import takes the same lock the same way for the time it copies
|
||||
the backup and swaps the save in (`saves.profile_lock`). A running game refuses the import; a game started in the
|
||||
middle of one ends at once with its message that the profile is in use, and starts normally after.
|
||||
|
||||
The game's menu imports at the next start instead of at once: the game keeps its progress in memory and writes it back
|
||||
at its next save, over files replaced under it, and the save's working copy is made when the game mounts its save.
|
||||
Import a save checks the zip as the launcher does and puts its save in `profile/save-import`; the row then reads "at
|
||||
|
||||
@@ -24,6 +24,7 @@ A whole save is account.sav, option.sav and the folders slot_00 to slot_05, each
|
||||
folder with progress.sav at its top is one slot: it replaces that slot (the slot named by its folder, else slot_00) and
|
||||
the rest of the current save stays, as the game reads progress.sav only inside a slot folder.
|
||||
"""
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -220,6 +221,64 @@ def game_running(game):
|
||||
return running(game)
|
||||
|
||||
|
||||
RUNNING = 'the game is running: quit it first, it keeps its save open while it runs'
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def profile_lock(game):
|
||||
"""The game's own lock on its profile, held while the save is replaced. The game takes
|
||||
<game>/profile/instance.lock when it starts and keeps it until it ends (runtime/fs_host.c fs_host_lock_profile):
|
||||
on Windows by opening the file without sharing, elsewhere with flock. Taken here the same way, it says for
|
||||
certain whether the game runs (SaveError when it does), and a game started while it is held ends at once with
|
||||
its "another copy is running" message instead of mounting a save that is half replaced. game_running() alone,
|
||||
asked before the player picked a file, left the whole time of that dialog and of the copy for the game to
|
||||
start in. A lock file that cannot be opened for another reason locks nothing: the import goes on as before."""
|
||||
prof = Path(game) / 'profile'
|
||||
path = prof / 'instance.lock'
|
||||
try:
|
||||
prof.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
if os.name == 'nt':
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
k = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
k.CreateFileW.restype = wintypes.HANDLE
|
||||
k.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p, wintypes.DWORD,
|
||||
wintypes.DWORD, wintypes.HANDLE]
|
||||
k.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
# read and write access, no sharing, made when missing: platform/win32.c plat_lock_file's own call
|
||||
h = k.CreateFileW(str(path), 0xC0000000, 0, None, 4, 0x80, None)
|
||||
if h in (None, wintypes.HANDLE(-1).value):
|
||||
h = None
|
||||
if ctypes.get_last_error() == 32: # ERROR_SHARING_VIOLATION: the game has it open
|
||||
raise SaveError(RUNNING)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if h is not None:
|
||||
k.CloseHandle(h)
|
||||
return
|
||||
f = None
|
||||
try:
|
||||
f = open(path, 'a+b')
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
if f is not None:
|
||||
import fcntl
|
||||
try:
|
||||
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB) # platform/linux.c plat_lock_file's own call
|
||||
except BlockingIOError:
|
||||
raise SaveError(RUNNING) from None
|
||||
except OSError: # a file system without locks
|
||||
pass
|
||||
yield
|
||||
finally:
|
||||
if f is not None:
|
||||
f.close()
|
||||
|
||||
|
||||
def pending_import(game):
|
||||
"""The save the game's menu took from a shared zip and left in <game>/profile/save-import for its next start
|
||||
(runtime/fs_host.c import_pending), or None."""
|
||||
@@ -275,7 +334,13 @@ def _put_away(game, names, stamp):
|
||||
def _replace(game, src, files, slot, stamp, log):
|
||||
"""Copy the files of src (relative paths) into the game's save: the whole save, or with slot only that slot folder
|
||||
and the rest of the current save kept. The save the game would load now goes to saves/backup <stamp> first.
|
||||
Returns (account folder, backup folder or None)."""
|
||||
Returns (account folder, backup folder or None). The game's profile lock is held meanwhile (profile_lock):
|
||||
SaveError when the game runs."""
|
||||
with profile_lock(game):
|
||||
return _replace_held(Path(game), src, files, slot, stamp, log)
|
||||
|
||||
|
||||
def _replace_held(game, src, files, slot, stamp, log):
|
||||
account = account_dir(game)
|
||||
saves = ensure_saves_dir(game)
|
||||
backup = None
|
||||
|
||||
@@ -4254,6 +4254,63 @@ class SavesTest(unittest.TestCase):
|
||||
saves.import_save(self.game, big, log=lambda m: None)
|
||||
self.assertEqual((acct / 'save.dat').read_bytes(), b'1') # nothing changed
|
||||
|
||||
@staticmethod
|
||||
def game_lock(path):
|
||||
"""The profile lock as the game takes it (runtime/platform plat_lock_file): Windows opens the file without
|
||||
sharing, elsewhere flock. Returns a function that lets go of it, or None when it is refused."""
|
||||
if os.name == 'nt':
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
k = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
k.CreateFileW.restype = wintypes.HANDLE
|
||||
k.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p,
|
||||
wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
|
||||
k.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
h = k.CreateFileW(str(path), 0xC0000000, 0, None, 4, 0x80, None)
|
||||
if h in (None, wintypes.HANDLE(-1).value):
|
||||
return None
|
||||
return lambda: k.CloseHandle(h)
|
||||
import fcntl
|
||||
f = open(path, 'a+b')
|
||||
try:
|
||||
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
f.close()
|
||||
return None
|
||||
return f.close
|
||||
|
||||
def test_no_import_while_the_game_holds_its_profile(self):
|
||||
"""The import takes the game's own profile lock for the time it replaces the save: a running game refuses
|
||||
it with nothing changed, and a game that starts in the middle of an import is turned away by the lock
|
||||
instead of mounting a save that is half replaced."""
|
||||
acct = saves.account_dir(self.game)
|
||||
self.files(acct, {'save.dat': b'old'})
|
||||
src = self.files(self.tmp / 'theirs', {'save.dat': b'new'})
|
||||
lock = self.game / 'profile' / 'instance.lock'
|
||||
let_go = self.game_lock(lock) # the game runs
|
||||
self.assertIsNotNone(let_go)
|
||||
with self.assertRaises(saves.SaveError) as refused:
|
||||
saves.import_save(self.game, src, log=lambda m: None)
|
||||
self.assertIn('the game is running', str(refused.exception))
|
||||
self.assertEqual((acct / 'save.dat').read_bytes(), b'old')
|
||||
self.assertFalse(saves.saves_dir(self.game).exists()) # no backup was made, nothing staged
|
||||
self.assertFalse((self.game / 'profile' / 'save-tx' / 'import').exists())
|
||||
let_go() # the game has ended
|
||||
started = []
|
||||
real_copytree = shutil.copytree
|
||||
|
||||
def copytree(a, b, *args, **kw): # the game is started during the backup's copy
|
||||
started.append(self.game_lock(lock))
|
||||
return real_copytree(a, b, *args, **kw)
|
||||
with mock.patch.object(shutil, 'copytree', copytree):
|
||||
r = saves.import_save(self.game, src, log=lambda m: None)
|
||||
self.assertEqual(started, [None]) # and is turned away
|
||||
self.assertEqual((acct / 'save.dat').read_bytes(), b'new')
|
||||
self.assertEqual((r['backup'] / 'save.dat').read_bytes(), b'old')
|
||||
let_go = self.game_lock(lock) # after the import the game starts as ever
|
||||
self.assertIsNotNone(let_go)
|
||||
let_go()
|
||||
|
||||
|
||||
class SaveShareTest(unittest.TestCase):
|
||||
"""launcher/saves.py export_save and import_zip: one zip with the save and a manifest, for a bug report or another
|
||||
|
||||
Reference in New Issue
Block a user