218 lines
9.8 KiB
Python
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())
|