mirror of
https://github.com/EDCD/EDMarketConnector.git
synced 2025-04-21 11:27:38 +03:00
Merge pull request #852 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:
commit
8f94a259be
@ -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
|
||||
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user