Release standalone PPT template workflow 0.5.0

This commit is contained in:
2026-10-10 21:02:47 +08:00
commit 4e3271ff62
303 changed files with 190946 additions and 0 deletions
+232
View File
@@ -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