Files
samiandClaude Sonnet 5 efe76c319d pkg: nightly integration run — real veloxd + testserver, 50 concurrent
Adds tests/integration/nightly_run.py, wired as the nightly-integration job in
ci.yml (schedule + workflow_dispatch). Real veloxd + tools/testserver, 50
concurrent downloads mixing flaky-reset/throttled/no-range/plain, asserting:
every completed file's SHA-256 against testserver's own /sha256/ route (never
trusting veloxd's own success claim), every task reaching a terminal state
inside the timeout, and veloxd's own open-FD count settling back to baseline.

veloxd runs isolated (XDG_RUNTIME_DIR/XDG_DATA_HOME/XDG_CONFIG_HOME under a
fresh mkdtemp — not the session scratch dir, whose path overflows AF_UNIX's
sun_path). saveTo.allowedRoots is seeded directly into velox.db after a
migrations-only warm-up start, since settings.set returns -32603 'not
implemented in this build' on the veloxd this job builds (verified live).

Every task gets its own filename override on download.add: tasks sharing
(mode, size) share a URL, and without distinct filenames they raced each
other's rename on the first real run (48/50 'passed' with io_errors and
checksum mismatches on the collided tasks) before this fix.

Every assertion was forced red once on purpose and the transcript recorded in
tests/integration/README.md, per this repo's history of green checks that
didn't look where the bug was.

Every spawned child (veloxd, testserver.py) gets PR_SET_PDEATHSIG plus its own
process group, so a hard-killed harness can't strand a daemon the way a prior
run did (4h40m under systemd --user, because SIGKILL never reaches a
finally: block). Proven by kill -9'ing a running harness mid-download and
confirming both children exit with it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RBPR7iM3YPyxrjWsVtZDPJ
2026-09-11 17:07:42 +04:00

419 lines
17 KiB
Python

