Skip to content

Commit

Permalink
Add workaround for pyinstaller bootloader not propagating signals to …
Browse files Browse the repository at this point in the history
…child.

Usually, pyinstaller tries to pass signals (eg: SIGINT, SIGTERM) to the child so that it can properly respond.
When running in `--onefile` mode (ie: bundled as single exe file), this doesn't happen. It appears that the bootloader
spawns the child as a separate process that runs completely separately. Kill signals sent to the parent do not get
forwarded to the child, which keeps running long after the parent ends.

To work around this, I've added a check to see if a parent process exists with the same name and exe. The app will
regularly check if the parent is still running. If not, the app exits.

I also did some tidying up of the shutdown logic. I kept running into "memory at 0xFFFFF... cannot be referenced"
errors popping up on Windows shutdown. I suspect these were due to exiting the wx mainloop without cleaning up
resources (ie: destroying the app). I've made it so that `shutdown` is called after the event loop exits and it
destroys the wxapp as part of the process
  • Loading branch information
Crozzers committed Jan 20, 2024
1 parent a9daed0 commit 217a0a6
Show file tree
Hide file tree
Showing 4 changed files with 61 additions and 22 deletions.
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ wmi
pypiwin32
wxPython
pyvda
psutil
2 changes: 1 addition & 1 deletion src/gui/systray.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def exit(self):
if callable(self._on_exit):
self._on_exit()
self.RemoveIcon()
WxApp().schedule_exit()
WxApp().ExitMainLoop()

def __enter__(self):
return self
Expand Down
40 changes: 29 additions & 11 deletions src/gui/wx_app.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from typing import Literal
import logging
from typing import Literal, Optional
import psutil

import wx
import wx.adv
from common import single_call

from gui.layout_manager import LayoutPage
from gui.on_spawn_manager import OnSpawnPage
Expand All @@ -13,6 +16,11 @@
class WxApp(wx.App):
__instance: 'WxApp'

@single_call # mark as `single_call` so we don't re-call `OnInit` during shutdown
def __init__(self):
self._log = logging.getLogger(__name__).getChild(self.__class__.__name__ + '.' + str(id(self)))
super().__init__()

def __new__(cls, *args, **kwargs):
if not isinstance(getattr(cls, '__instance', None), cls):
cls.__instance = wx.App.__new__(cls, *args, **kwargs)
Expand All @@ -26,18 +34,28 @@ def OnInit(self):
self.enable_sigterm()
return True

def enable_sigterm(self):
self.timer = wx.Timer(self._top_frame)
self._top_frame.Bind(wx.EVT_TIMER, self.OnTimer, self.timer)
self.timer.Start(1000)
def enable_sigterm(self, parent: Optional[psutil.Process] = None):
"""
Allow the application to respond to external signals such as SIGTERM or SIGINT.
This is done by creating a wx timer that regularly returns control of the program
to the Python runtime, allowing it to process the signals.
def OnTimer(self, *_):
return
Args:
parent: optional parent process. If provided, the lifetime of the application will
be tied to this process. When the parent exits, this app will follow suit
"""
self._log.debug(f'enable sigterm, {parent=}')

def schedule_exit(self):
self._top_frame.DestroyChildren()
self._top_frame.Destroy()
wx.CallAfter(self.Destroy)
def check_parent_alive():
if not parent or parent.is_running():
return
self._log.info('parent process no longer running. exiting mainloop...')
self.ExitMainLoop()

# enable sigterm by regularly returning control back to python
self.timer = wx.Timer(self._top_frame)
self._top_frame.Bind(wx.EVT_TIMER, lambda *_: check_parent_alive(), self.timer)
self.timer.Start(1000)


def spawn_gui(snapshot: SnapshotFile, start_page: Literal['rules', 'settings'] = 'rules'):
Expand Down
40 changes: 30 additions & 10 deletions src/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import logging
import os
import psutil
import signal
import time

Expand Down Expand Up @@ -161,6 +163,19 @@ def mtm(window: Window) -> bool:
snap.update()


@single_call
def shutdown(*_):
log.info('begin shutdown process')
app.ExitMainLoop()
monitor_thread.stop()
snapshot_service.stop()
log.debug('destroy WxApp')
app.Destroy()
log.info('save snapshot before shutting down')
snap.save()
log.info('end shutdown process')


if __name__ == '__main__':
logging.basicConfig(
filename=local_path('log.txt'), filemode='w', format='%(asctime)s:%(levelname)s:%(name)s:%(message)s'
Expand Down Expand Up @@ -216,19 +231,22 @@ def mtm(window: Window) -> bool:
['About', lambda *_: about_dialog()],
]

@single_call
def shutdown(*_):
log.info('begin shutdown process')
app.ExitMainLoop()
monitor_thread.stop()
snapshot_service.stop()
log.info('save snapshot before shutting down')
snap.save()
log.info('exit')

# register termination signals so we can do graceful shutdown
for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGABRT):
signal.signal(sig, shutdown)

# detect if we are running as a single exe file (pyinstaller --onefile mode)
current_process = psutil.Process(os.getpid())
log.debug(f'PID: {current_process.pid}')
parent_process = current_process.parent()
if (
parent_process is not None
and current_process.exe() == parent_process.exe()
and current_process.name() == parent_process.name()
):
log.debug(f'parent detected. PPID: {parent_process.pid}')
app.enable_sigterm(parent_process)

with TaskbarIcon(menu_options, on_click=update_systray_options, on_exit=shutdown):
monitor_thread = DeviceChangeService(DeviceChangeCallback(snap.restore, shutdown, snap.update), snap.lock)
monitor_thread.start()
Expand All @@ -242,4 +260,6 @@ def shutdown(*_):
except KeyboardInterrupt:
pass
finally:
log.info('app mainloop closed')
shutdown()
log.debug('fin')

0 comments on commit 217a0a6

Please sign in to comment.