"""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())