返回 ppt-master
server_common.py
根目录 / skills / ppt-master / scripts / server_common.py
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
233 lines PYTHON