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:
chrg6
2026-11-12 22:22:17 -05:00
parent d0e6cd695e
commit 0a85e33e0f
3 changed files with 128 additions and 1 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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