Files
Stat-Dashboard/start_dashboard.py

218 lines
9.8 KiB
Python

"""Windows development launcher: this terminal owns the backend and local DB.
Double-click start-dashboard.bat, or run python start_dashboard.py.
Ctrl+C stops gracefully. A Windows Job Object also stops child processes if the
terminal is closed abruptly. DATABASE_URL servers are external and never stopped.
"""
import argparse
import ctypes
from ctypes import wintypes
import os
from pathlib import Path
import signal
import socket
import subprocess
import sys
import threading
import time
from urllib.request import urlopen
import webbrowser
ROOT = Path(__file__).resolve().parent
LOCAL = ROOT / '.local'
PG_CTL = LOCAL / 'pgsql' / 'bin' / 'pg_ctl.exe'
PG_DATA = LOCAL / 'pgdata'
PYTHON = ROOT / '.venv' / 'Scripts' / 'python.exe'
class TerminalJob:
"""Include this process so all subsequently created children inherit the job."""
def __init__(self):
kernel = ctypes.WinDLL('kernel32', use_last_error=True)
class BasicLimits(ctypes.Structure):
_fields_ = [('process_time', ctypes.c_longlong), ('job_time', ctypes.c_longlong),
('flags', wintypes.DWORD), ('minimum_working_set', ctypes.c_size_t),
('maximum_working_set', ctypes.c_size_t), ('active_processes', wintypes.DWORD),
('affinity', ctypes.c_size_t), ('priority', wintypes.DWORD), ('scheduling', wintypes.DWORD)]
class IoCounters(ctypes.Structure):
_fields_ = [(name, ctypes.c_ulonglong) for name in
('reads', 'writes', 'other', 'read_bytes', 'write_bytes', 'other_bytes')]
class ExtendedLimits(ctypes.Structure):
_fields_ = [('basic', BasicLimits), ('io', IoCounters),
('process_memory', ctypes.c_size_t), ('job_memory', ctypes.c_size_t),
('peak_process_memory', ctypes.c_size_t), ('peak_job_memory', ctypes.c_size_t)]
kernel.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
kernel.CreateJobObjectW.restype = wintypes.HANDLE
kernel.SetInformationJobObject.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD]
kernel.SetInformationJobObject.restype = wintypes.BOOL
kernel.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
kernel.AssignProcessToJobObject.restype = wintypes.BOOL
kernel.GetCurrentProcess.restype = wintypes.HANDLE
self.handle = kernel.CreateJobObjectW(None, None)
if not self.handle:
raise ctypes.WinError(ctypes.get_last_error())
limits = ExtendedLimits()
limits.basic.flags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
if not kernel.SetInformationJobObject(self.handle, 9, ctypes.byref(limits), ctypes.sizeof(limits)):
raise ctypes.WinError(ctypes.get_last_error())
if not kernel.AssignProcessToJobObject(self.handle, kernel.GetCurrentProcess()):
raise ctypes.WinError(ctypes.get_last_error())
# Keep this non-inheritable handle open until process exit. Closing it
# explicitly would terminate the launcher itself along with its children.
self.kernel = kernel
class Dashboard:
def __init__(self, no_browser=False):
self.stop = threading.Event()
self.finished = threading.Event()
self.backend = None
self.local_db = False
self.no_browser = no_browser
self.port = int(os.environ.get('MOCKUP_PORT', '8011'))
self.url = f'http://127.0.0.1:{self.port}/mockups/overview-mockup.html'
self.job = None
def install_shutdown(self):
self.job = TerminalJob()
callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.DWORD)
def console_event(event):
if event not in (0, 1, 2, 5, 6):
return False
self.stop.set()
if event in (2, 5, 6):
self.finished.wait(4)
return True
self.console_handler = callback_type(console_event)
self.job.kernel.SetConsoleCtrlHandler.argtypes = [callback_type, wintypes.BOOL]
self.job.kernel.SetConsoleCtrlHandler.restype = wintypes.BOOL
if not self.job.kernel.SetConsoleCtrlHandler(self.console_handler, True):
raise ctypes.WinError(ctypes.get_last_error())
signal.signal(signal.SIGINT, lambda *_: self.stop.set())
signal.signal(signal.SIGTERM, lambda *_: self.stop.set())
def pg(self, *args, timeout=35, quiet=False):
return subprocess.run([str(PG_CTL), *args, '-D', str(PG_DATA)], cwd=ROOT,
creationflags=subprocess.CREATE_NO_WINDOW, timeout=timeout,
stdout=subprocess.DEVNULL if quiet else None,
stderr=subprocess.DEVNULL if quiet else None)
def start(self):
import msvcrt
LOCAL.mkdir(exist_ok=True)
self.lock_file = (LOCAL/'dashboard.lock').open('a+b')
self.lock_file.write(b'0')
self.lock_file.flush()
self.lock_file.seek(0)
try:
msvcrt.locking(self.lock_file.fileno(), msvcrt.LK_NBLCK, 1)
except OSError:
raise RuntimeError('This dashboard launcher is already running. Use its terminal window.') from None
if not 1 <= self.port <= 65535:
raise RuntimeError('MOCKUP_PORT must be between 1 and 65535.')
with socket.socket() as probe:
try:
probe.bind(('127.0.0.1', self.port))
except OSError:
raise RuntimeError(f'Port {self.port} is already in use. Close the existing backend terminal first.') from None
self.install_shutdown()
external_db = bool(os.environ.get('DATABASE_URL'))
needs_setup = not PYTHON.exists() or (not external_db and
(not PG_CTL.exists() or not (PG_DATA/'PG_VERSION').exists() or not (LOCAL/'database.json').exists()))
if needs_setup:
print('Preparing the development environment (first run may download PostgreSQL)...', flush=True)
self.local_db = not external_db
subprocess.run(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
str(ROOT/'mockups'/'setup-database.ps1')], cwd=ROOT, check=True)
if self.stop.is_set():
return
if not external_db:
self.local_db = True
if self.pg('status', quiet=True).returncode == 0:
print('Restarting the local sample database under this terminal...', flush=True)
self.pg('stop', '-m', 'fast', '-w', '-t', '15', timeout=20).check_returncode()
self.pg('start', '-l', str(LOCAL/'postgres.log'), '-o', '-h 127.0.0.1 -p 55432',
'-w', '-t', '30').check_returncode()
else:
print('Using DATABASE_URL; the external database is managed separately.', flush=True)
if self.stop.is_set():
return
subprocess.run([str(PYTHON), str(ROOT/'mockups'/'manage_database.py'), 'init'], cwd=ROOT, check=True)
print('\nStarting backend. Close this terminal or press Ctrl+C to stop everything.\n', flush=True)
self.backend = subprocess.Popen([str(PYTHON), '-u', str(ROOT/'mockups'/'mockup_server.py')],
cwd=ROOT, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
deadline = time.monotonic()+30
while not self.stop.is_set():
if self.backend.poll() is not None:
raise RuntimeError(f'Backend exited with code {self.backend.returncode}.')
try:
with urlopen(f'http://127.0.0.1:{self.port}/api/games', timeout=1) as response:
if response.status == 200:
break
except OSError:
pass
if time.monotonic()>deadline:
raise RuntimeError('Backend did not become ready within 30 seconds.')
self.stop.wait(.25)
if self.stop.is_set():
return
print(f'Ready: {self.url}', flush=True)
if not self.no_browser:
try:
webbrowser.open(self.url)
except Exception as error:
print(f'Open the URL above in your browser ({error}).', flush=True)
while not self.stop.wait(.25):
if self.backend.poll() is not None:
raise RuntimeError(f'Backend exited with code {self.backend.returncode}.')
def shutdown(self):
print('\nStopping backend and local PostgreSQL...', flush=True)
try:
if self.backend and self.backend.poll() is None:
self.backend.terminate()
self.backend.wait(timeout=1)
except (OSError, subprocess.TimeoutExpired):
if self.backend and self.backend.poll() is None:
self.backend.kill()
try:
if self.local_db and PG_CTL.exists():
self.pg('stop', '-m', 'fast', '-w', '-t', '2', timeout=3, quiet=True)
except (OSError, subprocess.TimeoutExpired):
# Closing the launcher still releases its Job Object and kills all
# remaining descendants, including PostgreSQL's background processes.
pass
finally:
self.finished.set()
print('Stopped.', flush=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--no-browser', action='store_true', help='Do not open the dashboard in a browser.')
args = parser.parse_args()
if os.name != 'nt':
parser.error('This launcher currently supports Windows.')
os.chdir(ROOT)
app = Dashboard(args.no_browser)
try:
app.start()
return 0
except KeyboardInterrupt:
return 0
except Exception as error:
print(f'\nStartup error: {error}', file=sys.stderr, flush=True)
return 1
finally:
app.shutdown()
if __name__ == '__main__':
sys.exit(main())