#!/usr/bin/env python3
"""tests/integration/nightly_run.py — the nightly integration gate.
Real veloxd + tools/testserver, 50 concurrent downloads mixing hostile testserver
modes, every completed file's SHA-256 checked against testserver's own /sha256/
route, and veloxd's open-FD count checked flat across the run. See
tests/integration/README.md#nightly-integration-run for what this proves and how
each failure mode was forced once to prove the check is not vacuous.
Standard library only (matches tools/testserver's own constraint) — this runs on
whatever CI image happens to have Python and does not need its own dependency
install step.
Usage:
python3 tests/integration/nightly_run.py --veloxd /path/to/build/dev/bin/veloxd
Exit codes: 0 all good, 1 a task failed or a checksum/FD assertion tripped,
2 usage/environment error (daemon or testserver would not start).
"""
from __future__ import annotations
import argparse
import ctypes
import hashlib
import json
import os
import re
import shutil
import signal
import socket
import sqlite3
import subprocess
import sys
import tempfile
import time
import urllib.request
from dataclasses import dataclass
from typing import NoReturn
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
TESTSERVER = os.path.join(REPO_ROOT, "tools", "testserver", "testserver.py")
_PR_SET_PDEATHSIG = 1
def _die_with_parent() -> None:
"""preexec_fn for every child we spawn: ask the kernel to SIGKILL this child the
moment its parent (this harness) goes away, by any means — including this
process's own SIGKILL, which no `finally:` block ever runs for. Verified live: a
prior run left a stranded veloxd running under systemd --user for 4h40m after the
harness was killed -9'd, because cleanup lived only in `finally:`. Proven fixed by
SIGKILL-ing a running harness mid-download and confirming both children exit with
it — see tests/integration/README.md.
ctypes, not python-prctl: this is stdlib-only by the same rule as testserver.py,
and it's one syscall.
"""
libc = ctypes.CDLL("libc.so.6", use_errno=True)
libc.prctl(_PR_SET_PDEATHSIG, signal.SIGKILL, 0, 0, 0)
# --- self-test hooks --------------------------------------------------------------
# Each of these deliberately breaks one assertion so the gate can be proven red on
# demand, without hand-editing the script. `tests/integration/README.md` records one
# transcript per flag from the session that added this file; re-run any of them
# whenever the corresponding assertion is touched, to re-prove it still catches the
# failure it claims to.
FORCE_BAD_HASH = os.environ.get("VDM_NIGHTLY_FORCE_BAD_HASH") == "1"
FORCE_FD_LEAK = os.environ.get("VDM_NIGHTLY_FORCE_FD_LEAK") == "1"
FORCE_HOSTILE_STALL = os.environ.get("VDM_NIGHTLY_FORCE_HOSTILE_STALL") == "1"
@dataclass
class TaskPlan:
idx: int
mode: str
size: str
task_id: str | None = None
url_override: str | None = None # self-test hook only; see FORCE_HOSTILE_STALL
@dataclass
class RpcClient:
sock: socket.socket
_next_id: int = 1
_buf: bytes = b""
def call(self, method: str, params: dict, timeout: float = 10.0) -> dict:
req = {"jsonrpc": "2.0", "id": self._next_id, "method": method, "params": params}
self._next_id += 1
self.sock.sendall((json.dumps(req) + "\n").encode())
self.sock.settimeout(timeout)
while b"\n" not in self._buf:
chunk = self.sock.recv(65536)
if not chunk:
raise ConnectionError(f"veloxd closed the connection mid-{method}")
self._buf += chunk
line, _, self._buf = self._buf.partition(b"\n")
return json.loads(line.decode())
def die(msg: str, code: int = 2) -> NoReturn:
print(f"nightly_run: {msg}", file=sys.stderr)
sys.exit(code)
def wait_for(pred, timeout: float, interval: float = 0.1, what: str = "condition"):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if pred():
return
time.sleep(interval)
raise TimeoutError(f"timed out waiting for {what}")
def start_testserver(log_path: str) -> tuple[subprocess.Popen, int]:
proc = subprocess.Popen(
[sys.executable, TESTSERVER, "--port", "0"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
preexec_fn=_die_with_parent,
start_new_session=True,
)
log = open(log_path, "w")
port = None
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
line = proc.stdout.readline()
if not line:
break
log.write(line)
log.flush()
m = re.search(r"\b(\d{2,5})\b", line)
if m:
port = int(m.group(1))
break
if port is None:
proc.terminate()
die("testserver never printed its ephemeral port")
# Drain the rest of stdout into the log in the background so the pipe never fills.
def _drain():
for line in proc.stdout:
log.write(line)
log.flush()
import threading
threading.Thread(target=_drain, daemon=True).start()
return proc, port
def start_veloxd(veloxd_bin: str, env: dict, sock_path: str, log_path: str) -> subprocess.Popen:
proc = subprocess.Popen(
[veloxd_bin],
env=env,
stdout=open(log_path, "a"),
stderr=subprocess.STDOUT,
preexec_fn=_die_with_parent,
start_new_session=True,
)
try:
wait_for(lambda: os.path.exists(sock_path), timeout=10, what="veloxd's UDS socket")
except TimeoutError:
proc.terminate()
die(
"veloxd never created its socket — check the log at " + log_path,
)
return proc
def seed_settings(db_path: str, allowed_root: str, max_concurrent: int) -> None:
"""Pre-seed saveTo.allowedRoots (and raise the concurrency cap) directly in the
DB. settings.set is not implemented in this build yet (verified live: it returns
-32603 "not implemented in this build"), so this is the only way in — not a
workaround for a wrong contract, just the one entry point that currently exists.
"""
conn = sqlite3.connect(db_path)
try:
for key, value in (
("saveTo.allowedRoots", json.dumps([allowed_root])),
("saveTo.defaultDir", json.dumps(allowed_root)),
("connection.maxConcurrentDownloads", json.dumps(max_concurrent)),
("connection.maxActiveSegments", json.dumps(max_concurrent * 2)),
):
conn.execute(
"INSERT INTO settings(key, value) VALUES(?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, value),
)
conn.commit()
finally:
conn.close()
def open_fd_count(pid: int) -> int:
return len(os.listdir(f"/proc/{pid}/fd"))
def build_plan(n: int, hostile_stall: bool) -> list[TaskPlan]:
# 60% plain, mixed in with the three named hostile modes from the brief.
modes = (
["plain"] * 30
+ ["flaky-reset"] * 8
+ ["throttled"] * 6
+ ["no-range"] * 6
)
assert len(modes) == n
plan = []
for i, mode in enumerate(modes):
# Small enough that even `throttled` (1 MiB/s default) finishes well inside
# the budget. Sizes repeat across tasks (only 8 distinct values) — that's
# fine for testserver, whose content is a function of (seed, path, size),
# but every task must still get its own `filename` override below: two
# tasks that share both mode and size share a URL, and without an override
# they'd share a destination filename too and race each other's rename —
# verified live, see tests/integration/README.md.
size = f"{256 + (i % 8) * 16}K"
t = TaskPlan(idx=i, mode=mode, size=size)
if hostile_stall and i == 0:
# Self-test hook, verified live: the first attempt at this used
# testserver's 416-always mode, expecting it to never yield a 2xx to a
# Range probe — but veloxd correctly falls back to a plain full GET on a
# 416, so that task completed fine and the hook proved nothing.
# TEST-NET-1 (RFC 5737) is unassigned and unroutable, so a connection
# attempt to it hangs until veloxd's own connection.timeoutSec — also
# verified live: it lands the task in 'failed' (retryable timeout) well
# inside this script's own --timeout, so this hook actually exercises the
# "reached a terminal state that isn't complete" branch below, not the
# "never reached one at all" branch — both are real ways a hostile source
# must not be allowed to read as a silent pass. See
# VDM_NIGHTLY_FORCE_HOSTILE_STALL in the README.
t.url_override = "http://192.0.2.1:1/black-hole"
plan.append(t)
return plan
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--veloxd", default=os.path.join(REPO_ROOT, "build", "dev", "bin", "veloxd"))
ap.add_argument("--tasks", type=int, default=50)
ap.add_argument("--timeout", type=float, default=120.0, help="seconds to wait for all tasks")
args = ap.parse_args()
if not os.path.isfile(args.veloxd):
die(f"veloxd binary not found at {args.veloxd} — build it first (cmake --build ...)")
# $TMPDIR, not the caller's scratch dir: AF_UNIX paths are capped at ~108 bytes
# (sun_path) and a deeply-nested scratch/session path blows that budget — verified
# live ("File name too long") before switching to mkdtemp() here.
base = tempfile.mkdtemp(prefix="vdm-nightly-")
print(f"nightly_run: workdir {base}")
ok = True
daemon = None
ts = None
try:
for d in ("runtime", "data", "config", "downloads"):
os.makedirs(os.path.join(base, d), exist_ok=True)
os.chmod(os.path.join(base, "runtime"), 0o700)
downloads_dir = os.path.join(base, "downloads")
env = os.environ.copy()
env["XDG_RUNTIME_DIR"] = os.path.join(base, "runtime")
env["XDG_DATA_HOME"] = os.path.join(base, "data")
env["XDG_CONFIG_HOME"] = os.path.join(base, "config")
sock_path = os.path.join(base, "runtime", "velox", "velox.sock")
veloxd_log = os.path.join(base, "veloxd.log")
# Warm-up start: apply migrations and create velox.db, nothing else.
warm = start_veloxd(args.veloxd, env, sock_path, veloxd_log)
warm.terminate()
warm.wait(timeout=5)
db_path = os.path.join(base, "data", "velox", "velox.db")
if not os.path.isfile(db_path):
die(f"veloxd's warm-up start never created {db_path}")
seed_settings(db_path, downloads_dir, max_concurrent=args.tasks)
ts, port = start_testserver(os.path.join(base, "testserver.log"))
daemon = start_veloxd(args.veloxd, env, sock_path, veloxd_log)
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect(sock_path)
rpc = RpcClient(sock=s)
hello = rpc.call(
"session.hello",
{"clientType": "cli", "clientName": "nightly-run", "protocolVersion": "1.0.0"},
)
if "error" in hello:
die(f"session.hello failed: {hello['error']}")
baseline_fds = open_fd_count(daemon.pid)
print(f"nightly_run: veloxd baseline FDs = {baseline_fds}")
plan = build_plan(args.tasks, hostile_stall=FORCE_HOSTILE_STALL)
for t in plan:
url = t.url_override or f"http://127.0.0.1:{port}/{t.mode}/file/{t.size}"
# Every task gets its own filename: several tasks share a (mode, size) and
# therefore a URL, and without this override they'd race each other for the
# same destination path — verified live (see README).
filename = f"task-{t.idx:02d}-{t.mode}-{t.size}"
add = rpc.call(
"download.add",
{"url": url, "saveDir": downloads_dir, "segments": 4, "filename": filename},
)
if "error" in add:
ok = False
print(f"task {t.idx} ({t.mode}, {t.size}): download.add failed: {add['error']}")
continue
t.task_id = add["result"]["taskId"]
# --- poll every task to a terminal state -------------------------------------
terminal = {"complete", "failed", "cancelled"}
by_id = {t.task_id: t for t in plan if t.task_id}
results: dict[str, dict] = {}
deadline = time.monotonic() + args.timeout
while time.monotonic() < deadline and len(results) < len(by_id):
lst = rpc.call("download.list", {"limit": 5000})
if "error" in lst:
die(f"download.list failed: {lst['error']}")
for item in lst["result"]["items"]:
if item["taskId"] in by_id and item["state"] in terminal:
results[item["taskId"]] = item
time.sleep(0.5)
missing = set(by_id) - set(results)
if missing:
ok = False
print(f"FAIL: {len(missing)}/{len(by_id)} task(s) never reached a terminal state "
f"within {args.timeout}s: {sorted(missing)}")
# --- checksum: every completed file against testserver's own /sha256/ route -
for task_id, item in results.items():
t = by_id[task_id]
if item["state"] != "complete":
ok = False
err = item.get("error")
print(f"FAIL: task {t.idx} ({t.mode}, {t.size}) ended in state "
f"'{item['state']}', error={err}")
continue
path = os.path.join(item["saveDir"], item["filename"])
if not os.path.isfile(path):
ok = False
print(f"FAIL: task {t.idx} ({t.mode}, {t.size}) reported complete but "
f"{path} does not exist")
continue
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
actual = h.hexdigest()
expected_url = f"http://127.0.0.1:{port}/{t.mode}/sha256/{t.size}"
with urllib.request.urlopen(expected_url, timeout=5) as resp:
expected = json.loads(resp.read())["sha256"]
if FORCE_BAD_HASH and t.idx == 0:
# Self-test hook: corrupt the comparison itself, not the file, so a
# clean re-run without the env var proves nothing was left broken.
expected = "0" * 64
if actual != expected:
ok = False
print(f"FAIL: task {t.idx} ({t.mode}, {t.size}) checksum mismatch: "
f"got {actual}, testserver says {expected}")
# --- FD leak: veloxd's own /proc/<pid>/fd count returns to baseline ----------
# One settle window for the last connections/segment threads to actually
# close, then compare. A real leak grows roughly with the number of tasks
# that touched a socket, so a fixed small tolerance (not "not more than
# double") is the point of this check.
time.sleep(2.0)
final_fds = open_fd_count(daemon.pid)
if FORCE_FD_LEAK:
final_fds += 25 # self-test hook — see VDM_NIGHTLY_FORCE_FD_LEAK in the README
# Verified live across several 50-task runs: a clean run settles at baseline+3
# to baseline+5 (thread-pool and libcurl connection-cache bookkeeping that
# doesn't unwind instantly), so 4 alone was noisy enough to fail a clean run by
# chance. 10 still catches a real leak by a wide margin — a run with tasks stuck
# retrying showed baseline+7 before those tasks even reached their timeout.
tolerance = 10
print(f"nightly_run: veloxd FDs after run = {final_fds} (baseline {baseline_fds}, "
f"tolerance {tolerance})")
if final_fds > baseline_fds + tolerance:
ok = False
print(f"FAIL: veloxd leaked file descriptors: {baseline_fds} -> {final_fds} "
f"(tolerance {tolerance})")
print(f"nightly_run: {len(results)}/{len(by_id)} tasks reached a terminal state, "
f"{'OK' if ok else 'FAILURES ABOVE'}")
return 0 if ok else 1
finally:
# Belt-and-suspenders on top of PR_SET_PDEATHSIG: a graceful path here still
# prefers SIGTERM before SIGKILL. killpg (not kill) because start_new_session
# made each child its own process group leader, so this also reaps anything
# it spawned (testserver's --drain thread aside, which is ours, not a child).
for proc in (daemon, ts):
if proc is not None and proc.poll() is None:
try:
os.killpg(proc.pid, signal.SIGTERM)
except ProcessLookupError:
continue
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
try:
os.killpg(proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
shutil.rmtree(base, ignore_errors=True)
if __name__ == "__main__":
sys.exit(main())