#!/usr/bin/env python
# cardinal_pythonlib/winservice.py
# noinspection HttpUrlsUsage
r"""
===============================================================================
Original code copyright (C) 2009-2022 Rudolf Cardinal (rudolf@pobox.com).
This file is part of cardinal_pythonlib.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
===============================================================================
**Functions to manage Windows services.**
For an example, see ``crate_anon/tools/winservice.py``
*Details*
- https://stackoverflow.com/questions/32404
- http://www.chrisumbel.com/article/windows_services_in_python
- https://code.activestate.com/recipes/551780-win-services-helper/
- https://docs.activestate.com/activepython/2.4/pywin32/PyWin32.HTML
- https://docs.activestate.com/activepython/2.4/pywin32/modules.html
- source: ``...venv.../Lib/site-packages/win32/lib/win32serviceutil.py``
- https://docs.activestate.com/activepython/3.3/pywin32/servicemanager.html
- http://timgolden.me.uk/pywin32-docs/contents.html
*Synopsis, applied to a service named CRATE*
- INSTALL: run this script with "install" argument, as administrator
- RUN: use Windows service manager, or NET START CRATE
- STOP: use Windows service manager, or NET STOP CRATE
- STATUS: SC QUERY CRATE
- DEBUG: run this script with "debug" argument
- Log messages are sent to the console.
- Press CTRL-C to abort.
- If the debug script succeeds but the start command doesn't...
- Find the service in the Windows service manage.
- Right-click it and inspect its properties. You'll see the name of the
actual program being run, e.g.
``D:\venvs\crate\Lib\site-packages\win32\pythonservice.exe``
- Try running this from the command line (outside any virtual
environment).
- In my case this failed with:
.. code-block:: none
pythonservice.exe - Unable to Locate Component
This application has failed to start because pywintypes34.dll was not
found. Re-installing the application may fix this problem.
- That DLL was in: ``D:\venvs\crate\Lib\site-packages\pypiwin32_system32``
... so add that to the system PATH... and reboot... and then it's happy.
- However, that's not ideal for a virtual environment!
- Looking at win32serviceutil.py, it seems we could do better by
specifying _exe_name_ (to replace the default PythonService.exe) and
_exe_args_. The sequence
.. code-block:: none
myscript.py install
-> win32serviceutil.HandleCommandLine()
... fishes things out of cls._exe_name_, etc.
-> win32serviceutil.InstallService()
... builds a command line
... by default:
"d:\venvs\crate\lib\site-packages\win32\PythonService.exe"
-> win32service.CreateService()
- So how, in the normal situation, does PythonService.exe find our script?
- At this point, see also https://stackoverflow.com/questions/34696815
- Source code is:
https://github.com/tjguk/pywin32/blob/master/win32/src/PythonService.cpp
- Starting a service directly with PrepareToHostSingle:
- https://mail.python.org/pipermail/python-win32/2008-April/007299.html
- https://mail.python.org/pipermail/python-win32/2010-May/010487.html
- SUCCESS! Method is:
- In service class:
.. code-block:: python
_exe_name_ = sys.executable # python.exe in the virtualenv
_exe_args_ = '"{}"'.format(os.path.realpath(__file__)) # this script
-- In main:
.. code-block:: python
if len(sys.argv) == 1:
try:
print("Trying to start service directly...")
evtsrc_dll = os.path.abspath(servicemanager.__file__)
servicemanager.PrepareToHostSingle(CratewebService) # CLASS
servicemanager.Initialize('aservice', evtsrc_dll)
servicemanager.StartServiceCtrlDispatcher()
except win32service.error as details:
print("Failed: {}".format(details))
# print(repr(details.__dict__))
errnum = details.winerror
if errnum == winerror.ERROR_FAILED_SERVICE_CONTROLLER_CONNECT:
win32serviceutil.usage()
else:
win32serviceutil.HandleCommandLine(CratewebService) # CLASS
- Now, if you run it directly with no arguments, from a command prompt,
it will fail and print the usage message, but if you run it from the
service manager (with no arguments), it'll start the service. Everything
else seems to work. Continues to work when the PATH doesn't include the
virtual environment.
- However, it breaks the "debug" option. The "debug" option reads the
registry about the INSTALLED service to establish the name of the program
that it runs. It assumes PythonService.exe.
*Script parameters*
If you run this script with no parameters, you'll see this:
.. code-block:: none
Usage: 'crate_windows_service-script.py [options] install|update|remove|start [...]|stop|restart [...]|debug [...]'
Options for 'install' and 'update' commands only:
--username domain\username : The Username the service is to run under
--password password : The password for the username
--startup [manual|auto|disabled|delayed] : How the service starts, default = manual
--interactive : Allow the service to interact with the desktop.
--perfmonini file: .ini file to use for registering performance monitor data
--perfmondll file: .dll file to use when querying the service for
performance data, default = perfmondata.dll
Options for 'start' and 'stop' commands only:
--wait seconds: Wait for the service to actually start or stop.
If you specify --wait with the 'stop' option, the service
and all dependent services will be stopped, each waiting
the specified period.
Windows functions:
- CreateEvent: https://msdn.microsoft.com/en-us/library/windows/desktop/ms682396(v=vs.85).aspx
- WaitForSingleObject: https://msdn.microsoft.com/en-us/library/windows/desktop/ms687032(v=vs.85).aspx
*Problems killing things*
We had this:
.. code-block:: python
def terminate(self):
if not self.running:
return
if WINDOWS:
# Under Windows, terminate() is an alias for kill(), which is
# the hard kill. This is the soft kill:
# https://docs.python.org/3.4/library/subprocess.html
# SOMETHING GOES SERIOUSLY AWRY HERE.
# - Tracebacks are from a slightly irrelevant place.
# - Not all instances of OSError are caught.
# - Running two processes, you get messages like:
# * process 1/2: sending CTRL-C
# * process 2/2: failed to send CTRL-C...
# without the other expected pair of messages.
# DOES THIS MEAN A BUG IN SUBPROCESS?
# Not sure. Removed "send_signal" code.
# try:
# # Ctrl-C is generally "softer" than Ctrl-Break.
# # Specifically: CherryPy prints exiting message upon Ctrl-C
# # and just stops on Ctrl-Break.
# self.warning("Asking process to stop (sending CTRL-C)")
# self.process.send_signal(CTRL_C_EVENT)
# # self.warning("Asking process to stop (sending CTRL-BREAK)")
# # self.process.send_signal(CTRL_BREAK_EVENT)
# except OSError:
# # In practice: "OSError: [WinError 6] The handle is invalid"
# self.warning("Failed to send CTRL-C; using hard kill")
# self.process.terminate() # hard kill under Windows
self.warning("Can't terminate nicely; using hard kill")
self.process.terminate() # hard kill under Windows
# The PROBLEM is that Celery processes live on.
else:
self.warning("Asking process to stop (SIGTERM)")
self.process.terminate() # soft kill under POSIX
However, the CTRL-C/CTRL-BREAK method failed, and a hard kill left Celery
stuff running (because it killed the root, not child, processes, I presume).
Looked at django-windows-tools,
- https://pypi.python.org/pypi/django-windows-tools
- https://github.com/antoinemartin/django-windows-tools
but it crashed in a print statement -- Python 2 only at present (2016-05-11,
version 0.1.1). However, its process management is instructive; it uses
"multiprocessing", not "subprocess". The multiprocessing module calls Python
functions. And its docs explicitly note that terminate() leaves descendant
processes orphaned.
See in particular
- https://stackoverflow.com/questions/7085604/sending-c-to-python-subprocess-objects-on-windows
- https://stackoverflow.com/questions/140111/sending-an-arbitrary-signal-in-windows
Python bug?
- https://bugs.python.org/issue3905
- https://bugs.python.org/issue13368
Maybe a subprocess bug. Better luck with
``ctypes.windll.kernel32.GenerateConsoleCtrlEvent``.
Current method tries a variety of things under Windows:
.. code-block:: none
CTRL-C -> CTRL-BREAK -> TASKKILL /T -> TASKKILL /T /F -> kill()
... which are progressively less graceful in terms of child processes getting
to clean up. Still, it works (usually at one of the two TASKKILL stages).
*"The specified service is marked for deletion"*
- https://stackoverflow.com/questions/20561990
""" # noqa: E501
import atexit
import ctypes
import os
import platform
import subprocess
import sys
import traceback
from typing import Any, List, Optional, TextIO, Type
import pendulum
from cardinal_pythonlib.logs import get_brace_style_log_with_null_handler
from cardinal_pythonlib.process import kill_proc_tree
try:
from subprocess import CREATE_NEW_PROCESS_GROUP
from signal import CTRL_C_EVENT
from signal import CTRL_BREAK_EVENT
except ImportError:
CREATE_NEW_PROCESS_GROUP = None
CTRL_C_EVENT = 0 # wincon.h
CTRL_BREAK_EVENT = 1 # wincon.h
try:
# noinspection PyPackageRequirements
import servicemanager # part of pypiwin32
# noinspection PyPackageRequirements
import winerror # part of pypiwin32
# noinspection PyPackageRequirements
import win32event # part of pypiwin32
# noinspection PyPackageRequirements
import win32service # part of pypiwin32
# noinspection PyPackageRequirements
import win32serviceutil # part of pypiwin32
from win32serviceutil import ServiceFramework
except ImportError:
# this makes the type checker less unhappy
servicemanager = None
winerror = None
win32event = None
win32service = None
win32serviceutil = None
if os.environ.get("_SPHINX_AUTODOC_IN_PROGRESS"):
ServiceFramework = object # duff thing for inheritance
else:
raise
log = get_brace_style_log_with_null_handler(__name__)
TEST_FILENAME = r"C:\test_win_svc.txt"
TEST_PERIOD_MS = 5000
WINDOWS = platform.system() == "Windows"
# =============================================================================
# Subprocess management
# =============================================================================
[docs]class ProcessDetails(object):
"""
Description of a process.
"""
def __init__(
self,
name: str,
procargs: List[str],
logfile_out: str = "",
logfile_err: str = "",
) -> None:
"""
Args:
name: cosmetic name of the process
procargs: command-line arguments
logfile_out: filename to write ``stdout`` to
logfile_err: filename to write ``stderr`` to
"""
self.name = name
self.procargs = procargs
self.logfile_out = logfile_out
self.logfile_err = logfile_err
[docs]class ProcessManager(object):
"""
Object that manages a single process.
"""
KILL_LEVEL_CTRL_C_OR_SOFT_KILL = 0
KILL_LEVEL_CTRL_BREAK = 1
KILL_LEVEL_TASKKILL = 2
KILL_LEVEL_TASKKILL_FORCE = 3
KILL_LEVEL_HARD_KILL = 4
ALL_KILL_LEVELS = [
KILL_LEVEL_CTRL_C_OR_SOFT_KILL,
KILL_LEVEL_CTRL_BREAK,
KILL_LEVEL_TASKKILL,
KILL_LEVEL_TASKKILL_FORCE,
KILL_LEVEL_HARD_KILL,
]
def __init__(
self,
details: ProcessDetails,
procnum: int,
nprocs: int,
kill_timeout_sec: float = 5,
debugging: bool = False,
):
"""
Args:
details: description of the process as a :class:`ProcessDetails`
object
procnum: for cosmetic purposes only: the process sequence number of
this process
nprocs: for cosmetic purposes only: the total number of processes
(including others not managed by this instance)
kill_timeout_sec: how long (in seconds) will we wait for the
process to end peacefully, before we try to kill it?
debugging: be verbose?
"""
self.details = details
self.procnum = procnum
self.nprocs = nprocs
self.kill_timeout_sec = kill_timeout_sec
self.process = None # type: Optional[subprocess.Popen]
self.running = False
self.stdout = None # type: Optional[TextIO]
self.stderr = None # type: Optional[TextIO]
self.debugging = debugging
@property
def fullname(self) -> str:
"""
Description of the process.
"""
fullname = (
f"Process {self.procnum}/{self.nprocs} ({self.details.name})"
)
if self.running:
fullname += f" (PID={self.process.pid})"
return fullname
# -------------------------------------------------------------------------
# Logging
# -------------------------------------------------------------------------
[docs] def debug(self, msg: str) -> None:
"""
If we are being verbose, write a debug message to the Python disk log.
"""
if self.debugging:
s = f"{self.fullname}: {msg}"
log.debug(s)
[docs] def info(self, msg: str) -> None:
"""
Write an info message to the Windows Application log
(± to the Python disk log).
"""
# noinspection PyUnresolvedReferences
s = f"{self.fullname}: {msg}"
# noinspection PyUnresolvedReferences
servicemanager.LogInfoMsg(s)
if self.debugging:
log.info(s)
[docs] def warning(self, msg: str) -> None:
"""
Write a warning message to the Windows Application log
(± to the Python disk log).
"""
# Log messages go to the Windows APPLICATION log.
# noinspection PyUnresolvedReferences
s = f"{self.fullname}: {msg}"
# noinspection PyUnresolvedReferences
servicemanager.LogWarningMsg(s)
if self.debugging:
log.warning(s)
[docs] def error(self, msg: str) -> None:
"""
Write an error message to the Windows Application log
(± to the Python disk log).
"""
# noinspection PyUnresolvedReferences
s = f"{self.fullname}: {msg}"
# noinspection PyUnresolvedReferences
servicemanager.LogErrorMsg(s)
if self.debugging:
log.warning(s)
[docs] def open_logs(self) -> None:
"""
Open Python disk logs.
"""
if self.details.logfile_out:
self.stdout = open(self.details.logfile_out, "a")
else:
self.stdout = None
if self.details.logfile_err:
if self.details.logfile_err == self.details.logfile_out:
self.stderr = subprocess.STDOUT
else:
self.stderr = open(self.details.logfile_err, "a")
else:
self.stderr = None
[docs] def close_logs(self) -> None:
"""
Close Python disk logs.
"""
if self.stdout is not None:
self.stdout.close()
self.stdout = None
if self.stderr is not None and self.stderr != subprocess.STDOUT:
self.stderr.close()
self.stderr = None
# -------------------------------------------------------------------------
# Starting, stopping
# -------------------------------------------------------------------------
[docs] def start(self) -> None:
"""
Starts a subprocess. Optionally routes its output to our disk logs.
"""
if self.running:
return
self.info(
f"Starting: {self.details.procargs} (with logs "
f"stdout={self.details.logfile_out}, "
f"stderr={self.details.logfile_err})"
)
self.open_logs()
creationflags = CREATE_NEW_PROCESS_GROUP if WINDOWS else 0
# self.warning("creationflags: {}".format(creationflags))
self.process = subprocess.Popen(
self.details.procargs,
stdin=None,
stdout=self.stdout,
stderr=self.stderr,
creationflags=creationflags,
)
self.running = True
[docs] def stop(self) -> None:
"""
Stops a subprocess.
Asks nicely. Waits. Asks less nicely. Repeat until subprocess is dead.
.. todo::
cardinal_pythonlib.winservice.ProcessManager.stop: make
it reliable under Windows
"""
if not self.running:
return
try:
self.wait(timeout_s=0)
# If we get here: stopped already
except subprocess.TimeoutExpired: # still running
for kill_level in self.ALL_KILL_LEVELS:
tried_to_kill = self._terminate(
level=kill_level
) # please stop
if tried_to_kill:
try:
self.wait(timeout_s=self.kill_timeout_sec)
break
except subprocess.TimeoutExpired: # failed to close
self.warning("Subprocess didn't stop when asked")
pass # carry on escalating
self.close_logs()
self.running = False
def _terminate(self, level: int) -> bool:
"""
Returns: succeeded in *attempting* a kill?
"""
if not self.running:
return True
# Already closed by itself?
try:
self.wait(0)
return True
except subprocess.TimeoutExpired: # failed to close
pass
# SEE NOTES ABOVE. This is tricky under Windows.
suffix = f" [to child process {self.process.pid}]"
if level == self.KILL_LEVEL_CTRL_C_OR_SOFT_KILL:
if WINDOWS:
success = 0 != ctypes.windll.kernel32.GenerateConsoleCtrlEvent(
CTRL_C_EVENT, self.process.pid
)
if success:
self.info("Sent CTRL-C to request stop" + suffix)
# ... but that doesn't mean it'll stop...
else:
self.info("Failed to send CTRL-C" + suffix)
return success
else:
self.warning("Asking process to stop (SIGTERM)" + suffix)
self.process.terminate() # soft kill under POSIX
return True
elif level == self.KILL_LEVEL_CTRL_BREAK:
if not WINDOWS:
return False
success = 0 != ctypes.windll.kernel32.GenerateConsoleCtrlEvent(
CTRL_BREAK_EVENT, self.process.pid
)
if success:
self.info("Sent CTRL-BREAK to request stop" + suffix)
else:
self.info("Failed to send CTRL-BREAK" + suffix)
return success
elif level == self.KILL_LEVEL_TASKKILL:
if not WINDOWS:
return False
retcode = self._taskkill(force=False) # does its own info messages
# noinspection PyUnresolvedReferences
return retcode == winerror.ERROR_SUCCESS
elif level == self.KILL_LEVEL_TASKKILL_FORCE:
if not WINDOWS:
return False
retcode = self._taskkill(force=True) # does its own info messages
# noinspection PyUnresolvedReferences
return retcode == winerror.ERROR_SUCCESS
elif level == self.KILL_LEVEL_HARD_KILL:
# Last resort
self._kill() # may do TASKKILL /F or some other method
return True
else:
raise ValueError("Bad kill level requested")
def _taskkill(self, force: bool = False) -> int:
"""
Executes a Windows ``TASKKILL /pid PROCESS_ID /t`` command
(``/t`` for "tree kill" = "kill all children").
Args:
force: also add ``/f`` (forcefully)
Returns:
return code from ``TASKKILL``
**Test code:**
Firstly we need a program that won't let itself be killed. Save this as
``nokill.py``:
.. code-block:: python
#!/usr/bin/env python
import logging
import time
import os
from cardinal_pythonlib.logs import main_only_quicksetup_rootlogger
from cardinal_pythonlib.signalfunc import trap_ctrl_c_ctrl_break
main_only_quicksetup_rootlogger(level=logging.DEBUG)
trap_ctrl_c_ctrl_break()
while True:
print("Process ID is {}; time is {} s".format(os.getpid(), time.perf_counter()))
time.sleep(1)
Now run that with ``python nokill.py``. It should resist CTRL-C and
CTRL-BREAK. Start another command prompt in which to play with
``TASKKILL``.
.. code-block:: bat
REM Firstly, avoid this single-ampersand syntax:
REM taskkill /im notepad.exe & echo %errorlevel%
REM ... as it prints the WRONG (previous?) errorlevel.
notepad.exe
taskkill /im notepad.exe
echo %errorlevel%
REM ... 0 for success (Windows 10), e.g.
REM 'SUCCESS: Sent termination signal to the process "notepad.exe" with PID 6988.'
taskkill /im notepad.exe
echo %errorlevel%
REM ... 128 for "not found" (Windows 10), e.g.
REM 'ERROR: The process "notepad.exe" not found.'
REM Now run notepad.exe as Administrator
taskkill /im notepad.exe & echo %errorlevel%
REM ... 1 for "access denied" (Windows 10)
REM Now kill the nokill.py process by its PID (e.g. 11892 here):
taskkill /pid 11892
echo %errorlevel%
REM ... 1 for "not allowed" (Windows 10), e.g.
REM 'ERROR: The process with PID 11892 could not be terminated.'
REM 'Reason: This process can only be terminated forcefully (with /F option).'
REM Now forcefully:
taskkill /pid 11892 /f
echo %errorlevel%
REM ... 0 for success (Windows 10), e.g.
REM 'SUCCESS: The process with PID 11892 has been terminated.'
""" # noqa: E501
args = [
"taskkill", # built in to Windows XP and higher
"/pid",
str(self.process.pid),
"/t", # tree kill: kill all children
]
if force:
args.append("/f") # forcefully
callname = " ".join(args)
retcode = subprocess.call(args)
# https://stackoverflow.com/questions/18682681/what-are-exit-codes-from-the-taskkill-utility # noqa: E501
# noinspection PyUnresolvedReferences
if retcode == winerror.ERROR_SUCCESS: # 0
self.info("Killed with " + repr(callname))
elif retcode == winerror.ERROR_INVALID_FUNCTION: # 1
self.warning(
repr(callname)
+ " failed (error code 1 = ERROR_INVALID_FUNCTION; "
"can mean 'Access denied', or 'This process can only be "
"terminated forcefully (with /F option)')."
)
elif retcode == winerror.ERROR_WAIT_NO_CHILDREN: # 128
self.warning(
repr(callname)
+ " failed (error code 128 = ERROR_WAIT_NO_CHILDREN "
"= 'There are no child processes to wait for', but also "
"occurs when the process doesn't exist, and when processes "
"require a forceful [/F] termination)"
)
elif retcode == winerror.ERROR_EA_LIST_INCONSISTENT: # 255
self.warning(
repr(callname)
+ " failed (error code 255 = ERROR_EA_LIST_INCONSISTENT "
"= 'The extended attributes are inconsistent.')"
)
else:
self.warning(callname + " failed: error code {}".format(retcode))
return retcode
def _kill(self) -> None:
"""
Hard kill.
- PROBLEM: originally, via ``self.process.kill()``, could leave orphans
under Windows.
- SOLUTION: see
https://stackoverflow.com/questions/1230669/subprocess-deleting-child-processes-in-windows,
which uses ``psutil``.
"""
self.warning("Using a recursive hard kill; will assume it worked")
pid = self.process.pid
gone, still_alive = kill_proc_tree(
pid, including_parent=True, timeout_s=self.kill_timeout_sec
)
self.debug("Killed: {!r}".format(gone))
self.warning("Still alive: {!r}".format(still_alive))
[docs] def wait(self, timeout_s: float = None) -> int:
"""
Wait for up to ``timeout_s`` for the child process to finish.
Args:
timeout_s: maximum time to wait or ``None`` to wait forever
Returns:
process return code; or ``0`` if it wasn't running, or ``1`` if
it managed to exit without a return code
Raises:
subprocess.TimeoutExpired: if the process continues to run
"""
if not self.running:
return 0
retcode = self.process.wait(timeout=timeout_s)
# We won't get further unless the process has stopped.
if retcode is None:
self.error("Subprocess finished, but return code was None")
retcode = 1 # we're promising to return an int
elif retcode == 0:
self.info("Subprocess finished cleanly (return code 0).")
else:
self.error(
"Subprocess finished, but FAILED (return code {}). "
"Logs were: {} (stdout), {} (stderr)".format(
retcode, self.details.logfile_out, self.details.logfile_err
)
)
self.running = False
return retcode
# =============================================================================
# Windows service framework
# =============================================================================
[docs]class WindowsService(ServiceFramework):
"""
Class representing a Windows service.
Derived classes must set the following class properties:
.. code-block:: python
# you can NET START/STOP the service by the following name
_svc_name_ = "CRATE"
# this text shows up as the service name in the Service
# Control Manager (SCM)
_svc_display_name_ = "CRATE web service"
# this text shows up as the description in the SCM
_svc_description_ = "Runs Django/Celery processes for CRATE web site"
# how to launch?
_exe_name_ = sys.executable # python.exe in the virtualenv
_exe_args_ = '"{}"'.format(os.path.realpath(__file__)) # this script
"""
def __init__(self, args: List[Any] = None) -> None:
super().__init__(args)
failmsg = "Derived class improperly configured"
# noinspection PyUnresolvedReferences
assert self._svc_name_, failmsg
# noinspection PyUnresolvedReferences
assert self._svc_display_name_, failmsg
# noinspection PyUnresolvedReferences
assert self._svc_description_, failmsg
# noinspection PyUnresolvedReferences
assert self._exe_name_, failmsg
# noinspection PyUnresolvedReferences
assert self._exe_args_, failmsg
# create an event to listen for stop requests on
# noinspection PyUnresolvedReferences
self.h_stop_event = win32event.CreateEvent(None, 0, 0, None)
self.process_managers = [] # type: List[ProcessManager]
self.debugging = False
# -------------------------------------------------------------------------
# Logging
# -------------------------------------------------------------------------
[docs] def debug(self, msg: str) -> None:
"""
If we are being verbose, write a debug message to the Python log.
"""
if self.debugging:
log.debug(msg)
[docs] def info(self, msg: str) -> None:
"""
Write an info message to the Windows Application log
(± to the Python disk log).
"""
# noinspection PyUnresolvedReferences
servicemanager.LogInfoMsg(str(msg))
if self.debugging:
log.info(msg)
[docs] def error(self, msg: str) -> None:
"""
Write an error message to the Windows Application log
(± to the Python disk log).
"""
# noinspection PyUnresolvedReferences
servicemanager.LogErrorMsg(str(msg))
if self.debugging:
log.error(msg)
# -------------------------------------------------------------------------
# Windows service calls
# -------------------------------------------------------------------------
# noinspection PyPep8Naming
[docs] def SvcStop(self) -> None:
"""
Called when the service is being shut down.
"""
# tell the SCM we're shutting down
# noinspection PyUnresolvedReferences
self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
# fire the stop event
# noinspection PyUnresolvedReferences
win32event.SetEvent(self.h_stop_event)
# called when service is started
# noinspection PyPep8Naming
[docs] def SvcDoRun(self) -> None:
"""
Called when the service is started.
"""
# No need to self.ReportServiceStatus(win32service.SERVICE_RUNNING);
# that is done by the framework (see win32serviceutil.py).
# Similarly, no need to report a SERVICE_STOP_PENDING on exit.
# noinspection PyUnresolvedReferences
self.debug("Sending PYS_SERVICE_STARTED message")
# noinspection PyUnresolvedReferences
servicemanager.LogMsg(
servicemanager.EVENTLOG_INFORMATION_TYPE,
servicemanager.PYS_SERVICE_STARTED,
(self._svc_name_, ""),
)
# self.test_service() # test service
self.main() # real service
# noinspection PyUnresolvedReferences
servicemanager.LogMsg(
servicemanager.EVENTLOG_INFORMATION_TYPE,
servicemanager.PYS_SERVICE_STOPPED,
(self._svc_name_, ""),
)
# noinspection PyUnresolvedReferences
self.ReportServiceStatus(win32service.SERVICE_STOPPED)
# -------------------------------------------------------------------------
# Testing
# -------------------------------------------------------------------------
[docs] def test_service(
self, filename: str = TEST_FILENAME, period_ms: int = TEST_PERIOD_MS
) -> None:
"""
A test service.
Writes to a file occasionally, so you can see it's running.
Args:
filename: file to write data to periodically
period_ms: period, in milliseconds
"""
# A test service. This works! (As long as you can write to the file.)
def write(msg):
f.write(f"{pendulum.now()}: {msg}\n")
f.flush()
self.info(
"Starting test service; writing data periodically to "
"{}".format(TEST_FILENAME)
)
f = open(filename, "a") # open for append
write("STARTING")
retcode = None
# if the stop event hasn't been fired keep looping
# noinspection PyUnresolvedReferences
while retcode != win32event.WAIT_OBJECT_0:
write("Test data; will now wait {} ms".format(period_ms))
# block for a while seconds and listen for a stop event
# noinspection PyUnresolvedReferences
retcode = win32event.WaitForSingleObject(
self.h_stop_event, period_ms
)
write("SHUTTING DOWN")
f.close()
self.info("Test service FINISHED.")
[docs] def run_debug(self) -> None:
"""
Enable verbose mode and call :func:`main`.
"""
self.debugging = True
self.main()
# -------------------------------------------------------------------------
# Main service
# -------------------------------------------------------------------------
[docs] def main(self) -> None:
"""
Main entry point. Runs :func:`service`.
"""
# Actual main service code.
try:
self.service()
except Exception as e:
self.error(
"Unexpected exception: {e}\n{t}".format(
e=e, t=traceback.format_exc()
)
)
[docs] def service(self) -> None:
"""
Service function. Must be overridden by derived classes.
"""
raise NotImplementedError()
[docs] def run_processes(
self,
procdetails: List[ProcessDetails],
subproc_run_timeout_sec: float = 1,
stop_event_timeout_ms: int = 1000,
kill_timeout_sec: float = 5,
) -> None:
"""
Run multiple child processes.
Args:
procdetails: list of :class:`ProcessDetails` objects (q.v.)
subproc_run_timeout_sec: time (in seconds) to wait for each process
when polling child processes to see how they're getting on
(default ``1``)
stop_event_timeout_ms: time to wait (in ms) while checking the
Windows stop event for this service (default ``1000``)
kill_timeout_sec: how long (in seconds) will we wait for the
subprocesses to end peacefully, before we try to kill them?
.. todo::
cardinal_pythonlib.winservice.WindowsService: NOT YET IMPLEMENTED:
Windows service autorestart
"""
# https://stackoverflow.com/questions/16333054
def cleanup():
self.debug("atexit function called: cleaning up")
for pmgr_ in self.process_managers:
pmgr_.stop()
atexit.register(cleanup)
# Set up process info
self.process_managers = [] # type: List[ProcessManager]
n = len(procdetails)
for i, details in enumerate(procdetails):
pmgr = ProcessManager(
details,
i + 1,
n,
kill_timeout_sec=kill_timeout_sec,
debugging=self.debugging,
)
self.process_managers.append(pmgr)
# Start processes
for pmgr in self.process_managers:
pmgr.start()
self.info("All started")
# Run processes
something_running = True
stop_requested = False
subproc_failed = False
while something_running and not stop_requested and not subproc_failed:
# noinspection PyUnresolvedReferences
if (
win32event.WaitForSingleObject(
self.h_stop_event, stop_event_timeout_ms
)
== win32event.WAIT_OBJECT_0
):
stop_requested = True
self.info("Stop requested; stopping")
else:
something_running = False
for pmgr in self.process_managers:
if subproc_failed:
break
try:
retcode = pmgr.wait(timeout_s=subproc_run_timeout_sec)
if retcode != 0:
subproc_failed = True
except subprocess.TimeoutExpired:
something_running = True
# Kill any outstanding processes
#
# (a) Slow way
# for pmgr in self.process_managers:
# pmgr.stop()
#
# (b) Faster (slightly more parallel) way
# for pmgr in self.process_managers:
# pmgr.terminate()
# for pmgr in self.process_managers:
# pmgr.stop_having_terminated()
#
# ... No, it's bad if we leave things orphaned.
# Let's go for slow, clean code.
for pmgr in self.process_managers:
pmgr.stop()
self.info("All stopped")
# =============================================================================
# Main
# =============================================================================
[docs]def generic_service_main(cls: Type[WindowsService], name: str) -> None:
"""
Call this from your command-line entry point to manage a service.
- Via inherited functions, enables you to ``install``, ``update``,
``remove``, ``start``, ``stop``, and ``restart`` the service.
- Via our additional code, allows you to run the service function directly
from the command line in debug mode, using the ``debug`` command.
- Run with an invalid command like ``help`` to see help (!).
See
https://mail.python.org/pipermail/python-win32/2008-April/007299.html
Args:
cls: class deriving from :class:`WindowsService`
name: name of this service
"""
argc = len(sys.argv)
if argc == 1:
# noinspection PyUnresolvedReferences
try:
print("Trying to start service directly...")
evtsrc_dll = os.path.abspath(servicemanager.__file__)
# noinspection PyUnresolvedReferences
servicemanager.PrepareToHostSingle(cls) # <-- sets up the service
# noinspection PyUnresolvedReferences
servicemanager.Initialize(name, evtsrc_dll)
# noinspection PyUnresolvedReferences
servicemanager.StartServiceCtrlDispatcher()
# noinspection PyUnresolvedReferences
except win32service.error as details:
print("Failed: {}".format(details))
# print(repr(details.__dict__))
errnum = details.winerror
# noinspection PyUnresolvedReferences
if errnum == winerror.ERROR_FAILED_SERVICE_CONTROLLER_CONNECT:
# noinspection PyUnresolvedReferences
win32serviceutil.usage()
elif argc == 2 and sys.argv[1] == "debug":
s = cls()
s.run_debug()
else:
# noinspection PyUnresolvedReferences
win32serviceutil.HandleCommandLine(cls)