1
0
mirror of https://github.com/EDCD/EDMarketConnector.git synced 2025-04-13 07:47:14 +03:00
EDMarketConnector/journal_lock.py
Athanasius e777727658 Change JournalLock.obtain_lock() return to an Enum.
This way we can tell the difference between:

1. This process obtained the lock.
2. Another process has the lock.
3. We couldn't get the lock due to not being able to open the lock file
   read-write.

Case 3 is currently also returned if the configured journal directory
doesn't exist.  This will be the case on any MacOS system that never had
the game running.  Likely given the OS hasn't been supported for the
game in years now.

# Conflicts:
#	EDMarketConnector.py
2021-04-01 14:45:50 +01:00

242 lines
8.9 KiB
Python

"""Implements locking of Journal directory."""
import pathlib
import tkinter as tk
from enum import Enum
from os import getpid as os_getpid
from sys import platform
from tkinter import ttk
from typing import TYPE_CHECKING, Callable, Optional
from config import config
from EDMCLogging import get_main_logger
logger = get_main_logger()
if TYPE_CHECKING:
def _(x: str) -> str:
return x
class JournalLockResult(Enum):
"""Enumeration of possible outcomes of trying to lock the Journal Directory."""
LOCKED = 1
JOURNALDIR_NOTEXIST = 2
JOURNALDIR_READONLY = 3
ALREADY_LOCKED = 4
class JournalLock:
"""Handle locking of journal directory."""
def __init__(self) -> None:
"""Initialise where the journal directory and lock file are."""
self.journal_dir: str = config.get_str('journaldir') or config.default_journal_dir
self.journal_dir_path = pathlib.Path(self.journal_dir)
self.journal_dir_lockfile_name: Optional[pathlib.Path] = None
# We never test truthiness of this, so let it be defined when first assigned. Avoids type hint issues.
# self.journal_dir_lockfile: Optional[IO] = None
def obtain_lock(self) -> JournalLockResult:
"""
Attempt to obtain a lock on the journal directory.
:return: LockResult - See the class Enum definition
"""
self.journal_dir_lockfile_name = self.journal_dir_path / 'edmc-journal-lock.txt'
logger.trace(f'journal_dir_lockfile_name = {self.journal_dir_lockfile_name!r}')
try:
self.journal_dir_lockfile = open(self.journal_dir_lockfile_name, mode='w+', encoding='utf-8')
# Linux CIFS read-only mount throws: OSError(30, 'Read-only file system')
# Linux no-write-perm directory throws: PermissionError(13, 'Permission denied')
except Exception as e: # For remote FS this could be any of a wide range of exceptions
logger.warning(f"Couldn't open \"{self.journal_dir_lockfile_name}\" for \"w+\""
f" Aborting duplicate process checks: {e!r}")
return JournalLockResult.JOURNALDIR_READONLY
if platform == 'win32':
logger.trace('win32, using msvcrt')
# win32 doesn't have fcntl, so we have to use msvcrt
import msvcrt
try:
msvcrt.locking(self.journal_dir_lockfile.fileno(), msvcrt.LK_NBLCK, 4096)
except Exception as e:
logger.info(f"Exception: Couldn't lock journal directory \"{self.journal_dir}\""
f", assuming another process running: {e!r}")
return JournalLockResult.ALREADY_LOCKED
else:
logger.trace('NOT win32, using fcntl')
try:
import fcntl
except ImportError:
logger.warning("Not on win32 and we have no fcntl, can't use a file lock!"
"Allowing multiple instances!")
return JournalLockResult.LOCKED
try:
fcntl.flock(self.journal_dir_lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
except Exception as e:
logger.info(f"Exception: Couldn't lock journal directory \"{self.journal_dir}\", "
f"assuming another process running: {e!r}")
return JournalLockResult.ALREADY_LOCKED
self.journal_dir_lockfile.write(f"Path: {self.journal_dir}\nPID: {os_getpid()}\n")
self.journal_dir_lockfile.flush()
logger.trace('Done')
return JournalLockResult.LOCKED
def release_lock(self) -> bool:
"""
Release lock on journal directory.
:return: bool - Success of unlocking operation.
"""
unlocked = False
if platform == 'win32':
logger.trace('win32, using msvcrt')
# win32 doesn't have fcntl, so we have to use msvcrt
import msvcrt
try:
# Need to seek to the start first, as lock range is relative to
# current position
self.journal_dir_lockfile.seek(0)
msvcrt.locking(self.journal_dir_lockfile.fileno(), msvcrt.LK_UNLCK, 4096)
except Exception as e:
logger.info(f"Exception: Couldn't unlock journal directory \"{self.journal_dir}\": {e!r}")
else:
unlocked = True
else:
logger.trace('NOT win32, using fcntl')
try:
import fcntl
except ImportError:
logger.warning("Not on win32 and we have no fcntl, can't use a file lock!")
return True # Lie about being unlocked
try:
fcntl.flock(self.journal_dir_lockfile, fcntl.LOCK_UN)
except Exception as e:
logger.info(f"Exception: Couldn't unlock journal directory \"{self.journal_dir}\": {e!r}")
else:
unlocked = True
# Close the file whether or not the unlocking succeeded.
self.journal_dir_lockfile.close()
self.journal_dir_lockfile_name = None
# Avoids type hint issues, see 'declaration' in JournalLock.__init__()
# self.journal_dir_lockfile = None
return unlocked
class JournalAlreadyLocked(tk.Toplevel):
"""Pop-up for when Journal directory already locked."""
def __init__(self, parent: tk.Tk, callback: Callable) -> None:
"""
Init the user choice popup.
:param parent: - The tkinter parent window.
:param callback: - The function to be called when the user makes their choice.
"""
tk.Toplevel.__init__(self, parent)
self.parent = parent
self.callback = callback
self.title(_('Journal directory already locked'))
# remove decoration
if platform == 'win32':
self.attributes('-toolwindow', tk.TRUE)
elif platform == 'darwin':
# http://wiki.tcl.tk/13428
parent.call('tk::unsupported::MacWindowStyle', 'style', self, 'utility')
self.resizable(tk.FALSE, tk.FALSE)
frame = ttk.Frame(self)
frame.grid(sticky=tk.NSEW)
self.blurb = tk.Label(frame)
self.blurb['text'] = _("The new Journal Directory location is already locked.{CR}"
"You can either attempt to resolve this and then Retry, or choose to Ignore this.")
self.blurb.grid(row=1, column=0, columnspan=2, sticky=tk.NSEW)
self.retry_button = ttk.Button(frame, text=_('Retry'), command=self.retry)
self.retry_button.grid(row=2, column=0, sticky=tk.EW)
self.ignore_button = ttk.Button(frame, text=_('Ignore'), command=self.ignore)
self.ignore_button.grid(row=2, column=1, sticky=tk.EW)
self.protocol("WM_DELETE_WINDOW", self._destroy)
def retry(self) -> None:
"""Handle user electing to Retry obtaining the lock."""
logger.trace('User selected: Retry')
self.destroy()
self.callback(True, self.parent)
def ignore(self) -> None:
"""Handle user electing to Ignore failure to obtain the lock."""
logger.trace('User selected: Ignore')
self.destroy()
self.callback(False, self.parent)
def _destroy(self) -> None:
"""Destroy the Retry/Ignore popup."""
logger.trace('User force-closed popup, treating as Ignore')
self.ignore()
def update_lock(self, parent: tk.Tk) -> None:
"""
Update journal directory lock to new location if possible.
:param parent: - The parent tkinter window.
"""
current_journaldir = config.get_str('journaldir') or config.default_journal_dir
if current_journaldir == self.journal_dir:
return # Still the same
self.release_lock()
self.journal_dir = current_journaldir
self.journal_dir_path = pathlib.Path(self.journal_dir)
if not self.obtain_lock():
# Pop-up message asking for Retry or Ignore
self.retry_popup = self.JournalAlreadyLocked(parent, self.retry_lock)
def retry_lock(self, retry: bool, parent: tk.Tk) -> None:
"""
Try again to obtain a lock on the Journal Directory.
:param retry: - does the user want to retry? Comes from the dialogue choice.
:param parent: - The parent tkinter window.
"""
logger.trace(f'We should retry: {retry}')
if not retry:
return
current_journaldir = config.get_str('journaldir') or config.default_journal_dir
self.journal_dir = current_journaldir
self.journal_dir_path = pathlib.Path(self.journal_dir)
if not self.obtain_lock():
# Pop-up message asking for Retry or Ignore
self.retry_popup = self.JournalAlreadyLocked(parent, self.retry_lock)