Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Local Preview Server Helpers
|
||||
|
||||
Shared per-project mutual-exclusion (lock) and liveness helpers for the local
|
||||
Flask preview servers (`svg_editor/server.py`, `confirm_ui/server.py`). Each
|
||||
server keeps its own lock filename and Flask app; this module owns only the
|
||||
cross-platform process-liveness check and the claim/read/release lock logic so
|
||||
the two servers cannot drift apart.
|
||||
|
||||
Usage:
|
||||
from server_common import find_free_port, validate_port
|
||||
|
||||
Dependencies:
|
||||
None (only uses standard library)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from workflow_transcript import DISABLE_TRANSCRIPT_ENV
|
||||
|
||||
|
||||
MIN_PORT = 1
|
||||
MAX_PORT = 65535
|
||||
|
||||
|
||||
def validate_port(port: int) -> int:
|
||||
"""Return a valid TCP port, raising ``ValueError`` outside 1..65535."""
|
||||
if isinstance(port, bool) or not isinstance(port, int):
|
||||
raise ValueError('port must be an integer between 1 and 65535')
|
||||
if not MIN_PORT <= port <= MAX_PORT:
|
||||
raise ValueError(f'port must be between {MIN_PORT} and {MAX_PORT}: {port}')
|
||||
return port
|
||||
|
||||
|
||||
def find_free_port(preferred: int, host: str = '127.0.0.1', span: int = 50) -> int:
|
||||
"""Return the first bindable port from ``preferred`` through its scan span.
|
||||
|
||||
The scan remains sequential so callers can keep 5050 as their preferred
|
||||
port and advance predictably when it is occupied. Invalid ports fail before
|
||||
probing, and an exhausted valid range raises ``RuntimeError`` instead of
|
||||
returning a port already known to be unavailable.
|
||||
"""
|
||||
preferred = validate_port(preferred)
|
||||
if isinstance(span, bool) or not isinstance(span, int) or span <= 0:
|
||||
raise ValueError(f'span must be a positive integer: {span}')
|
||||
|
||||
last_port = min(preferred + span - 1, MAX_PORT)
|
||||
for port in range(preferred, last_port + 1):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
||||
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
probe.bind((host, port))
|
||||
return port
|
||||
except OSError:
|
||||
continue
|
||||
raise RuntimeError(
|
||||
f'no free TCP port on {host} in range {preferred}..{last_port}'
|
||||
)
|
||||
|
||||
|
||||
def popen_detached(
|
||||
args: list[str],
|
||||
*,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
**kwargs: object,
|
||||
) -> subprocess.Popen:
|
||||
"""Start a long-running child process detached from the caller.
|
||||
|
||||
Windows hosts such as terminal sandboxes may place child processes in the
|
||||
caller's Job Object. ``CREATE_BREAKAWAY_FROM_JOB`` lets the local UI server
|
||||
survive after the launcher command returns; when that flag is forbidden, the
|
||||
function falls back to the previous detached-process flags.
|
||||
|
||||
Detached service output remains in its component log, so the child receives
|
||||
the shared workflow-transcript opt-out environment flag.
|
||||
"""
|
||||
supplied_env = kwargs.get('env')
|
||||
child_env = dict(os.environ if supplied_env is None else supplied_env)
|
||||
child_env[DISABLE_TRANSCRIPT_ENV] = '1'
|
||||
kwargs['env'] = child_env
|
||||
|
||||
if os.name != 'nt':
|
||||
return subprocess.Popen(args, start_new_session=True, **kwargs)
|
||||
|
||||
base_flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
||||
breakaway_flag = getattr(subprocess, 'CREATE_BREAKAWAY_FROM_JOB', 0x01000000)
|
||||
try:
|
||||
return subprocess.Popen(
|
||||
args,
|
||||
creationflags=base_flags | breakaway_flag,
|
||||
**kwargs,
|
||||
)
|
||||
except OSError as exc:
|
||||
if logger is not None:
|
||||
logger.warning(
|
||||
'Windows process breakaway failed; falling back to detached '
|
||||
'process-group launch (%s)',
|
||||
exc,
|
||||
)
|
||||
return subprocess.Popen(args, creationflags=base_flags, **kwargs)
|
||||
|
||||
|
||||
def process_alive(pid: object) -> bool:
|
||||
"""Return True if a process with this pid is reachable.
|
||||
|
||||
On POSIX, ``os.kill(pid, 0)`` succeeds when the process exists even without
|
||||
permission to signal it; ``PermissionError`` therefore still counts as
|
||||
alive. On Windows there is no ``os.kill(pid, 0)`` equivalent, so probe via
|
||||
``OpenProcess`` + ``WaitForSingleObject``.
|
||||
"""
|
||||
try:
|
||||
pid_int = int(pid)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if pid_int <= 0:
|
||||
return False
|
||||
if os.name == 'nt':
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
|
||||
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
kernel32.OpenProcess.argtypes = [
|
||||
ctypes.wintypes.DWORD,
|
||||
ctypes.wintypes.BOOL,
|
||||
ctypes.wintypes.DWORD,
|
||||
]
|
||||
kernel32.OpenProcess.restype = ctypes.wintypes.HANDLE
|
||||
kernel32.WaitForSingleObject.argtypes = [
|
||||
ctypes.wintypes.HANDLE,
|
||||
ctypes.wintypes.DWORD,
|
||||
]
|
||||
kernel32.WaitForSingleObject.restype = ctypes.wintypes.DWORD
|
||||
kernel32.CloseHandle.argtypes = [ctypes.wintypes.HANDLE]
|
||||
kernel32.CloseHandle.restype = ctypes.wintypes.BOOL
|
||||
|
||||
process_query_limited_information = 0x1000
|
||||
synchronize = 0x00100000
|
||||
wait_timeout = 0x00000102
|
||||
wait_object_0 = 0x00000000
|
||||
wait_failed = 0xFFFFFFFF
|
||||
|
||||
handle = kernel32.OpenProcess(
|
||||
process_query_limited_information | synchronize,
|
||||
False,
|
||||
pid_int,
|
||||
)
|
||||
if not handle:
|
||||
return ctypes.get_last_error() == 5 # ERROR_ACCESS_DENIED
|
||||
try:
|
||||
result = kernel32.WaitForSingleObject(handle, 0)
|
||||
if result == wait_timeout:
|
||||
return True
|
||||
if result in (wait_object_0, wait_failed):
|
||||
return False
|
||||
return False
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
try:
|
||||
os.kill(pid_int, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def read_lock(lock_file: Path) -> Optional[dict]:
|
||||
"""Read a lock file, returning the lock dict or None if absent/corrupt."""
|
||||
try:
|
||||
data = json.loads(lock_file.read_text(encoding='utf-8'))
|
||||
return data if isinstance(data, dict) else None
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def lock_pid(lock: Optional[dict]) -> int:
|
||||
"""Return a valid pid from a lock dict, or 0 if absent/corrupt."""
|
||||
if not lock:
|
||||
return 0
|
||||
raw_pid = lock.get('pid', 0)
|
||||
if isinstance(raw_pid, bool):
|
||||
return 0
|
||||
if isinstance(raw_pid, int):
|
||||
return raw_pid if raw_pid > 0 else 0
|
||||
if isinstance(raw_pid, str) and raw_pid.strip().isdigit():
|
||||
return int(raw_pid.strip())
|
||||
return 0
|
||||
|
||||
|
||||
def claim_lock(lock_file: Path, port: int) -> Optional[dict]:
|
||||
"""Try to claim the per-project preview slot.
|
||||
|
||||
Returns ``None`` on success. If another live process already holds the
|
||||
slot, returns the existing lock dict (caller surfaces it as an error).
|
||||
A stale lock (pointing at a dead pid) is silently overwritten.
|
||||
"""
|
||||
existing = read_lock(lock_file)
|
||||
if existing and process_alive(lock_pid(existing)):
|
||||
return existing
|
||||
lock_file.write_text(
|
||||
json.dumps({'pid': os.getpid(), 'port': port}),
|
||||
encoding='utf-8',
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def release_lock(lock_file: Path) -> None:
|
||||
"""Best-effort cleanup: only delete the lock if it still names *us*."""
|
||||
try:
|
||||
current = read_lock(lock_file)
|
||||
if lock_pid(current) == os.getpid():
|
||||
lock_file.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def clear_lock(lock_file: Path) -> None:
|
||||
"""Best-effort cleanup for a lock already proven stale by the caller."""
|
||||
try:
|
||||
lock_file.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
Reference in New Issue
Block a user