[cli/core/lfs] Add slightly janky lock for installed game data

In order to prevent multiple instances of Legendary mucking with installed game data
acquire a lock as soon as it is required and only release it (implicitly) when
Legendary exits.

This is a bit jank, but should prevent people from messing up their local data by
running two install commands at a time.

EGL sync is technically also affected by this, but in its case we simply skip the
sync/import/export and leave it to the next instance with a lock to do.
This commit is contained in:
derrod 2023-06-17 23:39:59 +02:00
parent e26b9e60ff
commit 4145381b93
3 changed files with 67 additions and 4 deletions

View file

@ -315,7 +315,7 @@ class LegendaryCLI:
print('\nInstalled games:')
for game in games:
if game.install_size == 0:
if game.install_size == 0 and self.core.lgd.lock_installed():
logger.debug(f'Updating missing size for {game.app_name}')
m = self.core.load_manifest(self.core.get_installed_manifest(game.app_name)[0])
game.install_size = sum(fm.file_size for fm in m.file_manifest_list.elements)
@ -472,12 +472,15 @@ class LegendaryCLI:
logger.info(f'Checking "{igame.title}" ({igame.app_name})')
# override save path only if app name is specified
if args.app_name and args.save_path:
if not self.core.lgd.lock_installed():
logger.error('Unable to lock install data, cannot modify save path.')
break
logger.info(f'Overriding save path with "{args.save_path}"...')
igame.save_path = args.save_path
self.core.lgd.set_installed_game(igame.app_name, igame)
# if there is no saved save path, try to get one
if not igame.save_path:
# if there is no saved save path, try to get one, skip if we cannot get a install data lock
if not igame.save_path and self.core.lgd.lock_installed():
if args.yes and not args.accept_path:
logger.info('Save path for this title has not been set, skipping due to --yes')
continue
@ -796,6 +799,11 @@ class LegendaryCLI:
subprocess.Popen(command, env=full_env)
def install_game(self, args):
if not self.core.lgd.lock_installed():
logger.fatal('Failed to acquire installed data lock, only one instance of Legendary may '
'install/import/move applications at a time.')
return
args.app_name = self._resolve_aliases(args.app_name)
if self.core.is_installed(args.app_name):
igame = self.core.get_installed_game(args.app_name)
@ -1129,6 +1137,11 @@ class LegendaryCLI:
logger.info('Automatic installation not available on Linux.')
def uninstall_game(self, args):
if not self.core.lgd.lock_installed():
logger.fatal('Failed to acquire installed data lock, only one instance of Legendary may '
'install/import/move applications at a time.')
return
args.app_name = self._resolve_aliases(args.app_name)
igame = self.core.get_installed_game(args.app_name)
if not igame:
@ -1259,6 +1272,11 @@ class LegendaryCLI:
logger.info(f'Run "legendary repair {args.app_name}" to repair your game installation.')
def import_game(self, args):
if not self.core.lgd.lock_installed():
logger.fatal('Failed to acquire installed data lock, only one instance of Legendary may '
'install/import/move applications at a time.')
return
# make sure path is absolute
args.app_path = os.path.abspath(args.app_path)
args.app_name = self._resolve_aliases(args.app_name)
@ -1354,6 +1372,11 @@ class LegendaryCLI:
logger.info(f'{"DLC" if game.is_dlc else "Game"} "{game.app_title}" has been imported.')
def egs_sync(self, args):
if not self.core.lgd.lock_installed():
logger.fatal('Failed to acquire installed data lock, only one instance of Legendary may '
'install/import/move applications at a time.')
return
if args.unlink:
logger.info('Unlinking and resetting EGS and LGD sync...')
self.core.lgd.config.remove_option('Legendary', 'egl_programdata')
@ -2509,6 +2532,11 @@ class LegendaryCLI:
logger.info('Saved choices to configuration.')
def move(self, args):
if not self.core.lgd.lock_installed():
logger.fatal('Failed to acquire installed data lock, only one instance of Legendary may '
'install/import/move applications at a time.')
return
app_name = self._resolve_aliases(args.app_name)
igame = self.core.get_installed_game(app_name, skip_sync=True)
if not igame:

View file

@ -1749,6 +1749,9 @@ class LegendaryCore:
def egl_import(self, app_name):
if not self.asset_valid(app_name):
raise ValueError(f'To-be-imported game {app_name} not in game asset database!')
if not self.lgd.lock_installed():
self.log.warning('Could not acquire lock for EGL import')
return
self.log.debug(f'Importing "{app_name}" from EGL')
# load egl json file
@ -1796,9 +1799,12 @@ class LegendaryCore:
# mark game as installed
_ = self._install_game(lgd_igame)
return
def egl_export(self, app_name):
if not self.lgd.lock_installed():
self.log.warning('Could not acquire lock for EGL import')
return
self.log.debug(f'Exporting "{app_name}" to EGL')
# load igame/game
lgd_game = self.get_game(app_name)
@ -1860,6 +1866,10 @@ class LegendaryCore:
"""
Sync game installs between Legendary and the Epic Games Launcher
"""
if not self.lgd.lock_installed():
self.log.warning('Could not acquire lock for EGL sync')
return
# read egl json files
if app_name:
lgd_igame = self._get_installed_game(app_name)

View file

@ -9,6 +9,8 @@ from collections import defaultdict
from pathlib import Path
from time import time
from filelock import FileLock
from .utils import clean_filename, LockedJSONData
from legendary.models.game import *
@ -114,6 +116,8 @@ class LGDLFS:
self.config.set('Legendary', '; Disables the notice about an available update on exit')
self.config.set('Legendary', 'disable_update_notice', 'false' if is_windows_mac_or_pyi() else 'true')
self._installed_lock = FileLock(os.path.join(self.path, 'installed.json') + '.lock')
try:
self._installed = json.load(open(os.path.join(self.path, 'installed.json')))
except Exception as e:
@ -293,6 +297,27 @@ class LGDLFS:
except Exception as e:
self.log.warning(f'Failed to delete file "{f}": {e!r}')
def lock_installed(self) -> bool:
"""
Locks the install data. We do not care about releasing this lock.
If it is acquired by a Legendary instance it should own the lock until it exits.
Some operations such as egl sync may be simply skipped if a lock cannot be acquired
"""
if self._installed_lock.is_locked:
return True
try:
self._installed_lock.acquire(blocking=False)
# reload data in case it has been updated elsewhere
try:
self._installed = json.load(open(os.path.join(self.path, 'installed.json')))
except Exception as e:
self.log.debug(f'Failed to load installed game data: {e!r}')
return True
except TimeoutError:
return False
def get_installed_game(self, app_name):
if self._installed is None:
try: