1
0
mirror of https://github.com/EDCD/EDMarketConnector.git synced 2025-04-21 11:27:38 +03:00

Merge pull request from EDCD/enhancement/process-dupe-check-using-folder-lock

Switch to using a journals folder lockfile to enforce single process
This commit is contained in:
Athanasius 2021-01-19 20:44:37 +00:00 committed by GitHub
commit 8f94a259be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 159 additions and 100 deletions

@ -10,6 +10,7 @@ import sys
import webbrowser
from builtins import object, str
from os import chdir, environ
from os import getpid as os_getpid
from os.path import dirname, isdir, join
from sys import platform
from time import localtime, strftime, time
@ -20,6 +21,21 @@ from constants import applongname, appname, protocolhandler_redirect
# config will now cause an appname logger to be set up, so we need the
# console redirect before this
if __name__ == '__main__':
# Keep this as the very first code run to be as sure as possible of no
# output until after this redirect is done, if needed.
if getattr(sys, 'frozen', False):
# By default py2exe tries to write log to dirname(sys.executable) which fails when installed
import tempfile
# unbuffered not allowed for text in python3, so use `1 for line buffering
sys.stdout = sys.stderr = open(join(tempfile.gettempdir(), f'{appname}.log'), mode='a', buffering=1)
# TODO: Test: Make *sure* this redirect is working, else py2exe is going to cause an exit popup
# After the redirect in case config does logging setup
from config import appversion, appversion_nobuild, config, copyright
from EDMCLogging import edmclogger, logger, logging
if __name__ == '__main__': # noqa: C901
# Command-line arguments
parser = argparse.ArgumentParser(
prog=appname,
@ -41,74 +57,138 @@ if __name__ == '__main__':
args = parser.parse_args()
if args.trace:
logger.setLevel(logging.TRACE)
edmclogger.set_channels_loglevel(logging.TRACE)
else:
edmclogger.set_channels_loglevel(logging.DEBUG)
def no_other_instance_running() -> bool: # noqa: CCR001
"""
Ensure only one copy of the app is running under this user account.
OSX does this automatically.
Ensure only one copy of the app is running for the configured journal directory.
:returns: True if we are the single instance, else False.
"""
# TODO: Linux implementation
logger.trace('Begin...')
if platform == 'win32':
import ctypes
from ctypes.wintypes import BOOL, HWND, INT, LPARAM, LPCWSTR, LPWSTR
logger.trace('win32, using msvcrt')
# win32 doesn't have fcntl, so we have to use msvcrt
import msvcrt
EnumWindows = ctypes.windll.user32.EnumWindows # noqa: N806
GetClassName = ctypes.windll.user32.GetClassNameW # noqa: N806
GetClassName.argtypes = [HWND, LPWSTR, ctypes.c_int]
GetWindowText = ctypes.windll.user32.GetWindowTextW # noqa: N806
GetWindowText.argtypes = [HWND, LPWSTR, ctypes.c_int]
GetWindowTextLength = ctypes.windll.user32.GetWindowTextLengthW # noqa: N806
GetProcessHandleFromHwnd = ctypes.windll.oleacc.GetProcessHandleFromHwnd # noqa: N806
logger.trace(f'journal_dir_lockfile = {journal_dir_lockfile!r}')
SW_RESTORE = 9 # noqa: N806
SetForegroundWindow = ctypes.windll.user32.SetForegroundWindow # noqa: N806
ShowWindow = ctypes.windll.user32.ShowWindow # noqa: N806
ShowWindowAsync = ctypes.windll.user32.ShowWindowAsync # noqa: N806
locked = False
try:
msvcrt.locking(journal_dir_lockfile.fileno(), msvcrt.LK_NBLCK, 4096)
COINIT_MULTITHREADED = 0 # noqa: N806,F841
COINIT_APARTMENTTHREADED = 0x2 # noqa: N806
COINIT_DISABLE_OLE1DDE = 0x4 # noqa: N806
CoInitializeEx = ctypes.windll.ole32.CoInitializeEx # noqa: N806
except Exception as e:
logger.info(f"Exception: Couldn't lock journal directory \"{journal_dir}\""
f", assuming another process running: {e!r}")
locked = True
ShellExecute = ctypes.windll.shell32.ShellExecuteW # noqa: N806
ShellExecute.argtypes = [HWND, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, INT]
if locked:
# Need to do the check for this being an edmc:// auth callback
import ctypes
from ctypes.wintypes import BOOL, HWND, INT, LPARAM, LPCWSTR, LPWSTR
def window_title(h):
if h:
text_length = GetWindowTextLength(h) + 1
buf = ctypes.create_unicode_buffer(text_length)
if GetWindowText(h, buf, text_length):
return buf.value
EnumWindows = ctypes.windll.user32.EnumWindows # noqa: N806
GetClassName = ctypes.windll.user32.GetClassNameW # noqa: N806
GetClassName.argtypes = [HWND, LPWSTR, ctypes.c_int]
GetWindowText = ctypes.windll.user32.GetWindowTextW # noqa: N806
GetWindowText.argtypes = [HWND, LPWSTR, ctypes.c_int]
GetWindowTextLength = ctypes.windll.user32.GetWindowTextLengthW # noqa: N806
GetProcessHandleFromHwnd = ctypes.windll.oleacc.GetProcessHandleFromHwnd # noqa: N806
return None
SW_RESTORE = 9 # noqa: N806
SetForegroundWindow = ctypes.windll.user32.SetForegroundWindow # noqa: N806
ShowWindow = ctypes.windll.user32.ShowWindow # noqa: N806
ShowWindowAsync = ctypes.windll.user32.ShowWindowAsync # noqa: N806
@ctypes.WINFUNCTYPE(BOOL, HWND, LPARAM)
def enumwindowsproc(window_handle, l_param):
# class name limited to 256 - https://msdn.microsoft.com/en-us/library/windows/desktop/ms633576
cls = ctypes.create_unicode_buffer(257)
if GetClassName(window_handle, cls, 257) \
and cls.value == 'TkTopLevel' \
and window_title(window_handle) == applongname \
and GetProcessHandleFromHwnd(window_handle):
# If GetProcessHandleFromHwnd succeeds then the app is already running as this user
if len(sys.argv) > 1 and sys.argv[1].startswith(protocolhandler_redirect):
CoInitializeEx(0, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE)
# Wait for it to be responsive to avoid ShellExecute recursing
ShowWindow(window_handle, SW_RESTORE)
ShellExecute(0, None, sys.argv[1], None, None, SW_RESTORE)
COINIT_MULTITHREADED = 0 # noqa: N806,F841
COINIT_APARTMENTTHREADED = 0x2 # noqa: N806
COINIT_DISABLE_OLE1DDE = 0x4 # noqa: N806
CoInitializeEx = ctypes.windll.ole32.CoInitializeEx # noqa: N806
else:
ShowWindowAsync(window_handle, SW_RESTORE)
SetForegroundWindow(window_handle)
ShellExecute = ctypes.windll.shell32.ShellExecuteW # noqa: N806
ShellExecute.argtypes = [HWND, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, INT]
return False
def window_title(h):
if h:
text_length = GetWindowTextLength(h) + 1
buf = ctypes.create_unicode_buffer(text_length)
if GetWindowText(h, buf, text_length):
return buf.value
return True
return None
return EnumWindows(enumwindowsproc, 0)
@ctypes.WINFUNCTYPE(BOOL, HWND, LPARAM)
def enumwindowsproc(window_handle, l_param):
"""
Determine if any window for the Application exists.
Called for each found window by EnumWindows().
When a match is found we check if we're being invoked as the
edmc://auth handler. If so we send the message to the existing
process/window. If not we'll raise that existing window to the
foreground.
:param window_handle: Window to check.
:param l_param: The second parameter to the EnumWindows() call.
:return: False if we found a match, else True to continue iteration
"""
# class name limited to 256 - https://msdn.microsoft.com/en-us/library/windows/desktop/ms633576
cls = ctypes.create_unicode_buffer(257)
# This conditional is exploded to make debugging slightly easier
if GetClassName(window_handle, cls, 257):
if cls.value == 'TkTopLevel':
if window_title(window_handle) == applongname:
if GetProcessHandleFromHwnd(window_handle):
# If GetProcessHandleFromHwnd succeeds then the app is already running as this user
if len(sys.argv) > 1 and sys.argv[1].startswith(protocolhandler_redirect):
CoInitializeEx(0, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE)
# Wait for it to be responsive to avoid ShellExecute recursing
ShowWindow(window_handle, SW_RESTORE)
ShellExecute(0, None, sys.argv[1], None, None, SW_RESTORE)
else:
ShowWindowAsync(window_handle, SW_RESTORE)
SetForegroundWindow(window_handle)
return False # Indicate window found, so stop iterating
# Indicate that EnumWindows() needs to continue iterating
return True # Do not remove, else this function as a callback breaks
# This performs the edmc://auth check and forward
# EnumWindows() will iterate through all open windows, calling
# enumwindwsproc() on each. When an invocation returns False it
# stops iterating.
# Ref: <https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-enumwindows>
EnumWindows(enumwindowsproc, 0)
return False # Another instance is running
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!")
try:
fcntl.flock(journal_dir_lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
except Exception as e:
logger.info(f"Exception: Couldn't lock journal directory \"{journal_dir}\","
f"assuming another process running: {e!r}")
return False
journal_dir_lockfile.write(f"Path: {journal_dir}\nPID: {os_getpid()}\n")
logger.trace('Done')
return True
def already_running_popup():
@ -134,42 +214,35 @@ if __name__ == '__main__':
root.mainloop()
if not no_other_instance_running():
# There's a copy already running. We want to inform the user by
# **appending** to the log file, not truncating it.
if getattr(sys, 'frozen', False):
# By default py2exe tries to write log to dirname(sys.executable) which fails when installed
import tempfile
journal_dir: str = config.get('journaldir') or config.default_journal_dir
# This must be at top level to guarantee the file handle doesn't go out
# of scope and get cleaned up, removing the lock with it.
journal_dir_lockfile_name = join(journal_dir, 'edmc-journal-lock.txt')
try:
journal_dir_lockfile = open(journal_dir_lockfile_name, mode='w+', encoding='utf-8')
# unbuffered not allowed for text in python3, so use `1 for line buffering
sys.stdout = sys.stderr = open(join(tempfile.gettempdir(), f'{appname}.log'), mode='a', buffering=1)
# 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 \"{journal_dir_lockfile_name}\" for \"w+\""
f" Aborting duplicate process checks: {e!r}")
# Logging isn't set up yet.
# We'll keep this print, but it will be over-written by any subsequent
# write by the already-running process.
print("An EDMarketConnector.exe process was already running, exiting.")
else:
if not no_other_instance_running():
# There's a copy already running.
# To be sure the user knows, we need a popup
already_running_popup()
# If the user closes the popup with the 'X', not the 'OK' button we'll
# reach here.
sys.exit(0)
logger.info("An EDMarketConnector.exe process was already running, exiting.")
# To be sure the user knows, we need a popup
already_running_popup()
# If the user closes the popup with the 'X', not the 'OK' button we'll
# reach here.
sys.exit(0)
# Keep this as the very first code run to be as sure as possible of no
# output until after this redirect is done, if needed.
if getattr(sys, 'frozen', False):
# By default py2exe tries to write log to dirname(sys.executable) which fails when installed
import tempfile
# Now that we're sure we're the only instance running we can truncate the logfile
sys.stdout.truncate()
# unbuffered not allowed for text in python3, so use `1 for line buffering
sys.stdout = sys.stderr = open(join(tempfile.gettempdir(), f'{appname}.log'), mode='wt', buffering=1)
# TODO: Test: Make *sure* this redirect is working, else py2exe is going to cause an exit popup
# isort: off
from config import appversion, appversion_nobuild, config, copyright
# isort: on
from EDMCLogging import edmclogger, logger, logging
# See EDMCLogging.py docs.
# isort: off
@ -195,15 +268,6 @@ import tkinter.font
import tkinter.messagebox
from tkinter import ttk
from ttkHyperlinkLabel import HyperlinkLabel
if __debug__:
if platform != 'win32':
import pdb
import signal
signal.signal(signal.SIGTERM, lambda sig, frame: pdb.Pdb().set_trace(frame))
import commodity
import companion
import plug
@ -217,6 +281,7 @@ from l10n import Translations
from monitor import monitor
from protocol import protocolhandler
from theme import theme
from ttkHyperlinkLabel import HyperlinkLabel
SERVER_RETRY = 5 # retry pause for Companion servers [s]
@ -242,7 +307,7 @@ class AppWindow(object):
EVENT_BUTTON = 4
EVENT_VIRTUAL = 35
def __init__(self, master):
def __init__(self, master): # noqa: C901
self.holdofftime = config.getint('querytime') + companion.holdoff
@ -598,7 +663,7 @@ class AppWindow(object):
self.status['text'] = str(e)
self.cooldown()
def getandsend(self, event=None, retrying=False):
def getandsend(self, event=None, retrying=False): # noqa: C901
auto_update = not event
play_sound = (auto_update or int(event.type) == self.EVENT_VIRTUAL) and not config.getint('hotkey_mute')
@ -757,7 +822,7 @@ class AppWindow(object):
pass
# Handle event(s) from the journal
def journal_event(self, event: str):
def journal_event(self, event: str): # noqa: C901
"""
Handle a Journal event passed through event queue from monitor.py.
@ -1193,13 +1258,7 @@ Locale LC_TIME: {locale.getlocale(locale.LC_TIME)}'''
# Run the app
if __name__ == "__main__":
if args.trace:
logger.setLevel(logging.TRACE)
edmclogger.set_channels_loglevel(logging.TRACE)
else:
edmclogger.set_channels_loglevel(logging.DEBUG)
if __name__ == "__main__": # noqa C901
logger.info(f'Startup v{appversion} : Running on Python v{sys.version}')
logger.debug(f'''Platform: {sys.platform} {sys.platform == "win32" and sys.getwindowsversion()}
argv[0]: {sys.argv[0]}

@ -5,7 +5,7 @@ import threading
import urllib.request, urllib.error, urllib.parse
import sys
from config import config
from config import appname, config
from constants import protocolhandler_redirect