Skip to content

Commit 3072230

Browse files
Merge issue-161-screenshot-runtime: Coordinate CI screenshot runtime ownership (#161)
2 parents 086412d + b8d17b0 commit 3072230

4 files changed

Lines changed: 567 additions & 24 deletions

File tree

.github/workflows/ci.yml

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -845,33 +845,18 @@ jobs:
845845
if: needs.classification.outputs.screenshots_mode == 'rerun'
846846
working-directory: .tmp/ci-controller
847847
run: uv run --frozen playwright install --with-deps chromium
848-
- name: Migrate the synthetic SQLite database
849-
if: needs.classification.outputs.screenshots_mode == 'rerun'
850-
run: uv run --frozen python manage.py migrate --noinput
851-
- name: Start the release application for local capture
848+
- name: Migrate and capture with one owned SQLite runtime
852849
if: needs.classification.outputs.screenshots_mode == 'rerun'
853850
run: |
854851
set -euo pipefail
855852
mkdir -p ../ci-controller/.tmp/components-screenshots
856-
uv run --frozen python manage.py runserver 127.0.0.1:8000 --noreload \
857-
> ../ci-controller/.tmp/components-screenshots/server.log 2>&1 &
858-
server_pid=$!
859-
trap 'kill "$server_pid" 2>/dev/null || true' EXIT
860-
for attempt in $(seq 1 60); do
861-
if curl --fail --silent http://127.0.0.1:8000/health/live >/dev/null; then
862-
break
863-
fi
864-
if [[ "$attempt" == "60" ]]; then
865-
cat ../ci-controller/.tmp/components-screenshots/server.log >&2
866-
exit 1
867-
fi
868-
sleep 1
869-
done
870-
cd ../ci-controller
871-
uv run --frozen python -m ci.screenshot_capture \
872-
--plan .tmp/verification-input/verification-plan.json \
853+
uv run --frozen python -m ci.screenshot_runtime \
854+
--repository . \
855+
--controller-repository ../ci-controller \
856+
--plan ../ci-controller/.tmp/verification-input/verification-plan.json \
873857
--base-url http://127.0.0.1:8000 \
874-
--output .tmp/components-screenshots
858+
--output ../ci-controller/.tmp/components-screenshots \
859+
--server-log ../ci-controller/.tmp/components-screenshots/server.log
875860
- name: Record screenshot evidence with the job's actual environment
876861
if: >-
877862
always() &&

ci/screenshot_runtime.py

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
"""Keep the owned SQLite runtime alive while CI captures screenshots.
2+
3+
The release application's test settings deliberately own an ephemeral SQLite
4+
runtime per Python process. CI needs migration, the local server, and the
5+
controller-side browser capture to share that runtime, so this module is the
6+
long-lived owner and supervises each child process.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import argparse
12+
import os
13+
import signal
14+
import subprocess
15+
import sys
16+
import time
17+
import urllib.error
18+
import urllib.request
19+
from collections.abc import Sequence
20+
from pathlib import Path
21+
from typing import NoReturn
22+
from urllib.parse import urlsplit
23+
24+
from test_support import runtime as test_runtime
25+
from test_support.runtime import TestRuntime, TestRuntimeSafetyError
26+
27+
DEFAULT_HOST = "127.0.0.1"
28+
DEFAULT_PORT = 8000
29+
DEFAULT_HEALTH_PATH = "/health/live"
30+
DEFAULT_HEALTH_ATTEMPTS = 60
31+
DEFAULT_HEALTH_INTERVAL_SECONDS = 1.0
32+
DEFAULT_HTTP_TIMEOUT_SECONDS = 1.0
33+
34+
35+
class ScreenshotRuntimeError(RuntimeError):
36+
"""The screenshot process boundary could not be established safely."""
37+
38+
39+
def _terminate_process(
40+
process: subprocess.Popen[str] | None,
41+
*,
42+
grace_seconds: float = 5.0,
43+
) -> None:
44+
if process is None or process.poll() is not None:
45+
return
46+
try:
47+
os.killpg(process.pid, signal.SIGTERM)
48+
except ProcessLookupError:
49+
return
50+
try:
51+
process.wait(timeout=grace_seconds)
52+
except subprocess.TimeoutExpired:
53+
try:
54+
os.killpg(process.pid, signal.SIGKILL)
55+
except ProcessLookupError:
56+
return
57+
process.wait()
58+
59+
60+
def _terminate_processes(*processes: subprocess.Popen[str] | None) -> None:
61+
for process in processes:
62+
_terminate_process(process)
63+
64+
65+
def _kill_process_groups(*processes: subprocess.Popen[str] | None) -> None:
66+
"""Stop child groups without waiting on a possibly interrupted Popen wait."""
67+
68+
for process in processes:
69+
if process is None:
70+
continue
71+
try:
72+
os.killpg(process.pid, signal.SIGTERM)
73+
except ProcessLookupError:
74+
continue
75+
try:
76+
os.killpg(process.pid, signal.SIGKILL)
77+
except ProcessLookupError:
78+
pass
79+
80+
81+
def _server_is_ready(url: str) -> bool:
82+
try:
83+
with urllib.request.urlopen(url, timeout=DEFAULT_HTTP_TIMEOUT_SECONDS) as response:
84+
return 200 <= response.status < 300
85+
except (OSError, urllib.error.URLError):
86+
return False
87+
88+
89+
def _validate_local_endpoint(*, base_url: str, host: str, port: int) -> None:
90+
parsed = urlsplit(base_url)
91+
if (
92+
parsed.scheme != "http"
93+
or parsed.hostname not in {"127.0.0.1", "localhost"}
94+
or parsed.username is not None
95+
or parsed.password is not None
96+
or parsed.port != port
97+
or host not in {"127.0.0.1", "localhost"}
98+
):
99+
raise ScreenshotRuntimeError("screenshot server must use an unauthenticated loopback URL")
100+
101+
102+
def _wait_for_server(
103+
*,
104+
process: subprocess.Popen[str],
105+
base_url: str,
106+
attempts: int = DEFAULT_HEALTH_ATTEMPTS,
107+
interval_seconds: float = DEFAULT_HEALTH_INTERVAL_SECONDS,
108+
) -> None:
109+
health_url = f"{base_url.rstrip('/')}{DEFAULT_HEALTH_PATH}"
110+
for attempt in range(attempts):
111+
if process.poll() is not None:
112+
raise ScreenshotRuntimeError(
113+
f"server exited before liveness became ready (exit {process.returncode})"
114+
)
115+
if _server_is_ready(health_url):
116+
return
117+
if attempt + 1 < attempts:
118+
time.sleep(interval_seconds)
119+
raise ScreenshotRuntimeError(f"server did not become ready at {health_url}")
120+
121+
122+
def _run_migration(repository: Path) -> subprocess.Popen[str]:
123+
return subprocess.Popen(
124+
["uv", "run", "--frozen", "python", "manage.py", "migrate", "--noinput"],
125+
cwd=repository,
126+
text=True,
127+
start_new_session=True,
128+
)
129+
130+
131+
def _run_server(repository: Path, *, host: str, port: int, log_path: Path) -> subprocess.Popen[str]:
132+
log_path.parent.mkdir(parents=True, exist_ok=True)
133+
log_file = log_path.open("w", encoding="utf-8")
134+
try:
135+
return subprocess.Popen(
136+
[
137+
"uv",
138+
"run",
139+
"--frozen",
140+
"python",
141+
"manage.py",
142+
"runserver",
143+
f"{host}:{port}",
144+
"--noreload",
145+
],
146+
cwd=repository,
147+
stdout=log_file,
148+
stderr=subprocess.STDOUT,
149+
text=True,
150+
start_new_session=True,
151+
)
152+
except BaseException:
153+
log_file.close()
154+
raise
155+
finally:
156+
log_file.close()
157+
158+
159+
def _run_capture(
160+
controller_repository: Path,
161+
*,
162+
plan: Path,
163+
output: Path,
164+
base_url: str,
165+
) -> subprocess.Popen[str]:
166+
return subprocess.Popen(
167+
[
168+
"uv",
169+
"run",
170+
"--frozen",
171+
"python",
172+
"-m",
173+
"ci.screenshot_capture",
174+
"--plan",
175+
os.fspath(plan),
176+
"--base-url",
177+
base_url,
178+
"--output",
179+
os.fspath(output),
180+
],
181+
cwd=controller_repository,
182+
text=True,
183+
start_new_session=True,
184+
)
185+
186+
187+
def _print_server_log(log_path: Path) -> None:
188+
try:
189+
contents = log_path.read_text(encoding="utf-8")
190+
except OSError:
191+
return
192+
if contents:
193+
print(contents, file=sys.stderr, end="")
194+
195+
196+
def run_capture(
197+
*,
198+
repository: str | Path,
199+
controller_repository: str | Path,
200+
plan: str | Path,
201+
output: str | Path,
202+
server_log: str | Path,
203+
base_url: str = f"http://{DEFAULT_HOST}:{DEFAULT_PORT}",
204+
host: str = DEFAULT_HOST,
205+
port: int = DEFAULT_PORT,
206+
) -> int:
207+
"""Run migration, server, and capture under one owned test-runtime lease."""
208+
209+
repository = Path(repository).resolve(strict=True)
210+
controller_repository = Path(controller_repository).resolve(strict=True)
211+
plan = Path(plan).resolve(strict=True)
212+
output = Path(output).resolve(strict=False)
213+
server_log = Path(server_log).resolve(strict=False)
214+
if not controller_repository.is_dir():
215+
raise ScreenshotRuntimeError("screenshot controller repository is not a directory")
216+
if not plan.is_file() or plan.is_symlink():
217+
raise ScreenshotRuntimeError("verification plan is not a regular file")
218+
if not (1 <= port <= 65_535):
219+
raise ScreenshotRuntimeError("server port is outside the valid range")
220+
try:
221+
_validate_local_endpoint(base_url=base_url, host=host, port=port)
222+
except ValueError as error:
223+
raise ScreenshotRuntimeError("screenshot server URL is malformed") from error
224+
output.mkdir(parents=True, exist_ok=True)
225+
226+
previous_sigterm = signal.getsignal(signal.SIGTERM)
227+
previous_sigint = signal.getsignal(signal.SIGINT)
228+
try:
229+
runtime = TestRuntime.acquire(repository)
230+
except TestRuntimeSafetyError as error:
231+
raise ScreenshotRuntimeError(str(error)) from error
232+
if not runtime.is_owner:
233+
raise ScreenshotRuntimeError("screenshot coordinator must own its test runtime")
234+
235+
migration: subprocess.Popen[str] | None = None
236+
server: subprocess.Popen[str] | None = None
237+
capture: subprocess.Popen[str] | None = None
238+
interrupted_signal: int | None = None
239+
240+
def handle_signal(signum: int, _frame: object) -> NoReturn:
241+
nonlocal interrupted_signal
242+
interrupted_signal = signum
243+
# A signal can interrupt Popen.wait while it holds that Popen's internal
244+
# wait lock. Calling wait() again from this handler would deadlock, so
245+
# terminate each process group directly and let the coordinator exit.
246+
_kill_process_groups(capture, server, migration)
247+
runtime.cleanup()
248+
signal.signal(signum, signal.SIG_DFL)
249+
os.kill(os.getpid(), signum)
250+
raise AssertionError("signal did not terminate the coordinator")
251+
252+
signal.signal(signal.SIGTERM, handle_signal)
253+
signal.signal(signal.SIGINT, handle_signal)
254+
try:
255+
migration = _run_migration(repository)
256+
migration_result = migration.wait()
257+
if migration_result != 0:
258+
return migration_result
259+
260+
server = _run_server(
261+
repository,
262+
host=host,
263+
port=port,
264+
log_path=server_log,
265+
)
266+
try:
267+
_wait_for_server(process=server, base_url=base_url)
268+
except ScreenshotRuntimeError:
269+
_terminate_process(server)
270+
_print_server_log(server_log)
271+
raise
272+
273+
capture = _run_capture(
274+
controller_repository,
275+
plan=plan,
276+
output=output,
277+
base_url=base_url,
278+
)
279+
return capture.wait()
280+
finally:
281+
_terminate_processes(capture, server, migration)
282+
runtime.cleanup()
283+
test_runtime._unregister_termination_cleanup(runtime)
284+
signal.signal(signal.SIGTERM, previous_sigterm)
285+
signal.signal(signal.SIGINT, previous_sigint)
286+
if interrupted_signal is not None:
287+
raise ScreenshotRuntimeError("screenshot coordinator interrupted")
288+
289+
290+
def main(argv: Sequence[str] | None = None) -> None:
291+
parser = argparse.ArgumentParser(description=__doc__)
292+
parser.add_argument("--repository", type=Path, default=Path.cwd())
293+
parser.add_argument("--controller-repository", type=Path, required=True)
294+
parser.add_argument("--plan", type=Path, required=True)
295+
parser.add_argument("--output", type=Path, required=True)
296+
parser.add_argument("--server-log", type=Path, required=True)
297+
parser.add_argument("--base-url", default=f"http://{DEFAULT_HOST}:{DEFAULT_PORT}")
298+
parser.add_argument("--host", default=DEFAULT_HOST)
299+
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
300+
args = parser.parse_args(argv)
301+
try:
302+
result = run_capture(
303+
repository=args.repository,
304+
controller_repository=args.controller_repository,
305+
plan=args.plan,
306+
output=args.output,
307+
server_log=args.server_log,
308+
base_url=args.base_url,
309+
host=args.host,
310+
port=args.port,
311+
)
312+
except ScreenshotRuntimeError as error:
313+
print(f"screenshot runtime failed: {error}", file=sys.stderr)
314+
raise SystemExit(1) from error
315+
raise SystemExit(result)
316+
317+
318+
if __name__ == "__main__":
319+
main()

0 commit comments

Comments
 (0)