| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - Local Preview Server Helpers |
| 4 | |
| 5 | Shared per-project mutual-exclusion (lock) and liveness helpers for the local |
| 6 | Flask preview servers (`svg_editor/server.py`, `confirm_ui/server.py`). Each |
| 7 | server keeps its own lock filename and Flask app; this module owns only the |
| 8 | cross-platform process-liveness check and the claim/read/release lock logic so |
| 9 | the two servers cannot drift apart. |
| 10 | |
| 11 | Usage: |
| 12 | from server_common import find_free_port, validate_port |
| 13 | |
| 14 | Dependencies: |
| 15 | None (only uses standard library) |
| 16 | """ |
| 17 | |
| 18 | import json |
| 19 | import logging |
| 20 | import os |
| 21 | import socket |
| 22 | import subprocess |
| 23 | from pathlib import Path |
| 24 | from typing import Optional |
| 25 | |
| 26 | from workflow_transcript import DISABLE_TRANSCRIPT_ENV |
| 27 | |
| 28 | |
| 29 | MIN_PORT = 1 |
| 30 | MAX_PORT = 65535 |
| 31 | |
| 32 | |
| 33 | def validate_port(port: int) -> int: |
| 34 | """Return a valid TCP port, raising ``ValueError`` outside 1..65535.""" |
| 35 | if isinstance(port, bool) or not isinstance(port, int): |
| 36 | raise ValueError('port must be an integer between 1 and 65535') |
| 37 | if not MIN_PORT <= port <= MAX_PORT: |
| 38 | raise ValueError(f'port must be between {MIN_PORT} and {MAX_PORT}: {port}') |
| 39 | return port |
| 40 | |
| 41 | |
| 42 | def find_free_port(preferred: int, host: str = '127.0.0.1', span: int = 50) -> int: |
| 43 | """Return the first bindable port from ``preferred`` through its scan span. |
| 44 | |
| 45 | The scan remains sequential so callers can keep 5050 as their preferred |
| 46 | port and advance predictably when it is occupied. Invalid ports fail before |
| 47 | probing, and an exhausted valid range raises ``RuntimeError`` instead of |
| 48 | returning a port already known to be unavailable. |
| 49 | """ |
| 50 | preferred = validate_port(preferred) |
| 51 | if isinstance(span, bool) or not isinstance(span, int) or span <= 0: |
| 52 | raise ValueError(f'span must be a positive integer: {span}') |
| 53 | |
| 54 | last_port = min(preferred + span - 1, MAX_PORT) |
| 55 | for port in range(preferred, last_port + 1): |
| 56 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: |
| 57 | probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) |
| 58 | try: |
| 59 | probe.bind((host, port)) |
| 60 | return port |
| 61 | except OSError: |
| 62 | continue |
| 63 | raise RuntimeError( |
| 64 | f'no free TCP port on {host} in range {preferred}..{last_port}' |
| 65 | ) |
| 66 | |
| 67 | |
| 68 | def popen_detached( |
| 69 | args: list[str], |
| 70 | *, |
| 71 | logger: Optional[logging.Logger] = None, |
| 72 | **kwargs: object, |
| 73 | ) -> subprocess.Popen: |
| 74 | """Start a long-running child process detached from the caller. |
| 75 | |
| 76 | Windows hosts such as terminal sandboxes may place child processes in the |
| 77 | caller's Job Object. ``CREATE_BREAKAWAY_FROM_JOB`` lets the local UI server |
| 78 | survive after the launcher command returns; when that flag is forbidden, the |
| 79 | function falls back to the previous detached-process flags. |
| 80 | |
| 81 | Detached service output remains in its component log, so the child receives |
| 82 | the shared workflow-transcript opt-out environment flag. |
| 83 | """ |
| 84 | supplied_env = kwargs.get('env') |
| 85 | child_env = dict(os.environ if supplied_env is None else supplied_env) |
| 86 | child_env[DISABLE_TRANSCRIPT_ENV] = '1' |
| 87 | kwargs['env'] = child_env |
| 88 | |
| 89 | if os.name != 'nt': |
| 90 | return subprocess.Popen(args, start_new_session=True, **kwargs) |
| 91 | |
| 92 | base_flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS |
| 93 | breakaway_flag = getattr(subprocess, 'CREATE_BREAKAWAY_FROM_JOB', 0x01000000) |
| 94 | try: |
| 95 | return subprocess.Popen( |
| 96 | args, |
| 97 | creationflags=base_flags | breakaway_flag, |
| 98 | **kwargs, |
| 99 | ) |
| 100 | except OSError as exc: |
| 101 | if logger is not None: |
| 102 | logger.warning( |
| 103 | 'Windows process breakaway failed; falling back to detached ' |
| 104 | 'process-group launch (%s)', |
| 105 | exc, |
| 106 | ) |
| 107 | return subprocess.Popen(args, creationflags=base_flags, **kwargs) |
| 108 | |
| 109 | |
| 110 | def process_alive(pid: object) -> bool: |
| 111 | """Return True if a process with this pid is reachable. |
| 112 | |
| 113 | On POSIX, ``os.kill(pid, 0)`` succeeds when the process exists even without |
| 114 | permission to signal it; ``PermissionError`` therefore still counts as |
| 115 | alive. On Windows there is no ``os.kill(pid, 0)`` equivalent, so probe via |
| 116 | ``OpenProcess`` + ``WaitForSingleObject``. |
| 117 | """ |
| 118 | try: |
| 119 | pid_int = int(pid) |
| 120 | except (TypeError, ValueError): |
| 121 | return False |
| 122 | if pid_int <= 0: |
| 123 | return False |
| 124 | if os.name == 'nt': |
| 125 | import ctypes |
| 126 | import ctypes.wintypes |
| 127 | |
| 128 | kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) |
| 129 | kernel32.OpenProcess.argtypes = [ |
| 130 | ctypes.wintypes.DWORD, |
| 131 | ctypes.wintypes.BOOL, |
| 132 | ctypes.wintypes.DWORD, |
| 133 | ] |
| 134 | kernel32.OpenProcess.restype = ctypes.wintypes.HANDLE |
| 135 | kernel32.WaitForSingleObject.argtypes = [ |
| 136 | ctypes.wintypes.HANDLE, |
| 137 | ctypes.wintypes.DWORD, |
| 138 | ] |
| 139 | kernel32.WaitForSingleObject.restype = ctypes.wintypes.DWORD |
| 140 | kernel32.CloseHandle.argtypes = [ctypes.wintypes.HANDLE] |
| 141 | kernel32.CloseHandle.restype = ctypes.wintypes.BOOL |
| 142 | |
| 143 | process_query_limited_information = 0x1000 |
| 144 | synchronize = 0x00100000 |
| 145 | wait_timeout = 0x00000102 |
| 146 | wait_object_0 = 0x00000000 |
| 147 | wait_failed = 0xFFFFFFFF |
| 148 | |
| 149 | handle = kernel32.OpenProcess( |
| 150 | process_query_limited_information | synchronize, |
| 151 | False, |
| 152 | pid_int, |
| 153 | ) |
| 154 | if not handle: |
| 155 | return ctypes.get_last_error() == 5 # ERROR_ACCESS_DENIED |
| 156 | try: |
| 157 | result = kernel32.WaitForSingleObject(handle, 0) |
| 158 | if result == wait_timeout: |
| 159 | return True |
| 160 | if result in (wait_object_0, wait_failed): |
| 161 | return False |
| 162 | return False |
| 163 | finally: |
| 164 | kernel32.CloseHandle(handle) |
| 165 | |
| 166 | try: |
| 167 | os.kill(pid_int, 0) |
| 168 | except ProcessLookupError: |
| 169 | return False |
| 170 | except PermissionError: |
| 171 | return True |
| 172 | except OSError: |
| 173 | return False |
| 174 | return True |
| 175 | |
| 176 | |
| 177 | def read_lock(lock_file: Path) -> Optional[dict]: |
| 178 | """Read a lock file, returning the lock dict or None if absent/corrupt.""" |
| 179 | try: |
| 180 | data = json.loads(lock_file.read_text(encoding='utf-8')) |
| 181 | return data if isinstance(data, dict) else None |
| 182 | except (OSError, json.JSONDecodeError): |
| 183 | return None |
| 184 | |
| 185 | |
| 186 | def lock_pid(lock: Optional[dict]) -> int: |
| 187 | """Return a valid pid from a lock dict, or 0 if absent/corrupt.""" |
| 188 | if not lock: |
| 189 | return 0 |
| 190 | raw_pid = lock.get('pid', 0) |
| 191 | if isinstance(raw_pid, bool): |
| 192 | return 0 |
| 193 | if isinstance(raw_pid, int): |
| 194 | return raw_pid if raw_pid > 0 else 0 |
| 195 | if isinstance(raw_pid, str) and raw_pid.strip().isdigit(): |
| 196 | return int(raw_pid.strip()) |
| 197 | return 0 |
| 198 | |
| 199 | |
| 200 | def claim_lock(lock_file: Path, port: int) -> Optional[dict]: |
| 201 | """Try to claim the per-project preview slot. |
| 202 | |
| 203 | Returns ``None`` on success. If another live process already holds the |
| 204 | slot, returns the existing lock dict (caller surfaces it as an error). |
| 205 | A stale lock (pointing at a dead pid) is silently overwritten. |
| 206 | """ |
| 207 | existing = read_lock(lock_file) |
| 208 | if existing and process_alive(lock_pid(existing)): |
| 209 | return existing |
| 210 | lock_file.write_text( |
| 211 | json.dumps({'pid': os.getpid(), 'port': port}), |
| 212 | encoding='utf-8', |
| 213 | ) |
| 214 | return None |
| 215 | |
| 216 | |
| 217 | def release_lock(lock_file: Path) -> None: |
| 218 | """Best-effort cleanup: only delete the lock if it still names *us*.""" |
| 219 | try: |
| 220 | current = read_lock(lock_file) |
| 221 | if lock_pid(current) == os.getpid(): |
| 222 | lock_file.unlink(missing_ok=True) |
| 223 | except OSError: |
| 224 | pass |
| 225 | |
| 226 | |
| 227 | def clear_lock(lock_file: Path) -> None: |
| 228 | """Best-effort cleanup for a lock already proven stale by the caller.""" |
| 229 | try: |
| 230 | lock_file.unlink(missing_ok=True) |
| 231 | except OSError: |
| 232 | pass |
| 233 |