diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4cb5b8bc..8dc61841 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,8 @@ Avoid `panic!`, `unreachable!`, `.unwrap()`, and `.expect()` in production code. For running tests, we recommend [nextest](https://nexte.st/). +Daemon tests that exercise Managed Resource, gateway, and supervisor fixtures require `python3` on `PATH`. These fixtures use only Python's standard library; no Python packages or virtual environment is required. + To run a specific test by name: ```shell diff --git a/crates/daemon/src/managed_resources/mysql_tests.rs b/crates/daemon/src/managed_resources/mysql_tests.rs index ed1c3260..b3078185 100644 --- a/crates/daemon/src/managed_resources/mysql_tests.rs +++ b/crates/daemon/src/managed_resources/mysql_tests.rs @@ -14,6 +14,10 @@ const MYSQL_TRACK: &str = "8.0"; const MYSQL_ARTIFACT_VERSION: &str = "8.0.35-pv1"; const MYSQL_ARCHIVE_FILE_NAME: &str = "mysql-8.0.35-pv1-any.tar.gz"; const OFFLINE_TEST_MANIFEST_URL: &str = "https://127.0.0.1:9/manifest.json"; +const MYSQL_FIXTURE_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/mysql.py" +)); #[test] fn mysql_runtime_port_prefers_3306() -> Result<()> { @@ -307,7 +311,7 @@ fn seed_mysql_fixture_artifact(paths: &PvPaths, track: &str) -> Result<()> { .join(format!("releases/{MYSQL_ARTIFACT_VERSION}")); let executable = release_path.join("bin/mysqld"); - state::fs::write_sensitive_file(&executable, mysql_fixture_script())?; + state::fs::write_sensitive_file(&executable, MYSQL_FIXTURE_SCRIPT)?; set_executable(&executable)?; let mut database = Database::open(paths)?; database.record_managed_resource_track_installed( @@ -344,7 +348,7 @@ fn create_mysql_archive(tempdir: &Utf8Path, archive_path: &Utf8Path) -> Result<( let root = archive_parent.join(&root_name); let executable = root.join("bin/mysqld"); - state::fs::write_sensitive_file(&executable, mysql_fixture_script())?; + state::fs::write_sensitive_file(&executable, MYSQL_FIXTURE_SCRIPT)?; set_executable(&executable)?; run_fixture_command( "/usr/bin/tar", @@ -437,76 +441,6 @@ fn mysql_manifest(sha256: &str, size: u64) -> String { ) } -fn mysql_fixture_script() -> &'static str { - r#"#!/bin/sh -set -eu - -datadir="" -port="" -first_arg="${1:-}" - -while [ "$#" -gt 0 ]; do - case "$1" in - --initialize-insecure) - initialize=1 - shift - ;; - --datadir) - datadir="$2" - shift 2 - ;; - --basedir) - shift 2 - ;; - --port) - port="$2" - shift 2 - ;; - --bind-address|--socket) - shift 2 - ;; - --no-defaults) - shift - ;; - *) - shift - ;; - esac -done - -if [ "${initialize:-0}" = "1" ]; then - if [ "${first_arg:-}" != "--no-defaults" ]; then - echo "mysqld initialization must start with --no-defaults" >&2 - exit 64 - fi - mkdir -p "$datadir/mysql" - exit 0 -fi - -python3 - "$port" <<'PY' -import signal -import socketserver -import sys - -class Handler(socketserver.BaseRequestHandler): - def handle(self): - self.request.recv(1024) - -class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): - allow_reuse_address = True - -def stop(_signum, _frame): - sys.exit(0) - -signal.signal(signal.SIGTERM, stop) -signal.signal(signal.SIGINT, stop) - -server = TcpServer(("127.0.0.1", int(sys.argv[1])), Handler) -server.serve_forever() -PY -"# -} - fn assert_mysql_snapshot( tempdir: &Utf8Path, name: &'static str, diff --git a/crates/daemon/src/managed_resources/tests.rs b/crates/daemon/src/managed_resources/tests.rs index 07f1ca80..68901619 100644 --- a/crates/daemon/src/managed_resources/tests.rs +++ b/crates/daemon/src/managed_resources/tests.rs @@ -56,6 +56,43 @@ const SETUP_DEFAULT_RUSTFS_TRACK: &str = "1"; const SETUP_DEFAULT_RUSTFS_ARTIFACT_VERSION: &str = "1.0.0-pv1"; const OFFLINE_TEST_MANIFEST_URL: &str = "https://127.0.0.1:9/manifest.json"; const TEST_ARTIFACT_MANIFEST_URL: &str = "https://artifacts.example.test/manifest.json"; +const FAKE_MAILPIT_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/fake-mailpit.py" +)); +const MAILPIT_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/mailpit.py" +)); +const MAILPIT_FAST_EXIT_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/mailpit-fast-exit.py" +)); +const MAILPIT_UNREADY_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/mailpit-unready.sh" +)); +const POSTGRES_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/postgres.py" +)); +const POSTGRES_INITDB_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/postgres-initdb.sh" +)); +const POSTGRES_UNREADY_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/postgres-unready.sh" +)); +const REDIS_SERVER_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/redis-server.py" +)); +const RUSTFS_SCRIPT_TEMPLATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/rustfs.py.in" +)); +const RUSTFS_REJECT_S3_SENTINEL: &str = "__PV_REJECT_S3__"; const EMPTY_ARTIFACT_MANIFEST: &str = r#" { "schema_version": 1, @@ -1689,6 +1726,7 @@ async fn rustfs_allocation_failure_preserves_project_env_and_records_failed_runt database.project_env_observed_state(&project.id)?, ) }; + stop_recorded_rustfs_runtime(&paths).await?; assert!( result.is_err(), @@ -3152,7 +3190,7 @@ fn delete_optional_file(path: &Utf8Path) -> Result<()> { } fn seed_fake_mailpit_artifact(paths: &PvPaths, track: &str) -> Result<()> { - seed_fake_mailpit_artifact_with_script(paths, track, fake_mailpit_script()) + seed_fake_mailpit_artifact_with_script(paths, track, FAKE_MAILPIT_SCRIPT) } fn seed_mailpit_fixture_artifact(paths: &PvPaths, track: &str) -> Result<()> { @@ -3163,7 +3201,7 @@ fn seed_mailpit_fixture_artifact(paths: &PvPaths, track: &str) -> Result<()> { .join(format!("releases/{FAKE_MAILPIT_ARTIFACT_VERSION}")); let executable = release_path.join("bin/mailpit"); - state::fs::write_sensitive_file(&executable, mailpit_script())?; + state::fs::write_sensitive_file(&executable, MAILPIT_SCRIPT)?; set_executable(&executable)?; let mut database = Database::open(paths)?; database.record_managed_resource_track_installed( @@ -3354,11 +3392,11 @@ fn seed_fake_mailpit_artifact_with_script( } fn seed_unready_fake_mailpit_artifact(paths: &PvPaths, track: &str) -> Result<()> { - seed_fake_mailpit_artifact_with_script(paths, track, unready_fake_mailpit_script()) + seed_fake_mailpit_artifact_with_script(paths, track, MAILPIT_UNREADY_SCRIPT) } fn seed_fast_exit_fake_mailpit_artifact(paths: &PvPaths, track: &str) -> Result<()> { - seed_fake_mailpit_artifact_with_script(paths, track, fast_exit_fake_mailpit_script()) + seed_fake_mailpit_artifact_with_script(paths, track, MAILPIT_FAST_EXIT_SCRIPT) } fn seed_mailpit_runtime_ports(paths: &PvPaths, track: &str) -> Result<[TcpListener; 2]> { @@ -3403,11 +3441,11 @@ fn seed_fake_sql_artifact(paths: &PvPaths, resource: &str, track: &str) -> Resul } fn seed_postgres_fixture_artifact(paths: &PvPaths, track: &str) -> Result<()> { - seed_postgres_fixture_artifact_with_script(paths, track, fake_postgres_script()) + seed_postgres_fixture_artifact_with_script(paths, track, POSTGRES_SCRIPT) } fn seed_unready_postgres_fixture_artifact(paths: &PvPaths, track: &str) -> Result<()> { - seed_postgres_fixture_artifact_with_script(paths, track, unready_fake_postgres_script()) + seed_postgres_fixture_artifact_with_script(paths, track, POSTGRES_UNREADY_SCRIPT) } fn seed_postgres_fixture_artifact_with_script( @@ -3476,7 +3514,7 @@ fn seed_redis_fixture_artifact(paths: &PvPaths, track: &str) -> Result<()> { .join(format!("releases/{REDIS_ARTIFACT_VERSION}")); let executable = release_path.join("bin/redis-server"); - state::fs::write_sensitive_file(&executable, redis_server_script())?; + state::fs::write_sensitive_file(&executable, REDIS_SERVER_SCRIPT)?; set_executable(&executable)?; let mut database = Database::open(paths)?; database.record_managed_resource_track_installed( @@ -3490,11 +3528,13 @@ fn seed_redis_fixture_artifact(paths: &PvPaths, track: &str) -> Result<()> { } fn seed_rustfs_fixture_artifact(paths: &PvPaths, track: &str) -> Result<()> { - seed_rustfs_fixture_artifact_with_script(paths, track, &rustfs_script()) + let script = rustfs_script()?; + seed_rustfs_fixture_artifact_with_script(paths, track, &script) } fn seed_auth_rejecting_rustfs_fixture_artifact(paths: &PvPaths, track: &str) -> Result<()> { - seed_rustfs_fixture_artifact_with_script(paths, track, &auth_rejecting_rustfs_script()) + let script = auth_rejecting_rustfs_script()?; + seed_rustfs_fixture_artifact_with_script(paths, track, &script) } fn seed_rustfs_fixture_artifact_with_script( @@ -3664,7 +3704,7 @@ fn create_fake_mailpit_archive(tempdir: &Utf8Path, archive_path: &Utf8Path) -> R let root = archive_parent.join(&root_name); let executable = root.join("bin/pv-fake-mailpit"); - state::fs::write_sensitive_file(&executable, fake_mailpit_script())?; + state::fs::write_sensitive_file(&executable, FAKE_MAILPIT_SCRIPT)?; set_executable(&executable)?; run_fixture_command( "/usr/bin/tar", @@ -3686,7 +3726,7 @@ fn create_mailpit_archive(tempdir: &Utf8Path, archive_path: &Utf8Path) -> Result let root = archive_parent.join(&root_name); let executable = root.join("bin/mailpit"); - state::fs::write_sensitive_file(&executable, mailpit_script())?; + state::fs::write_sensitive_file(&executable, MAILPIT_SCRIPT)?; set_executable(&executable)?; run_fixture_command( "/usr/bin/tar", @@ -3708,7 +3748,7 @@ fn create_redis_archive(tempdir: &Utf8Path, archive_path: &Utf8Path) -> Result<( let root = archive_parent.join(&root_name); let executable = root.join("bin/redis-server"); - state::fs::write_sensitive_file(&executable, redis_server_script())?; + state::fs::write_sensitive_file(&executable, REDIS_SERVER_SCRIPT)?; set_executable(&executable)?; run_fixture_command( "/usr/bin/tar", @@ -3730,7 +3770,8 @@ fn create_rustfs_archive(tempdir: &Utf8Path, archive_path: &Utf8Path) -> Result< let root = archive_parent.join(&root_name); let executable = root.join("bin/rustfs"); - state::fs::write_sensitive_file(&executable, &rustfs_script())?; + let script = rustfs_script()?; + state::fs::write_sensitive_file(&executable, &script)?; set_executable(&executable)?; run_fixture_command( "/usr/bin/tar", @@ -3747,7 +3788,7 @@ fn create_rustfs_archive(tempdir: &Utf8Path, archive_path: &Utf8Path) -> Result< } fn write_postgres_fixture_binaries(release_path: &Utf8Path) -> Result<()> { - write_postgres_fixture_binaries_with_script(release_path, fake_postgres_script()) + write_postgres_fixture_binaries_with_script(release_path, POSTGRES_SCRIPT) } fn write_postgres_fixture_binaries_with_script( @@ -3766,8 +3807,8 @@ fn write_postgres_fixture_binaries_without_support_files(release_path: &Utf8Path let initdb = release_path.join("bin/initdb"); let postgres = release_path.join("bin/postgres"); - state::fs::write_sensitive_file(&initdb, fake_postgres_initdb_script())?; - state::fs::write_sensitive_file(&postgres, fake_postgres_script())?; + state::fs::write_sensitive_file(&initdb, POSTGRES_INITDB_SCRIPT)?; + state::fs::write_sensitive_file(&postgres, POSTGRES_SCRIPT)?; set_executable(&initdb)?; set_executable(&postgres)?; @@ -4165,448 +4206,6 @@ fn postgres_fixture_manifest(sha256: &str, size: u64) -> String { ) } -fn fake_mailpit_script() -> &'static str { - r#"#!/bin/sh -set -eu - -smtp_port="$1" -dashboard_port="$2" - -python3 - "$smtp_port" "$dashboard_port" <<'PY' -import http.server -import signal -import socketserver -import sys -import threading - -class SmtpHandler(socketserver.BaseRequestHandler): - def handle(self): - self.request.sendall(b"220 fake mailpit\r\n") - -class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): - allow_reuse_address = True - -smtp = TcpServer(("127.0.0.1", int(sys.argv[1])), SmtpHandler) -dashboard = http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[2])), http.server.SimpleHTTPRequestHandler) - -def stop(_signum, _frame): - sys.exit(0) - -signal.signal(signal.SIGTERM, stop) -signal.signal(signal.SIGINT, stop) - -threading.Thread(target=smtp.serve_forever, daemon=True).start() -dashboard.serve_forever() -PY -"# -} - -fn fake_postgres_initdb_script() -> &'static str { - r#"#!/bin/sh -set -eu - -data_dir="" -username="" -password_file="" - -while [ "$#" -gt 0 ]; do - case "$1" in - -D) - data_dir="$2" - shift 2 - ;; - -U) - username="$2" - shift 2 - ;; - --username) - username="$2" - shift 2 - ;; - --pwfile) - password_file="$2" - shift 2 - ;; - --auth-host|--auth-local) - shift 2 - ;; - *) - echo "unexpected initdb argument: $1" >&2 - exit 64 - ;; - esac -done - -if [ -z "$data_dir" ] || [ -z "$username" ] || [ -z "$password_file" ]; then - echo "missing initdb inputs" >&2 - exit 64 -fi - -if [ -d "$data_dir" ] && [ "$(find "$data_dir" -mindepth 1 -maxdepth 1 | wc -l)" -gt 0 ]; then - echo "PGDATA is not empty before initdb" >&2 - exit 65 -fi - -mkdir -p "$data_dir/databases" -printf '16\n' > "$data_dir/PG_VERSION" -printf '%s\n' "$username" > "$data_dir/initdb.username" -cat "$password_file" > "$data_dir/initdb.password" -"# -} - -fn fake_postgres_script() -> &'static str { - r#"#!/bin/sh -set -eu - -data_dir="" -argument_host="" -argument_port="" - -while [ "$#" -gt 0 ]; do - case "$1" in - -D) - data_dir="$2" - shift 2 - ;; - -h) - argument_host="$2" - shift 2 - ;; - -p) - argument_port="$2" - shift 2 - ;; - *) - echo "unexpected postgres argument: $1" >&2 - exit 64 - ;; - esac -done - -if [ -z "$data_dir" ] || [ -z "$argument_host" ] || [ -z "$argument_port" ] || [ ! -f "$data_dir/PG_VERSION" ]; then - echo "postgres data dir is not initialized" >&2 - exit 64 -fi - -python3 - "$data_dir" "$argument_host" "$argument_port" <<'PY' -import os -import signal -import socketserver -import struct -import sys -import threading - -data_dir = sys.argv[1] -argument_host = sys.argv[2] -argument_port = int(sys.argv[3]) -config_path = os.path.join(data_dir, "postgresql.conf") -database_dir = os.path.join(data_dir, "databases") - -host = "127.0.0.1" -port = None - -with open(config_path, "r", encoding="utf-8") as config: - for line in config: - line = line.strip() - if line.startswith("listen_addresses"): - host = line.split("=", 1)[1].strip().strip("'\"") - if line.startswith("port"): - port = int(line.split("=", 1)[1].strip()) - -if host != "127.0.0.1" or port is None: - raise SystemExit("postgresql.conf did not set loopback host and port") -if argument_host != host or argument_port != port: - raise SystemExit("postgres arguments did not match generated config") - -os.makedirs(database_dir, exist_ok=True) -with open(os.path.join(data_dir, "postgres.started"), "w", encoding="utf-8") as started: - started.write(f"{host}:{port}\n") - -def packet(message_type, payload=b""): - return message_type + struct.pack("!I", len(payload) + 4) + payload - -def auth_ok(): - return packet(b"R", struct.pack("!I", 0)) - -def parameter_status(key, value): - return packet(b"S", key.encode() + b"\0" + value.encode() + b"\0") - -def backend_key_data(): - return packet(b"K", struct.pack("!II", os.getpid() & 0x7fffffff, 1)) - -def ready(): - return packet(b"Z", b"I") - -def parameter_description(query): - if "$1" in query: - return packet(b"t", struct.pack("!H", 1) + struct.pack("!I", 25)) - return packet(b"t", struct.pack("!H", 0)) - -def command_complete(tag): - return packet(b"C", tag.encode() + b"\0") - -def parse_complete(): - return packet(b"1") - -def bind_complete(): - return packet(b"2") - -def close_complete(): - return packet(b"3") - -def no_data(): - return packet(b"n") - -def row_description(): - field = b"?column?\0" + struct.pack("!IhIhih", 0, 0, 23, 4, -1, 0) - return packet(b"T", struct.pack("!H", 1) + field) - -def data_row(value): - data = str(value).encode() - return packet(b"D", struct.pack("!H", 1) + struct.pack("!I", len(data)) + data) - -def error_response(message): - return packet(b"E", b"SERROR\0CXX000\0M" + message.encode() + b"\0\0") - -def cstring(payload, start): - end = payload.index(b"\0", start) - return payload[start:end].decode(), end + 1 - -def read_exact(stream, length): - data = b"" - while len(data) < length: - chunk = stream.recv(length - len(data)) - if not chunk: - raise EOFError - data += chunk - return data - -def read_startup(stream): - length = struct.unpack("!I", read_exact(stream, 4))[0] - payload = read_exact(stream, length - 4) - code = struct.unpack("!I", payload[:4])[0] - if code == 80877103: - stream.sendall(b"N") - return read_startup(stream) - return payload - -def startup_response(): - return b"".join([ - auth_ok(), - parameter_status("server_version", "16.0"), - parameter_status("server_encoding", "UTF8"), - parameter_status("client_encoding", "UTF8"), - parameter_status("DateStyle", "ISO, MDY"), - parameter_status("integer_datetimes", "on"), - parameter_status("standard_conforming_strings", "on"), - backend_key_data(), - ready(), - ]) - -def database_file(database): - safe = "".join(ch for ch in database if ch.isalnum() or ch == "_") - if safe != database: - raise ValueError("unsafe database name") - return os.path.join(database_dir, database) - -def database_exists(database): - return os.path.exists(database_file(database)) - -def create_database(database): - with open(database_file(database), "w", encoding="utf-8") as marker: - marker.write(database + "\n") - -def database_from_create(query): - quoted = query.split("CREATE DATABASE", 1)[1].strip() - if quoted.startswith('"') and quoted.endswith('"'): - return quoted[1:-1] - return quoted - -def query_response(query, params): - normalized = " ".join(query.strip().split()) - if normalized.upper() in {"SELECT 1", "SELECT $1"}: - return row_description() + data_row(1) + command_complete("SELECT 1") - if "FROM pg_database WHERE datname" in normalized: - database = params[0] if params else "" - if database_exists(database): - return row_description() + data_row(1) + command_complete("SELECT 1") - return row_description() + command_complete("SELECT 0") - if normalized.upper().startswith("CREATE DATABASE"): - create_database(database_from_create(normalized)) - return command_complete("CREATE DATABASE") - if normalized.upper().startswith("SET "): - return command_complete("SET") - return error_response("unsupported fixture query: " + normalized) - -class Handler(socketserver.BaseRequestHandler): - def handle(self): - statements = {} - portals = {} - try: - read_startup(self.request) - self.request.sendall(startup_response()) - while True: - message_type = read_exact(self.request, 1) - length = struct.unpack("!I", read_exact(self.request, 4))[0] - payload = read_exact(self.request, length - 4) - if message_type == b"X": - return - if message_type == b"Q": - query = payload[:-1].decode() - self.request.sendall(query_response(query, []) + ready()) - continue - if message_type == b"P": - statement, offset = cstring(payload, 0) - query, _offset = cstring(payload, offset) - statements[statement] = query - self.request.sendall(parse_complete()) - continue - if message_type == b"B": - portal, offset = cstring(payload, 0) - statement, offset = cstring(payload, offset) - format_count = struct.unpack("!H", payload[offset:offset + 2])[0] - offset += 2 + (format_count * 2) - param_count = struct.unpack("!H", payload[offset:offset + 2])[0] - offset += 2 - params = [] - for _index in range(param_count): - size = struct.unpack("!i", payload[offset:offset + 4])[0] - offset += 4 - if size == -1: - params.append(None) - else: - params.append(payload[offset:offset + size].decode()) - offset += size - portals[portal] = (statements.get(statement, ""), params) - self.request.sendall(bind_complete()) - continue - if message_type == b"D": - describe_kind = payload[:1] - name = payload[1:-1].decode() - query, _params = portals.get(name, (statements.get(name, ""), [])) - response = b"" - if describe_kind == b"S": - response += parameter_description(query) - if query.strip().upper().startswith("CREATE DATABASE"): - response += no_data() - else: - response += row_description() - self.request.sendall(response) - continue - if message_type == b"E": - portal, offset = cstring(payload, 0) - _max_rows = struct.unpack("!I", payload[offset:offset + 4])[0] - query, params = portals.get(portal, ("", [])) - self.request.sendall(query_response(query, params)) - continue - if message_type == b"S": - self.request.sendall(ready()) - continue - if message_type == b"H": - continue - if message_type == b"C": - self.request.sendall(close_complete()) - continue - self.request.sendall(error_response("unsupported message type")) - except (EOFError, ConnectionResetError, BrokenPipeError): - return - -class Server(socketserver.ThreadingMixIn, socketserver.TCPServer): - allow_reuse_address = True - -server = Server((host, port), Handler) - -def stop(_signum, _frame): - server.shutdown() - -signal.signal(signal.SIGTERM, stop) -signal.signal(signal.SIGINT, stop) - -threading.Thread(target=server.serve_forever, daemon=True).start() -signal.pause() -PY -"# -} - -fn unready_fake_postgres_script() -> &'static str { - r#"#!/bin/sh -set -eu - -data_dir="" - -while [ "$#" -gt 0 ]; do - case "$1" in - -D) - data_dir="$2" - shift 2 - ;; - -h|-p) - shift 2 - ;; - *) - echo "unexpected postgres argument: $1" >&2 - exit 64 - ;; - esac -done - -if [ -z "$data_dir" ] || [ ! -f "$data_dir/PG_VERSION" ]; then - echo "postgres data dir is not initialized" >&2 - exit 64 -fi - -stop() { - exit 0 -} - -trap stop TERM INT - -while true; do - sleep 1 -done -"# -} - -fn unready_fake_mailpit_script() -> &'static str { - r#"#!/bin/sh -set -eu - -stop() { - exit 0 -} - -trap stop TERM INT - -while true; do - sleep 1 -done -"# -} - -fn fast_exit_fake_mailpit_script() -> &'static str { - r#"#!/usr/bin/env python3 -import http.server -import os -import sys - - -class Handler(http.server.BaseHTTPRequestHandler): - def do_GET(self): - self.send_response(200) - self.end_headers() - self.wfile.write(b"ready") - self.wfile.flush() - os._exit(0) - - def log_message(self, _format, *_args): - pass - - -server = http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[2])), Handler) -server.serve_forever() -"# -} - fn fake_sql_script() -> &'static str { r#"#!/bin/sh set -eu @@ -4764,346 +4363,31 @@ impl super::ManagedResourceRuntimeAdapter for AsyncSqlHookRuntimeAdapter { } } -fn mailpit_script() -> &'static str { - r#"#!/bin/sh -set -eu - -smtp="" -listen="" -database="" -disable_version_check=false - -while [ "$#" -gt 0 ]; do - case "$1" in - --smtp) - smtp="$2" - shift 2 - ;; - --listen) - listen="$2" - shift 2 - ;; - --database) - database="$2" - shift 2 - ;; - --disable-version-check) - disable_version_check=true - shift - ;; - *) - echo "unexpected argument: $1" >&2 - exit 2 - ;; - esac -done - -if [ -z "$smtp" ] || [ -z "$listen" ] || [ -z "$database" ]; then - echo "missing required mailpit argument" >&2 - exit 2 -fi - -if [ "$disable_version_check" != true ]; then - echo "missing --disable-version-check" >&2 - exit 2 -fi - -case "$database" in - */mailpit.db) - ;; - *) - echo "unexpected database path: $database" >&2 - exit 2 - ;; -esac - -database_dir="$(dirname "$database")" -if [ ! -d "$database_dir" ]; then - echo "database directory does not exist: $database_dir" >&2 - exit 2 -fi - -python3 - "$smtp" "$listen" <<'PY' -import http.server -import signal -import socketserver -import sys -import threading - -def host_port(value): - host, port = value.rsplit(":", 1) - return host, int(port) - -class SmtpHandler(socketserver.BaseRequestHandler): - def handle(self): - self.request.sendall(b"220 mailpit fixture\r\n") - -class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): - allow_reuse_address = True - -smtp = TcpServer(host_port(sys.argv[1]), SmtpHandler) -dashboard = http.server.ThreadingHTTPServer( - host_port(sys.argv[2]), - http.server.SimpleHTTPRequestHandler, -) - -def stop(_signum, _frame): - sys.exit(0) - -signal.signal(signal.SIGTERM, stop) -signal.signal(signal.SIGINT, stop) - -threading.Thread(target=smtp.serve_forever, daemon=True).start() -dashboard.serve_forever() -PY -"# +fn rustfs_script() -> Result { + rustfs_script_source(false) } -fn redis_server_script() -> &'static str { - r#"#!/bin/sh -set -eu - -python3 - "$@" <<'PY' -import os -import signal -import shlex -import socketserver -import sys -import threading - -def redis_config(argv): - port = None - data_dir = None - args = list(argv) - while args: - arg = args.pop(0) - if arg == "--port" and args: - port = int(args.pop(0)) - elif arg == "--dir" and args: - data_dir = args.pop(0) - elif os.path.isfile(arg): - with open(arg, "r", encoding="utf-8") as config: - for line in config: - parts = shlex.split(line) - if len(parts) == 2 and parts[0] == "port": - port = int(parts[1]) - elif len(parts) == 2 and parts[0] == "dir": - data_dir = parts[1] - if port is None: - raise RuntimeError("missing Redis port") - return port, data_dir - -class RedisPingHandler(socketserver.BaseRequestHandler): - def handle(self): - while True: - data = self.request.recv(4096) - if not data: - return - upper = data.upper() - responses = [] - for _ in range(upper.count(b"CLIENT")): - responses.append(b"+OK\r\n") - for _ in range(upper.count(b"PING")): - responses.append(b"+PONG\r\n") - if not responses: - responses.append(b"+OK\r\n") - self.request.sendall(b"".join(responses)) - -class RedisServer(socketserver.ThreadingMixIn, socketserver.TCPServer): - allow_reuse_address = True - daemon_threads = True - -shutdown_requested = threading.Event() - - -def stop(_signum, _frame): - if shutdown_requested.is_set(): - return - shutdown_requested.set() - threading.Thread(target=server.shutdown, daemon=True).start() - -port, data_dir = redis_config(sys.argv[1:]) -if data_dir: - os.makedirs(data_dir, exist_ok=True) - -server = RedisServer(("127.0.0.1", port), RedisPingHandler) -signal.signal(signal.SIGTERM, stop) -signal.signal(signal.SIGINT, stop) -server.serve_forever() -PY -"# +fn auth_rejecting_rustfs_script() -> Result { + rustfs_script_source(true) } -fn rustfs_script() -> String { - rustfs_script_source("False") -} - -fn auth_rejecting_rustfs_script() -> String { - rustfs_script_source("True") -} - -fn rustfs_script_source(reject_s3: &str) -> String { - r#"#!/bin/sh -set -eu +fn rustfs_script_source(reject_s3: bool) -> Result { + let occurrence_count = RUSTFS_SCRIPT_TEMPLATE + .matches(RUSTFS_REJECT_S3_SENTINEL) + .count(); + if occurrence_count != 1 { + bail!( + "RustFS fixture must contain exactly one {RUSTFS_REJECT_S3_SENTINEL} sentinel; found {occurrence_count}" + ); + } -address="" -console_address="" -data_dir="" - -while [ "$#" -gt 0 ]; do - case "$1" in - --address) - address="$2" - shift 2 - ;; - --console-address) - console_address="$2" - shift 2 - ;; - *) - data_dir="$1" - shift - ;; - esac -done + let replacement = if reject_s3 { "True" } else { "False" }; + let script = RUSTFS_SCRIPT_TEMPLATE.replacen(RUSTFS_REJECT_S3_SENTINEL, replacement, 1); + if script.contains(RUSTFS_REJECT_S3_SENTINEL) { + bail!("RustFS fixture still contains {RUSTFS_REJECT_S3_SENTINEL} after rendering"); + } -python3 - "$address" "$console_address" "$data_dir" <<'PY' -import hashlib -import http.server -import os -import posixpath -import signal -import sys -import threading -import urllib.parse - -api_address = sys.argv[1] -console_address = sys.argv[2] -data_dir = sys.argv[3] -reject_s3 = __PV_REJECT_S3__ -buckets_dir = os.path.join(data_dir, "buckets") -os.makedirs(buckets_dir, exist_ok=True) -with open(os.path.join(data_dir, "process-env"), "w", encoding="utf-8") as file: - file.write(f"RUSTFS_ACCESS_KEY={os.environ.get('RUSTFS_ACCESS_KEY', '')}\n") - file.write(f"RUSTFS_SECRET_KEY={os.environ.get('RUSTFS_SECRET_KEY', '')}\n") - -def split_address(value): - host, port = value.rsplit(":", 1) - return host, int(port) - -def bucket_path(bucket): - return os.path.join(buckets_dir, bucket) - -def object_path(bucket, key): - clean_key = posixpath.normpath("/" + key).lstrip("/") - return os.path.join(bucket_path(bucket), clean_key) - -class RustfsHandler(http.server.BaseHTTPRequestHandler): - def do_GET(self): - path = urllib.parse.urlparse(self.path).path - if path in {"/", "/health"}: - self.send_response(200) - self.end_headers() - self.wfile.write(b"rustfs") - return - - self.send_response(404) - self.end_headers() - - def do_PUT(self): - if reject_s3: - self.send_response(403) - self.end_headers() - return - - path = urllib.parse.urlparse(self.path).path.strip("/") - parts = path.split("/", 1) - length = int(self.headers.get("Content-Length", "0")) - body = self.rfile.read(length) - if not parts or not parts[0]: - self.send_response(400) - self.end_headers() - return - - bucket = parts[0] - if len(parts) == 1: - os.makedirs(bucket_path(bucket), exist_ok=True) - self.send_response(200) - self.end_headers() - return - - target = object_path(bucket, parts[1]) - os.makedirs(os.path.dirname(target), exist_ok=True) - with open(target, "wb") as file: - file.write(body) - self.send_response(200) - self.send_header("ETag", hashlib.md5(body).hexdigest()) - self.end_headers() - - def do_HEAD(self): - if reject_s3: - self.send_response(403) - self.end_headers() - return - - path = urllib.parse.urlparse(self.path).path.strip("/") - parts = path.split("/", 1) - if len(parts) != 2: - exists = bool(parts and parts[0] and os.path.isdir(bucket_path(parts[0]))) - self.send_response(200 if exists else 404) - self.end_headers() - return - - target = object_path(parts[0], parts[1]) - if not os.path.exists(target): - self.send_response(404) - self.end_headers() - return - - size = os.path.getsize(target) - with open(target, "rb") as file: - digest = hashlib.md5(file.read()).hexdigest() - self.send_response(200) - self.send_header("Content-Length", str(size)) - self.send_header("ETag", digest) - self.end_headers() - - def log_message(self, _format, *_args): - return - -class ConsoleHandler(http.server.BaseHTTPRequestHandler): - def do_GET(self): - self.send_response(200) - self.end_headers() - self.wfile.write(b"rustfs console") - - def log_message(self, _format, *_args): - return - -class Server(http.server.ThreadingHTTPServer): - allow_reuse_address = True - -api = Server(split_address(api_address), RustfsHandler) -console = Server(split_address(console_address), ConsoleHandler) - -shutdown_requested = threading.Event() - - -def stop(_signum, _frame): - if shutdown_requested.is_set(): - return - shutdown_requested.set() - threading.Thread(target=api.shutdown, daemon=True).start() - -signal.signal(signal.SIGTERM, stop) -signal.signal(signal.SIGINT, stop) - -threading.Thread(target=console.serve_forever, daemon=True).start() -api.serve_forever() -console.shutdown() -PY -"# - .replace("__PV_REJECT_S3__", reject_s3) + Ok(script) } fn assert_with_normalized_postgres_runtime( diff --git a/crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port-server.py b/crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port-server.py new file mode 100644 index 00000000..04c5deb4 --- /dev/null +++ b/crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port-server.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +import http.server +import signal +import sys + + +signal.signal(signal.SIGUSR1, signal.SIG_IGN) +port = int(sys.argv[1]) +with http.server.ThreadingHTTPServer( + ("127.0.0.1", port), http.server.SimpleHTTPRequestHandler +) as server: + server.serve_forever() diff --git a/crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port.sh.in b/crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port.sh.in new file mode 100644 index 00000000..53b8ea92 --- /dev/null +++ b/crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port.sh.in @@ -0,0 +1,29 @@ +#!/bin/sh +set -eu + +if [ "$1" = "validate" ]; then + test -f "$3" + exit 0 +fi + +if [ "$1" = "run" ]; then + port="$(awk '/^# PV_FAKE_PORT / { print $3; exit }' "$3")" + if [ "$port" = "__PV_BLOCKED_PORT__" ]; then + sleep 30 + exit 0 + fi + python3 "$0.server.py" "$port" & + child="$!" + trap ':' USR1 + trap 'kill "$child"; wait "$child"; exit 0' TERM INT + while true; do + wait "$child" && exit 0 + status="$?" + if kill -0 "$child" 2>/dev/null; then + continue + fi + exit "$status" + done +fi + +exit 2 diff --git a/crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py b/crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py new file mode 100644 index 00000000..c282595d --- /dev/null +++ b/crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +import http.server +import re +import signal +import ssl +import sys +import threading + + +signal.signal(signal.SIGUSR1, signal.SIG_IGN) + +with open(sys.argv[1], encoding="utf-8") as config_file: + config = config_file.read() + + +def required(pattern): + match = re.search(pattern, config, re.MULTILINE) + if not match: + raise SystemExit(f"missing fake runtime setting: {pattern}") + return match.group(1) + + +def optional(pattern): + match = re.search(pattern, config, re.MULTILINE) + if not match: + return None + return match.group(1) + + +class Handler(http.server.SimpleHTTPRequestHandler): + def log_message(self, format, *args): + pass + + +http_port = int(required(r"^# PV_FAKE_PORT (\d+)$")) +https_port = optional(r"^\s*https_port (\d+)$") +cert_path = optional(r'^\s*cert "([^"]+)"$') +key_path = optional(r'^\s*key "([^"]+)"$') +servers = [http.server.ThreadingHTTPServer(("127.0.0.1", http_port), Handler)] + +if https_port is not None and cert_path is not None and key_path is not None: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(certfile=cert_path, keyfile=key_path) + https_server = http.server.ThreadingHTTPServer( + ("127.0.0.1", int(https_port)), Handler + ) + https_server.socket = context.wrap_socket(https_server.socket, server_side=True) + servers.append(https_server) + +for server in servers[1:]: + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + +with servers[0] as server: + server.serve_forever() diff --git a/crates/daemon/test-fixtures/gateway/fake-frankenphp.sh b/crates/daemon/test-fixtures/gateway/fake-frankenphp.sh new file mode 100644 index 00000000..53349dcd --- /dev/null +++ b/crates/daemon/test-fixtures/gateway/fake-frankenphp.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +if [ "$1" = "validate" ]; then + test -f "$3" + exit 0 +fi + +if [ "$1" = "run" ]; then + python3 - "$3" < "$0.server.py" & + child="$!" + trap ':' USR1 + trap 'kill "$child"; wait "$child"; exit 0' TERM INT + while true; do + wait "$child" && exit 0 + status="$?" + if kill -0 "$child" 2>/dev/null; then + continue + fi + exit "$status" + done +fi + +exit 2 diff --git a/crates/daemon/test-fixtures/managed-resources/fake-mailpit.py b/crates/daemon/test-fixtures/managed-resources/fake-mailpit.py new file mode 100644 index 00000000..03e6ee0e --- /dev/null +++ b/crates/daemon/test-fixtures/managed-resources/fake-mailpit.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +import http.server +import os +import signal +import socketserver +import sys +import threading + + +smtp_port = sys.argv[1] +dashboard_port = sys.argv[2] + + +class SmtpHandler(socketserver.BaseRequestHandler): + def handle(self): + self.request.sendall(b"220 fake mailpit\r\n") + + +class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + daemon_threads = True + + +smtp = TcpServer(("127.0.0.1", int(smtp_port)), SmtpHandler) +dashboard = http.server.ThreadingHTTPServer( + ("127.0.0.1", int(dashboard_port)), + http.server.SimpleHTTPRequestHandler, +) +shutdown_requested = threading.Event() +shutdown_thread = None +received_signal = None + + +def shutdown_servers(): + smtp.shutdown() + dashboard.shutdown() + + +def stop(signum, _frame): + global received_signal, shutdown_thread + if shutdown_requested.is_set(): + return + received_signal = signum + shutdown_requested.set() + shutdown_thread = threading.Thread(target=shutdown_servers, daemon=True) + shutdown_thread.start() + + +signal.signal(signal.SIGTERM, stop) +signal.signal(signal.SIGINT, stop) + +threading.Thread( + target=smtp.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True +).start() +dashboard.serve_forever(poll_interval=0.1) +if shutdown_thread is not None: + shutdown_thread.join() +else: + smtp.shutdown() +smtp.server_close() +dashboard.server_close() +if received_signal is not None: + signal.signal(received_signal, signal.SIG_DFL) + os.kill(os.getpid(), received_signal) diff --git a/crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py b/crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py new file mode 100644 index 00000000..7ecdd0ef --- /dev/null +++ b/crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +import http.server +import os +import sys + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b"ready") + self.wfile.flush() + os._exit(0) + + def log_message(self, _format, *_args): + pass + + +server = http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[2])), Handler) +server.serve_forever() diff --git a/crates/daemon/test-fixtures/managed-resources/mailpit-unready.sh b/crates/daemon/test-fixtures/managed-resources/mailpit-unready.sh new file mode 100644 index 00000000..f8b711ea --- /dev/null +++ b/crates/daemon/test-fixtures/managed-resources/mailpit-unready.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +stop() { + exit 0 +} + +trap stop TERM INT + +while true; do + sleep 1 +done diff --git a/crates/daemon/test-fixtures/managed-resources/mailpit.py b/crates/daemon/test-fixtures/managed-resources/mailpit.py new file mode 100644 index 00000000..aba95bbe --- /dev/null +++ b/crates/daemon/test-fixtures/managed-resources/mailpit.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +import http.server +import os +import signal +import socketserver +import sys +import threading + + +arguments = list(sys.argv[1:]) +smtp = "" +listen = "" +database = "" +disable_version_check = False + +while arguments: + argument = arguments.pop(0) + if argument == "--smtp": + smtp = arguments.pop(0) + elif argument == "--listen": + listen = arguments.pop(0) + elif argument == "--database": + database = arguments.pop(0) + elif argument == "--disable-version-check": + disable_version_check = True + else: + print(f"unexpected argument: {argument}", file=sys.stderr) + sys.exit(2) + +if not smtp or not listen or not database: + print("missing required mailpit argument", file=sys.stderr) + sys.exit(2) + +if not disable_version_check: + print("missing --disable-version-check", file=sys.stderr) + sys.exit(2) + +if not database.endswith("/mailpit.db"): + print(f"unexpected database path: {database}", file=sys.stderr) + sys.exit(2) + +database_dir = os.path.dirname(database) +if not os.path.isdir(database_dir): + print(f"database directory does not exist: {database_dir}", file=sys.stderr) + sys.exit(2) + + +def host_port(value): + host, port = value.rsplit(":", 1) + return host, int(port) + + +class SmtpHandler(socketserver.BaseRequestHandler): + def handle(self): + self.request.sendall(b"220 mailpit fixture\r\n") + + +class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + daemon_threads = True + + +smtp_server = TcpServer(host_port(smtp), SmtpHandler) +dashboard = http.server.ThreadingHTTPServer( + host_port(listen), + http.server.SimpleHTTPRequestHandler, +) +shutdown_requested = threading.Event() +shutdown_thread = None +received_signal = None + + +def shutdown_servers(): + smtp_server.shutdown() + dashboard.shutdown() + + +def stop(signum, _frame): + global received_signal, shutdown_thread + if shutdown_requested.is_set(): + return + received_signal = signum + shutdown_requested.set() + shutdown_thread = threading.Thread(target=shutdown_servers, daemon=True) + shutdown_thread.start() + + +signal.signal(signal.SIGTERM, stop) +signal.signal(signal.SIGINT, stop) + +threading.Thread( + target=smtp_server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True +).start() +dashboard.serve_forever(poll_interval=0.1) +if shutdown_thread is not None: + shutdown_thread.join() +else: + smtp_server.shutdown() +smtp_server.server_close() +dashboard.server_close() +if received_signal is not None: + signal.signal(received_signal, signal.SIG_DFL) + os.kill(os.getpid(), received_signal) diff --git a/crates/daemon/test-fixtures/managed-resources/mysql.py b/crates/daemon/test-fixtures/managed-resources/mysql.py new file mode 100644 index 00000000..edbefe99 --- /dev/null +++ b/crates/daemon/test-fixtures/managed-resources/mysql.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +import os +import signal +import socketserver +import sys +import threading + + +arguments = list(sys.argv[1:]) +first_argument = arguments[0] if arguments else "" +data_dir = "" +port = "" +initialize = False + +while arguments: + argument = arguments.pop(0) + if argument == "--initialize-insecure": + initialize = True + elif argument == "--datadir": + data_dir = arguments.pop(0) + elif argument == "--basedir": + arguments.pop(0) + elif argument == "--port": + port = arguments.pop(0) + elif argument in {"--bind-address", "--socket"}: + arguments.pop(0) + elif argument == "--no-defaults": + pass + +if initialize: + if first_argument != "--no-defaults": + print("mysqld initialization must start with --no-defaults", file=sys.stderr) + sys.exit(64) + os.makedirs(f"{data_dir}/mysql", exist_ok=True) + sys.exit(0) + + +class Handler(socketserver.BaseRequestHandler): + def handle(self): + handler_marker = os.environ.get("PV_FIXTURE_HANDLER_STARTED") + if handler_marker: + with open(handler_marker, "w", encoding="utf-8") as marker: + marker.write("started\n") + self.request.recv(1024) + + +class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + daemon_threads = True + + +server = TcpServer(("127.0.0.1", int(port)), Handler) +shutdown_requested = threading.Event() +shutdown_thread = None +received_signal = None + + +def stop(signum, _frame): + global received_signal, shutdown_thread + if not shutdown_requested.is_set(): + received_signal = signum + shutdown_requested.set() + shutdown_thread = threading.Thread(target=server.shutdown, daemon=True) + shutdown_thread.start() + + +signal.signal(signal.SIGTERM, stop) +signal.signal(signal.SIGINT, stop) + +server.serve_forever() +if shutdown_thread is not None: + shutdown_thread.join() +server.server_close() +if received_signal is not None: + signal.signal(received_signal, signal.SIG_DFL) + os.kill(os.getpid(), received_signal) diff --git a/crates/daemon/test-fixtures/managed-resources/postgres-initdb.sh b/crates/daemon/test-fixtures/managed-resources/postgres-initdb.sh new file mode 100644 index 00000000..a87f9183 --- /dev/null +++ b/crates/daemon/test-fixtures/managed-resources/postgres-initdb.sh @@ -0,0 +1,49 @@ +#!/bin/sh +set -eu + +data_dir="" +username="" +password_file="" + +while [ "$#" -gt 0 ]; do + case "$1" in + -D) + data_dir="$2" + shift 2 + ;; + -U) + username="$2" + shift 2 + ;; + --username) + username="$2" + shift 2 + ;; + --pwfile) + password_file="$2" + shift 2 + ;; + --auth-host|--auth-local) + shift 2 + ;; + *) + echo "unexpected initdb argument: $1" >&2 + exit 64 + ;; + esac +done + +if [ -z "$data_dir" ] || [ -z "$username" ] || [ -z "$password_file" ]; then + echo "missing initdb inputs" >&2 + exit 64 +fi + +if [ -d "$data_dir" ] && [ "$(find "$data_dir" -mindepth 1 -maxdepth 1 | wc -l)" -gt 0 ]; then + echo "PGDATA is not empty before initdb" >&2 + exit 65 +fi + +mkdir -p "$data_dir/databases" +printf '16\n' > "$data_dir/PG_VERSION" +printf '%s\n' "$username" > "$data_dir/initdb.username" +cat "$password_file" > "$data_dir/initdb.password" diff --git a/crates/daemon/test-fixtures/managed-resources/postgres-unready.sh b/crates/daemon/test-fixtures/managed-resources/postgres-unready.sh new file mode 100644 index 00000000..727e7164 --- /dev/null +++ b/crates/daemon/test-fixtures/managed-resources/postgres-unready.sh @@ -0,0 +1,35 @@ +#!/bin/sh +set -eu + +data_dir="" + +while [ "$#" -gt 0 ]; do + case "$1" in + -D) + data_dir="$2" + shift 2 + ;; + -h|-p) + shift 2 + ;; + *) + echo "unexpected postgres argument: $1" >&2 + exit 64 + ;; + esac +done + +if [ -z "$data_dir" ] || [ ! -f "$data_dir/PG_VERSION" ]; then + echo "postgres data dir is not initialized" >&2 + exit 64 +fi + +stop() { + exit 0 +} + +trap stop TERM INT + +while true; do + sleep 1 +done diff --git a/crates/daemon/test-fixtures/managed-resources/postgres.py b/crates/daemon/test-fixtures/managed-resources/postgres.py new file mode 100644 index 00000000..12778281 --- /dev/null +++ b/crates/daemon/test-fixtures/managed-resources/postgres.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +import os +import signal +import socketserver +import struct +import sys +import threading + + +arguments = list(sys.argv[1:]) +data_dir = "" +argument_host = "" +argument_port = "" + +while arguments: + argument = arguments.pop(0) + if argument == "-D": + data_dir = arguments.pop(0) + elif argument == "-h": + argument_host = arguments.pop(0) + elif argument == "-p": + argument_port = arguments.pop(0) + else: + print(f"unexpected postgres argument: {argument}", file=sys.stderr) + sys.exit(64) + +if ( + not data_dir + or not argument_host + or not argument_port + or not os.path.isfile(os.path.join(data_dir, "PG_VERSION")) +): + print("postgres data dir is not initialized", file=sys.stderr) + sys.exit(64) + +argument_port = int(argument_port) +config_path = os.path.join(data_dir, "postgresql.conf") +database_dir = os.path.join(data_dir, "databases") + +host = "127.0.0.1" +port = None + +with open(config_path, "r", encoding="utf-8") as config: + for line in config: + line = line.strip() + if line.startswith("listen_addresses"): + host = line.split("=", 1)[1].strip().strip("'\"") + if line.startswith("port"): + port = int(line.split("=", 1)[1].strip()) + +if host != "127.0.0.1" or port is None: + raise SystemExit("postgresql.conf did not set loopback host and port") +if argument_host != host or argument_port != port: + raise SystemExit("postgres arguments did not match generated config") + +os.makedirs(database_dir, exist_ok=True) +with open( + os.path.join(data_dir, "postgres.started"), "w", encoding="utf-8" +) as started: + started.write(f"{host}:{port}\n") + + +def packet(message_type, payload=b""): + return message_type + struct.pack("!I", len(payload) + 4) + payload + + +def auth_ok(): + return packet(b"R", struct.pack("!I", 0)) + + +def parameter_status(key, value): + return packet(b"S", key.encode() + b"\0" + value.encode() + b"\0") + + +def backend_key_data(): + return packet(b"K", struct.pack("!II", os.getpid() & 0x7FFFFFFF, 1)) + + +def ready(): + return packet(b"Z", b"I") + + +def parameter_description(query): + if "$1" in query: + return packet(b"t", struct.pack("!H", 1) + struct.pack("!I", 25)) + return packet(b"t", struct.pack("!H", 0)) + + +def command_complete(tag): + return packet(b"C", tag.encode() + b"\0") + + +def parse_complete(): + return packet(b"1") + + +def bind_complete(): + return packet(b"2") + + +def close_complete(): + return packet(b"3") + + +def no_data(): + return packet(b"n") + + +def row_description(): + field = b"?column?\0" + struct.pack("!IhIhih", 0, 0, 23, 4, -1, 0) + return packet(b"T", struct.pack("!H", 1) + field) + + +def data_row(value): + data = str(value).encode() + return packet(b"D", struct.pack("!H", 1) + struct.pack("!I", len(data)) + data) + + +def error_response(message): + return packet(b"E", b"SERROR\0CXX000\0M" + message.encode() + b"\0\0") + + +def cstring(payload, start): + end = payload.index(b"\0", start) + return payload[start:end].decode(), end + 1 + + +def read_exact(stream, length): + data = b"" + while len(data) < length: + chunk = stream.recv(length - len(data)) + if not chunk: + raise EOFError + data += chunk + return data + + +def read_startup(stream): + length = struct.unpack("!I", read_exact(stream, 4))[0] + payload = read_exact(stream, length - 4) + code = struct.unpack("!I", payload[:4])[0] + if code == 80877103: + stream.sendall(b"N") + return read_startup(stream) + return payload + + +def startup_response(): + return b"".join( + [ + auth_ok(), + parameter_status("server_version", "16.0"), + parameter_status("server_encoding", "UTF8"), + parameter_status("client_encoding", "UTF8"), + parameter_status("DateStyle", "ISO, MDY"), + parameter_status("integer_datetimes", "on"), + parameter_status("standard_conforming_strings", "on"), + backend_key_data(), + ready(), + ] + ) + + +def database_file(database): + safe = "".join(character for character in database if character.isalnum() or character == "_") + if safe != database: + raise ValueError("unsafe database name") + return os.path.join(database_dir, database) + + +def database_exists(database): + return os.path.exists(database_file(database)) + + +def create_database(database): + with open(database_file(database), "w", encoding="utf-8") as marker: + marker.write(database + "\n") + + +def database_from_create(query): + quoted = query.split("CREATE DATABASE", 1)[1].strip() + if quoted.startswith('"') and quoted.endswith('"'): + return quoted[1:-1] + return quoted + + +def query_response(query, params): + normalized = " ".join(query.strip().split()) + if normalized.upper() in {"SELECT 1", "SELECT $1"}: + return row_description() + data_row(1) + command_complete("SELECT 1") + if "FROM pg_database WHERE datname" in normalized: + database = params[0] if params else "" + if database_exists(database): + return row_description() + data_row(1) + command_complete("SELECT 1") + return row_description() + command_complete("SELECT 0") + if normalized.upper().startswith("CREATE DATABASE"): + create_database(database_from_create(normalized)) + return command_complete("CREATE DATABASE") + if normalized.upper().startswith("SET "): + return command_complete("SET") + return error_response("unsupported fixture query: " + normalized) + + +class Handler(socketserver.BaseRequestHandler): + def handle(self): + handler_marker = os.environ.get("PV_FIXTURE_HANDLER_STARTED") + if handler_marker: + with open(handler_marker, "w", encoding="utf-8") as marker: + marker.write("started\n") + statements = {} + portals = {} + try: + read_startup(self.request) + self.request.sendall(startup_response()) + while True: + message_type = read_exact(self.request, 1) + length = struct.unpack("!I", read_exact(self.request, 4))[0] + payload = read_exact(self.request, length - 4) + if message_type == b"X": + return + if message_type == b"Q": + query = payload[:-1].decode() + self.request.sendall(query_response(query, []) + ready()) + continue + if message_type == b"P": + statement, offset = cstring(payload, 0) + query, _offset = cstring(payload, offset) + statements[statement] = query + self.request.sendall(parse_complete()) + continue + if message_type == b"B": + portal, offset = cstring(payload, 0) + statement, offset = cstring(payload, offset) + format_count = struct.unpack("!H", payload[offset : offset + 2])[0] + offset += 2 + (format_count * 2) + param_count = struct.unpack("!H", payload[offset : offset + 2])[0] + offset += 2 + params = [] + for _index in range(param_count): + size = struct.unpack("!i", payload[offset : offset + 4])[0] + offset += 4 + if size == -1: + params.append(None) + else: + params.append(payload[offset : offset + size].decode()) + offset += size + portals[portal] = (statements.get(statement, ""), params) + self.request.sendall(bind_complete()) + continue + if message_type == b"D": + describe_kind = payload[:1] + name = payload[1:-1].decode() + query, _params = portals.get(name, (statements.get(name, ""), [])) + response = b"" + if describe_kind == b"S": + response += parameter_description(query) + if query.strip().upper().startswith("CREATE DATABASE"): + response += no_data() + else: + response += row_description() + self.request.sendall(response) + continue + if message_type == b"E": + portal, offset = cstring(payload, 0) + _max_rows = struct.unpack("!I", payload[offset : offset + 4])[0] + query, params = portals.get(portal, ("", [])) + self.request.sendall(query_response(query, params)) + continue + if message_type == b"S": + self.request.sendall(ready()) + continue + if message_type == b"H": + continue + if message_type == b"C": + self.request.sendall(close_complete()) + continue + self.request.sendall(error_response("unsupported message type")) + except (EOFError, ConnectionResetError, BrokenPipeError): + return + + +class Server(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + daemon_threads = True + + +server = Server((host, port), Handler) +shutdown_requested = threading.Event() +shutdown_thread = None +received_signal = None + + +def stop(signum, _frame): + global received_signal, shutdown_thread + if not shutdown_requested.is_set(): + received_signal = signum + shutdown_requested.set() + shutdown_thread = threading.Thread(target=server.shutdown, daemon=True) + shutdown_thread.start() + + +signal.signal(signal.SIGTERM, stop) +signal.signal(signal.SIGINT, stop) + +server.serve_forever() +if shutdown_thread is not None: + shutdown_thread.join() +server.server_close() +if received_signal is not None: + signal.signal(received_signal, signal.SIG_DFL) + os.kill(os.getpid(), received_signal) diff --git a/crates/daemon/test-fixtures/managed-resources/redis-server.py b/crates/daemon/test-fixtures/managed-resources/redis-server.py new file mode 100644 index 00000000..0c2f327f --- /dev/null +++ b/crates/daemon/test-fixtures/managed-resources/redis-server.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +import os +import signal +import shlex +import socketserver +import sys +import threading + + +def redis_config(argv): + port = None + data_dir = None + arguments = list(argv) + while arguments: + argument = arguments.pop(0) + if argument == "--port" and arguments: + port = int(arguments.pop(0)) + elif argument == "--dir" and arguments: + data_dir = arguments.pop(0) + elif os.path.isfile(argument): + with open(argument, "r", encoding="utf-8") as config: + for line in config: + parts = shlex.split(line) + if len(parts) == 2 and parts[0] == "port": + port = int(parts[1]) + elif len(parts) == 2 and parts[0] == "dir": + data_dir = parts[1] + if port is None: + raise RuntimeError("missing Redis port") + return port, data_dir + + +class RedisPingHandler(socketserver.BaseRequestHandler): + def handle(self): + while True: + data = self.request.recv(4096) + if not data: + return + upper = data.upper() + responses = [] + for _index in range(upper.count(b"CLIENT")): + responses.append(b"+OK\r\n") + for _index in range(upper.count(b"PING")): + responses.append(b"+PONG\r\n") + if not responses: + responses.append(b"+OK\r\n") + self.request.sendall(b"".join(responses)) + + +class RedisServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + daemon_threads = True + + +shutdown_requested = threading.Event() +shutdown_thread = None +received_signal = None + + +def stop(signum, _frame): + global received_signal, shutdown_thread + if shutdown_requested.is_set(): + return + received_signal = signum + shutdown_requested.set() + shutdown_thread = threading.Thread(target=server.shutdown, daemon=True) + shutdown_thread.start() + + +port, data_dir = redis_config(sys.argv[1:]) +if data_dir: + os.makedirs(data_dir, exist_ok=True) + +server = RedisServer(("127.0.0.1", port), RedisPingHandler) +signal.signal(signal.SIGTERM, stop) +signal.signal(signal.SIGINT, stop) +server.serve_forever() +if shutdown_thread is not None: + shutdown_thread.join() +server.server_close() +if received_signal is not None: + signal.signal(received_signal, signal.SIG_DFL) + os.kill(os.getpid(), received_signal) diff --git a/crates/daemon/test-fixtures/managed-resources/rustfs.py.in b/crates/daemon/test-fixtures/managed-resources/rustfs.py.in new file mode 100644 index 00000000..104b7f42 --- /dev/null +++ b/crates/daemon/test-fixtures/managed-resources/rustfs.py.in @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +import hashlib +import http.server +import os +import posixpath +import signal +import sys +import threading +import urllib.parse + + +arguments = list(sys.argv[1:]) +api_address = "" +console_address = "" +data_dir = "" + +while arguments: + argument = arguments.pop(0) + if argument == "--address": + api_address = arguments.pop(0) + elif argument == "--console-address": + console_address = arguments.pop(0) + else: + data_dir = argument + +reject_s3 = __PV_REJECT_S3__ +buckets_dir = os.path.join(data_dir, "buckets") +os.makedirs(buckets_dir, exist_ok=True) +with open(os.path.join(data_dir, "process-env"), "w", encoding="utf-8") as file: + file.write(f"RUSTFS_ACCESS_KEY={os.environ.get('RUSTFS_ACCESS_KEY', '')}\n") + file.write(f"RUSTFS_SECRET_KEY={os.environ.get('RUSTFS_SECRET_KEY', '')}\n") + + +def split_address(value): + host, port = value.rsplit(":", 1) + return host, int(port) + + +def bucket_path(bucket): + return os.path.join(buckets_dir, bucket) + + +def object_path(bucket, key): + clean_key = posixpath.normpath("/" + key).lstrip("/") + return os.path.join(bucket_path(bucket), clean_key) + + +class RustfsHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + path = urllib.parse.urlparse(self.path).path + if path in {"/", "/health"}: + self.send_response(200) + self.end_headers() + self.wfile.write(b"rustfs") + return + + self.send_response(404) + self.end_headers() + + def do_PUT(self): + if reject_s3: + self.send_response(403) + self.end_headers() + return + + path = urllib.parse.urlparse(self.path).path.strip("/") + parts = path.split("/", 1) + length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(length) + if not parts or not parts[0]: + self.send_response(400) + self.end_headers() + return + + bucket = parts[0] + if len(parts) == 1: + os.makedirs(bucket_path(bucket), exist_ok=True) + self.send_response(200) + self.end_headers() + return + + target = object_path(bucket, parts[1]) + os.makedirs(os.path.dirname(target), exist_ok=True) + with open(target, "wb") as file: + file.write(body) + self.send_response(200) + self.send_header("ETag", hashlib.md5(body).hexdigest()) + self.end_headers() + + def do_HEAD(self): + if reject_s3: + self.send_response(403) + self.end_headers() + return + + path = urllib.parse.urlparse(self.path).path.strip("/") + parts = path.split("/", 1) + if len(parts) != 2: + exists = bool(parts and parts[0] and os.path.isdir(bucket_path(parts[0]))) + self.send_response(200 if exists else 404) + self.end_headers() + return + + target = object_path(parts[0], parts[1]) + if not os.path.exists(target): + self.send_response(404) + self.end_headers() + return + + size = os.path.getsize(target) + with open(target, "rb") as file: + digest = hashlib.md5(file.read()).hexdigest() + self.send_response(200) + self.send_header("Content-Length", str(size)) + self.send_header("ETag", digest) + self.end_headers() + + def log_message(self, _format, *_args): + return + + +class ConsoleHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b"rustfs console") + + def log_message(self, _format, *_args): + return + + +class Server(http.server.ThreadingHTTPServer): + allow_reuse_address = True + + +api = Server(split_address(api_address), RustfsHandler) +console = Server(split_address(console_address), ConsoleHandler) + +shutdown_requested = threading.Event() +shutdown_thread = None +received_signal = None + + +def shutdown_servers(): + console.shutdown() + api.shutdown() + + +def stop(signum, _frame): + global received_signal, shutdown_thread + if shutdown_requested.is_set(): + return + received_signal = signum + shutdown_requested.set() + shutdown_thread = threading.Thread(target=shutdown_servers, daemon=True) + shutdown_thread.start() + + +signal.signal(signal.SIGTERM, stop) +signal.signal(signal.SIGINT, stop) + +threading.Thread( + target=console.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True +).start() +api.serve_forever(poll_interval=0.1) +if shutdown_thread is not None: + shutdown_thread.join() +else: + console.shutdown() +api.server_close() +console.server_close() +if received_signal is not None: + signal.signal(received_signal, signal.SIG_DFL) + os.kill(os.getpid(), received_signal) diff --git a/crates/daemon/test-fixtures/supervisor/owned-python-runtime.py b/crates/daemon/test-fixtures/supervisor/owned-python-runtime.py new file mode 100644 index 00000000..2a124005 --- /dev/null +++ b/crates/daemon/test-fixtures/supervisor/owned-python-runtime.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +import signal +import sys + + +def stop(_signum, _frame): + sys.exit(0) + + +if sys.argv[1:] != ["1025", "8025"]: + sys.exit(2) + +signal.signal(signal.SIGTERM, stop) +signal.pause() diff --git a/crates/daemon/tests/fixture_contracts.rs b/crates/daemon/tests/fixture_contracts.rs new file mode 100644 index 00000000..d2693844 --- /dev/null +++ b/crates/daemon/tests/fixture_contracts.rs @@ -0,0 +1,1272 @@ +use std::io::{Error, ErrorKind, Read, Seek}; +use std::net::{Ipv4Addr, TcpListener, TcpStream}; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::process::{CommandExt, ExitStatusExt}; +use std::process::{Child, ExitStatus, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{Result, anyhow, bail}; +use camino::Utf8Path; +use camino_tempfile::{tempdir, tempfile}; +use insta::{Settings, assert_debug_snapshot}; +use rustix::io::Errno; +use rustix::process::{Pid, Signal, kill_process, kill_process_group, test_kill_process}; +use state::StateError; +use state::fs::ensure_user_dir; + +const FIXTURE_COMMAND_TIMEOUT: Duration = Duration::from_secs(3); +const FIXTURE_COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(10); +const FIXTURE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(1); +const FIXTURE_COMMAND_TIMEOUT_SCHEDULING_MARGIN: Duration = Duration::from_millis(100); +const FIXTURE_HANDLER_MARKER_CONTENTS: &str = "started\n"; +const POSTGRES_SHUTDOWN_INJECTION_MARKER_CONTENTS: &str = "injected\n"; + +const MYSQL_FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/mysql.py" +)); +const FAKE_MAILPIT_FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/fake-mailpit.py" +)); +const POSTGRES_FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/postgres.py" +)); +const REDIS_FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/redis-server.py" +)); +const MAILPIT_FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/mailpit.py" +)); +const RUSTFS_FIXTURE_TEMPLATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/rustfs.py.in" +)); +const RUSTFS_REJECT_S3_SENTINEL: &str = "__PV_REJECT_S3__"; +const HANGING_FIXTURE: &str = r#"#!/usr/bin/env python3 +import signal + +signal.pause() +"#; +const VERBOSE_FIXTURE: &str = r#"#!/usr/bin/env python3 +import sys + + +contents = "v" * (2 * 1024 * 1024) +sys.stdout.write(contents) +sys.stdout.flush() +sys.stderr.write(contents) +sys.stderr.flush() +"#; +const EXITED_FIXTURE: &str = r#"#!/usr/bin/env python3 +import sys + + +sys.exit(0) +"#; +const LIVE_PROCESS_GROUP_FIXTURE: &str = r#"#!/usr/bin/env python3 +import signal +import sys + + +with open(sys.argv[1], "w", encoding="utf-8") as marker: + marker.write("started\n") + +signal.pause() +"#; +const POSTGRES_SHUTDOWN_SITECUSTOMIZE: &str = r#"import os +import signal +import socketserver +import threading + + +injected = False + + +def inject(): + global injected + if injected: + return + injected = True + with open(os.environ["PV_POSTGRES_SHUTDOWN_MARKER"], "w", encoding="utf-8") as marker: + marker.write("injected\n") + os.kill(os.getpid(), signal.SIGTERM) + + +original_pause = signal.pause + + +def pause(): + inject() + return original_pause() + + +signal.pause = pause + +original_serve_forever = socketserver.BaseServer.serve_forever + + +def serve_forever(self, *args, **kwargs): + if threading.current_thread() is threading.main_thread(): + inject() + return original_serve_forever(self, *args, **kwargs) + + +socketserver.BaseServer.serve_forever = serve_forever +"#; + +#[expect( + clippy::disallowed_types, + reason = "daemon fixture contract tests execute materialized test programs" +)] +type FixtureCommand = std::process::Command; + +#[derive(Debug)] +struct FixtureOutput { + code: Option, + stdout: String, + stderr: String, +} + +#[derive(Clone, Copy)] +enum SingleServerFixture { + Mysql, + Postgres, + Redis, +} + +impl SingleServerFixture { + fn name(self) -> &'static str { + match self { + Self::Mysql => "MySQL", + Self::Postgres => "PostgreSQL", + Self::Redis => "Redis", + } + } + + fn executable_name(self) -> &'static str { + match self { + Self::Mysql => "mysqld", + Self::Postgres => "postgres", + Self::Redis => "redis-server", + } + } + + fn source(self) -> &'static str { + match self { + Self::Mysql => MYSQL_FIXTURE, + Self::Postgres => POSTGRES_FIXTURE, + Self::Redis => REDIS_FIXTURE, + } + } +} + +#[derive(Clone, Copy)] +enum MultiServerFixture { + FakeMailpit, + Mailpit, + Rustfs, +} + +impl MultiServerFixture { + fn name(self) -> &'static str { + match self { + Self::FakeMailpit => "fake Mailpit", + Self::Mailpit => "Mailpit", + Self::Rustfs => "RustFS", + } + } + + fn executable_name(self) -> &'static str { + match self { + Self::FakeMailpit => "fake-mailpit", + Self::Mailpit => "mailpit", + Self::Rustfs => "rustfs", + } + } +} + +#[test] +fn fixture_command_timeout_kills_and_reaps_child() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("hanging-fixture"); + let timeout = Duration::from_secs(1); + + materialize_fixture(&fixture, HANGING_FIXTURE)?; + let mut command = FixtureCommand::new(fixture.as_std_path()); + command.current_dir(tempdir.path()); + + let started_at = Instant::now(); + let mut raw_child_pid = None; + let error = match run_fixture_command(&mut command, timeout, Some(&mut raw_child_pid)) { + Ok(output) => bail!("hanging fixture unexpectedly exited: {output:?}"), + Err(error) => error, + }; + let io_error = error + .downcast_ref::() + .ok_or_else(|| anyhow!("fixture timeout did not return an I/O error: {error}"))?; + assert_eq!(io_error.kind(), ErrorKind::TimedOut); + assert!( + started_at.elapsed() + < timeout + + FIXTURE_SHUTDOWN_TIMEOUT + + FIXTURE_COMMAND_POLL_INTERVAL + + FIXTURE_COMMAND_POLL_INTERVAL + + FIXTURE_COMMAND_TIMEOUT_SCHEDULING_MARGIN, + "fixture command timeout exceeded its cleanup deadline" + ); + + let raw_child_pid = + raw_child_pid.ok_or_else(|| anyhow!("fixture command did not report its child PID"))?; + let child_pid = process_pid(raw_child_pid)?; + assert!(test_kill_process(child_pid).is_err()); + + Ok(()) +} + +#[test] +fn fixture_command_captures_verbose_output_without_pipe_backpressure() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("verbose-fixture"); + + materialize_fixture(&fixture, VERBOSE_FIXTURE)?; + let mut command = FixtureCommand::new(fixture.as_std_path()); + command.current_dir(tempdir.path()); + + let output = run_fixture_command(&mut command, FIXTURE_COMMAND_TIMEOUT, None)?; + assert_eq!(output.stdout.len(), 2 * 1024 * 1024); + assert_eq!(output.stderr.len(), 2 * 1024 * 1024); + assert_fixture_snapshot( + tempdir.path(), + "fixture_command_captures_verbose_output_without_pipe_backpressure", + (output.code, output.stdout.len(), output.stderr.len()), + ) +} + +#[test] +fn mysql_fixture_exits_after_sigterm_with_idle_client() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("mysqld"); + let handler_marker = tempdir.path().join("mysql-handler-started"); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let port = listener.local_addr()?.port(); + let port_argument = port.to_string(); + + materialize_fixture(&fixture, MYSQL_FIXTURE)?; + drop(listener); + + let mut child = FixtureCommand::new(fixture.as_std_path()) + .args(["--port", port_argument.as_str()]) + .current_dir(tempdir.path()) + .env("PV_FIXTURE_HANDLER_STARTED", handler_marker.as_std_path()) + .spawn()?; + let lifecycle = (|| { + let _idle_client = connect_to_loopback(port, FIXTURE_COMMAND_TIMEOUT)?; + wait_for_handler_marker(&handler_marker, FIXTURE_COMMAND_TIMEOUT)?; + kill_process(process_pid(child.id())?, Signal::TERM)?; + if !wait_for_child_exit(&mut child, FIXTURE_SHUTDOWN_TIMEOUT)? { + bail!("MySQL fixture did not exit after SIGTERM with an idle client"); + } + + Ok::<(), anyhow::Error>(()) + })(); + let cleanup = kill_and_reap_child(&mut child); + + if let Err(error) = lifecycle { + cleanup?; + return Err(error); + } + cleanup +} + +#[test] +fn postgres_fixture_exits_after_sigterm_with_idle_client() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("postgres"); + let data_dir = tempdir.path().join("postgres-data"); + let handler_marker = tempdir.path().join("postgres-handler-started"); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let port = listener.local_addr()?.port(); + let port_argument = port.to_string(); + + materialize_fixture(&fixture, POSTGRES_FIXTURE)?; + state::fs::write_sensitive_file(&data_dir.join("PG_VERSION"), "16\n")?; + state::fs::write_sensitive_file( + &data_dir.join("postgresql.conf"), + &format!("listen_addresses = '127.0.0.1'\nport = {port}\n"), + )?; + drop(listener); + + let mut child = FixtureCommand::new(fixture.as_std_path()) + .args([ + "-D", + data_dir.as_str(), + "-h", + "127.0.0.1", + "-p", + port_argument.as_str(), + ]) + .current_dir(tempdir.path()) + .env("PV_FIXTURE_HANDLER_STARTED", handler_marker.as_std_path()) + .spawn()?; + let lifecycle = (|| { + let _idle_client = connect_to_loopback(port, FIXTURE_COMMAND_TIMEOUT)?; + wait_for_handler_marker(&handler_marker, FIXTURE_COMMAND_TIMEOUT)?; + kill_process(process_pid(child.id())?, Signal::TERM)?; + if !wait_for_child_exit(&mut child, FIXTURE_SHUTDOWN_TIMEOUT)? { + bail!("PostgreSQL fixture did not exit after SIGTERM with an idle client"); + } + + Ok::<(), anyhow::Error>(()) + })(); + let cleanup = kill_and_reap_child(&mut child); + + if let Err(error) = lifecycle { + cleanup?; + return Err(error); + } + cleanup +} + +#[test] +fn postgres_fixture_shutdown_is_deterministic_after_sigterm() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("postgres"); + let data_dir = tempdir.path().join("postgres-data"); + let probe_dir = tempdir.path().join("probe"); + let shutdown_marker = tempdir.path().join("postgres-shutdown-injected"); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let port = listener.local_addr()?.port(); + let port_argument = port.to_string(); + + materialize_fixture(&fixture, POSTGRES_FIXTURE)?; + state::fs::write_sensitive_file( + &probe_dir.join("sitecustomize.py"), + POSTGRES_SHUTDOWN_SITECUSTOMIZE, + )?; + state::fs::write_sensitive_file(&data_dir.join("PG_VERSION"), "16\n")?; + state::fs::write_sensitive_file( + &data_dir.join("postgresql.conf"), + &format!("listen_addresses = '127.0.0.1'\nport = {port}\n"), + )?; + drop(listener); + + let mut child = FixtureCommand::new(fixture.as_std_path()) + .args([ + "-D", + data_dir.as_str(), + "-h", + "127.0.0.1", + "-p", + port_argument.as_str(), + ]) + .current_dir(tempdir.path()) + .env("PYTHONPATH", probe_dir.as_std_path()) + .env("PYTHONDONTWRITEBYTECODE", "1") + .env("PV_POSTGRES_SHUTDOWN_MARKER", shutdown_marker.as_std_path()) + .spawn()?; + let lifecycle = (|| { + let deadline = Instant::now() + FIXTURE_COMMAND_TIMEOUT; + loop { + match state::fs::read_to_string(&shutdown_marker) { + Ok(contents) if contents == POSTGRES_SHUTDOWN_INJECTION_MARKER_CONTENTS => break, + Ok(_) => {} + Err(StateError::Filesystem { source, .. }) + if source.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + if Instant::now() >= deadline { + bail!("timed out waiting for PostgreSQL shutdown injection marker"); + } + + thread::sleep(FIXTURE_COMMAND_POLL_INTERVAL); + } + + let deadline = Instant::now() + FIXTURE_SHUTDOWN_TIMEOUT; + let status = loop { + if let Some(status) = child.try_wait()? { + break status; + } + if Instant::now() >= deadline { + bail!("PostgreSQL fixture did not exit after injected SIGTERM"); + } + + thread::sleep(FIXTURE_COMMAND_POLL_INTERVAL); + }; + if status.signal() != Some(Signal::TERM.as_raw()) { + bail!("PostgreSQL fixture exited unexpectedly after injected SIGTERM: {status}"); + } + + Ok::<(), anyhow::Error>(()) + })(); + let cleanup = kill_and_reap_child(&mut child); + + if let Err(error) = lifecycle { + cleanup?; + return Err(error); + } + cleanup +} + +#[test] +fn single_server_fixture_exits_after_signal_status() -> Result<()> { + for fixture in [ + SingleServerFixture::Mysql, + SingleServerFixture::Postgres, + SingleServerFixture::Redis, + ] { + for signal in [Signal::TERM, Signal::INT] { + assert_single_server_fixture_exits_after_signal(fixture, signal)?; + } + } + + Ok(()) +} + +#[test] +fn multi_server_fixture_exits_after_signal_status() -> Result<()> { + for fixture in [ + MultiServerFixture::FakeMailpit, + MultiServerFixture::Mailpit, + MultiServerFixture::Rustfs, + ] { + for signal in [Signal::TERM, Signal::INT] { + assert_multi_server_fixture_exits_after_signal(fixture, signal)?; + } + } + + Ok(()) +} + +#[test] +fn process_group_cleanup_does_not_kill_recycled_group_after_child_reaped() -> Result<()> { + let tempdir = tempdir()?; + let exited_fixture = tempdir.path().join("exited-fixture"); + let live_fixture = tempdir.path().join("live-fixture"); + let live_marker = tempdir.path().join("live-fixture-started"); + + materialize_fixture(&exited_fixture, EXITED_FIXTURE)?; + materialize_fixture(&live_fixture, LIVE_PROCESS_GROUP_FIXTURE)?; + + let mut exited_command = FixtureCommand::new(exited_fixture.as_std_path()); + exited_command.current_dir(tempdir.path()).process_group(0); + let mut exited_child = exited_command.spawn()?; + let exited_status = wait_for_child_status(&mut exited_child, FIXTURE_COMMAND_TIMEOUT)? + .ok_or_else(|| anyhow!("exited fixture did not exit before the test setup completed"))?; + if !exited_status.success() { + bail!("exited fixture returned unexpected status {exited_status}"); + } + + let mut live_command = FixtureCommand::new(live_fixture.as_std_path()); + live_command + .arg(live_marker.as_std_path()) + .current_dir(tempdir.path()) + .process_group(0); + let mut live_child = live_command.spawn()?; + let simulated_recycled_process_group = process_pid(live_child.id())?; + let lifecycle = (|| { + wait_for_handler_marker(&live_marker, FIXTURE_COMMAND_TIMEOUT)?; + let cleanup_result = + kill_process_group_and_reap_child(&mut exited_child, simulated_recycled_process_group); + let live_group_was_alive = + wait_for_child_status(&mut live_child, FIXTURE_SHUTDOWN_TIMEOUT)?.is_none(); + + Ok::<_, anyhow::Error>((cleanup_result, live_group_was_alive)) + })(); + let cleanup = + kill_process_group_and_reap_child(&mut live_child, simulated_recycled_process_group); + + let (cleanup_result, live_group_was_alive) = match lifecycle { + Ok(result) => result, + Err(error) => { + cleanup?; + return Err(error); + } + }; + cleanup?; + assert!( + live_group_was_alive, + "cleanup killed the live process group through a recycled PGID" + ); + cleanup_result +} + +#[test] +fn fixture_handler_marker_requires_complete_contents() -> Result<()> { + let tempdir = tempdir()?; + let handler_marker = tempdir.path().join("handler-started"); + + state::fs::write_sensitive_file(&handler_marker, "started")?; + + let error = match wait_for_handler_marker(&handler_marker, Duration::ZERO) { + Ok(()) => bail!("incomplete fixture handler marker unexpectedly satisfied waiter"), + Err(error) => error, + }; + + assert_fixture_snapshot( + tempdir.path(), + "fixture_handler_marker_requires_complete_contents", + error.to_string(), + ) +} + +#[test] +fn mysql_fixture_cli_preserves_shell_contract() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("mysqld"); + let probe_dir = tempdir.path().join("probe"); + let probe_path = tempdir.path().join("mkdir-target"); + let rejected_data_dir = tempdir.path().join("rejected-data"); + let first_data_dir = tempdir.path().join("first-data"); + let selected_data_dir = tempdir.path().join("selected-data"); + + materialize_fixture(&fixture, MYSQL_FIXTURE)?; + state::fs::write_sensitive_file( + &probe_dir.join("sitecustomize.py"), + r#"import os + + +def record_makedirs(path, mode=0o777, exist_ok=False): + with open(os.environ["PV_MYSQL_MKDIR_PROBE"], "w", encoding="utf-8") as probe: + probe.write(os.fspath(path)) + + +os.makedirs = record_makedirs +"#, + )?; + + let first_argument_failure = run_fixture( + &fixture, + &[ + "--initialize-insecure", + "--no-defaults", + "--datadir", + rejected_data_dir.as_str(), + ], + tempdir.path(), + )?; + let successful_initialization = run_fixture( + &fixture, + &[ + "--no-defaults", + "--bind-address=127.0.0.1", + "--future-option", + "--initialize-insecure", + "--datadir", + first_data_dir.as_str(), + "--datadir", + selected_data_dir.as_str(), + "--basedir", + tempdir.path().as_str(), + ], + tempdir.path(), + )?; + let mut empty_data_dir_command = FixtureCommand::new(fixture.as_std_path()); + empty_data_dir_command + .args(["--no-defaults", "--initialize-insecure"]) + .current_dir(tempdir.path()) + .env("PYTHONPATH", &probe_dir) + .env("PYTHONDONTWRITEBYTECODE", "1") + .env("PV_MYSQL_MKDIR_PROBE", &probe_path); + let empty_data_dir_initialization = + run_fixture_command(&mut empty_data_dir_command, FIXTURE_COMMAND_TIMEOUT, None)?; + + assert_fixture_snapshot( + tempdir.path(), + "mysql_fixture_cli_preserves_shell_contract", + ( + first_argument_failure, + successful_initialization, + path_exists(&rejected_data_dir.join("mysql"))?, + path_exists(&first_data_dir.join("mysql"))?, + path_exists(&selected_data_dir.join("mysql"))?, + empty_data_dir_initialization, + state::fs::read_to_string(&probe_path)?, + ), + ) +} + +#[test] +fn fake_mailpit_fixture_cli_ignores_extra_arguments() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("fake-mailpit"); + let smtp_listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let smtp_port = smtp_listener.local_addr()?.port(); + let dashboard_listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let dashboard_port = dashboard_listener.local_addr()?.port(); + + materialize_fixture(&fixture, FAKE_MAILPIT_FIXTURE)?; + drop(smtp_listener); + drop(dashboard_listener); + + let mut child = FixtureCommand::new(fixture.as_std_path()) + .args([ + smtp_port.to_string(), + dashboard_port.to_string(), + "ignored-extra".to_owned(), + ]) + .current_dir(tempdir.path()) + .spawn()?; + let lifecycle = (|| { + let readiness = + wait_for_loopback_ports([smtp_port, dashboard_port], Duration::from_secs(3))?; + let running_after_readiness = child.try_wait()?.is_none(); + + Ok::<_, anyhow::Error>((readiness, running_after_readiness)) + })(); + let cleanup = kill_and_reap_child(&mut child); + + let lifecycle = match lifecycle { + Ok(lifecycle) => lifecycle, + Err(error) => { + cleanup?; + return Err(error); + } + }; + cleanup?; + + assert_fixture_snapshot( + tempdir.path(), + "fake_mailpit_fixture_cli_ignores_extra_arguments", + lifecycle, + ) +} + +#[test] +fn postgres_fixture_cli_preserves_shell_contract() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("postgres"); + let initialized_data_dir = tempdir.path().join("initialized-postgres"); + let selected_missing_data_dir = tempdir.path().join("selected-missing-postgres"); + + materialize_fixture(&fixture, POSTGRES_FIXTURE)?; + state::fs::write_sensitive_file(&initialized_data_dir.join("PG_VERSION"), "16\n")?; + + let unknown_argument = run_fixture(&fixture, &["--unexpected"], tempdir.path())?; + let last_data_dir_wins = run_fixture( + &fixture, + &[ + "-D", + initialized_data_dir.as_str(), + "-D", + selected_missing_data_dir.as_str(), + "-h", + "127.0.0.1", + "-p", + "5432", + ], + tempdir.path(), + )?; + + assert_fixture_snapshot( + tempdir.path(), + "postgres_fixture_cli_preserves_shell_contract", + (unknown_argument, last_data_dir_wins), + ) +} + +#[test] +fn mailpit_fixture_cli_preserves_shell_contract() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("mailpit"); + let missing_database = tempdir.path().join("missing/mailpit.db"); + + materialize_fixture(&fixture, MAILPIT_FIXTURE)?; + + let unknown_argument = run_fixture(&fixture, &["--unexpected"], tempdir.path())?; + let missing_required_arguments = + run_fixture(&fixture, &["--disable-version-check"], tempdir.path())?; + let missing_version_check = run_fixture( + &fixture, + &[ + "--smtp", + "127.0.0.1:1025", + "--listen", + "127.0.0.1:8025", + "--database", + missing_database.as_str(), + ], + tempdir.path(), + )?; + let invalid_database_path = run_fixture( + &fixture, + &[ + "--smtp", + "127.0.0.1:1025", + "--listen", + "127.0.0.1:8025", + "--database", + "mailpit.db", + "--disable-version-check", + ], + tempdir.path(), + )?; + let missing_database_directory = run_fixture( + &fixture, + &[ + "--smtp", + "127.0.0.1:1025", + "--listen", + "127.0.0.1:8025", + "--database", + missing_database.as_str(), + "--disable-version-check", + ], + tempdir.path(), + )?; + let duplicate_database_last_wins = run_fixture( + &fixture, + &[ + "--smtp", + "127.0.0.1:1025", + "--listen", + "127.0.0.1:8025", + "--database", + "mailpit.db", + "--database", + missing_database.as_str(), + "--disable-version-check", + ], + tempdir.path(), + )?; + + assert_fixture_snapshot( + tempdir.path(), + "mailpit_fixture_cli_preserves_shell_contract", + ( + unknown_argument, + missing_required_arguments, + missing_version_check, + invalid_database_path, + missing_database_directory, + duplicate_database_last_wins, + ), + ) +} + +#[test] +fn rustfs_fixture_cli_preserves_shell_contract() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("rustfs"); + let first_data_dir = tempdir.path().join("first-rustfs-data"); + let selected_data_dir = tempdir.path().join("selected-rustfs-data"); + let rendered = render_rustfs_fixture(false)?; + + materialize_fixture(&fixture, &rendered)?; + let output = run_fixture( + &fixture, + &[ + first_data_dir.as_str(), + "--future-option", + selected_data_dir.as_str(), + "--address", + "invalid-api-address", + "--console-address", + "invalid-console-address", + ], + tempdir.path(), + )?; + + assert_fixture_snapshot( + tempdir.path(), + "rustfs_fixture_cli_preserves_shell_contract", + ( + output.code, + output.stdout, + output.stderr.contains("ValueError"), + path_exists(&first_data_dir)?, + path_exists(&selected_data_dir.join("buckets"))?, + path_exists(&selected_data_dir.join("process-env"))?, + path_exists(&tempdir.path().join("invalid-api-address"))?, + path_exists(&tempdir.path().join("invalid-console-address"))?, + rendered.contains(RUSTFS_REJECT_S3_SENTINEL), + ), + ) +} + +fn render_rustfs_fixture(reject_s3: bool) -> Result { + let occurrence_count = RUSTFS_FIXTURE_TEMPLATE + .matches(RUSTFS_REJECT_S3_SENTINEL) + .count(); + if occurrence_count != 1 { + bail!( + "RustFS fixture must contain exactly one {RUSTFS_REJECT_S3_SENTINEL} sentinel; found {occurrence_count}" + ); + } + + let replacement = if reject_s3 { "True" } else { "False" }; + let rendered = RUSTFS_FIXTURE_TEMPLATE.replacen(RUSTFS_REJECT_S3_SENTINEL, replacement, 1); + if rendered.contains(RUSTFS_REJECT_S3_SENTINEL) { + bail!("RustFS fixture still contains {RUSTFS_REJECT_S3_SENTINEL} after rendering"); + } + + Ok(rendered) +} + +fn assert_single_server_fixture_exits_after_signal( + fixture: SingleServerFixture, + signal: Signal, +) -> Result<()> { + let tempdir = tempdir()?; + let executable = tempdir.path().join(fixture.executable_name()); + let port_reservation = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let port = port_reservation.local_addr()?.port(); + let port_argument = port.to_string(); + + materialize_fixture(&executable, fixture.source())?; + let mut command = FixtureCommand::new(executable.as_std_path()); + command.current_dir(tempdir.path()); + match fixture { + SingleServerFixture::Mysql => { + let data_dir = tempdir.path().join("mysql-data"); + let socket_path = tempdir.path().join("mysql.sock"); + let init_file = tempdir.path().join("mysql-init.sql"); + state::fs::write_sensitive_file(&init_file, "")?; + command.args([ + "--no-defaults", + "--datadir", + data_dir.as_str(), + "--bind-address=127.0.0.1", + "--port", + port_argument.as_str(), + "--mysqlx=0", + "--socket", + socket_path.as_str(), + "--init-file", + init_file.as_str(), + ]); + } + SingleServerFixture::Postgres => { + let data_dir = tempdir.path().join("postgres-data"); + state::fs::write_sensitive_file(&data_dir.join("PG_VERSION"), "16\n")?; + state::fs::write_sensitive_file( + &data_dir.join("postgresql.conf"), + &format!("listen_addresses = '127.0.0.1'\nport = {port}\n"), + )?; + command.args([ + "-D", + data_dir.as_str(), + "-h", + "127.0.0.1", + "-p", + port_argument.as_str(), + ]); + } + SingleServerFixture::Redis => { + let data_dir = tempdir.path().join("redis-data"); + let config_path = tempdir.path().join("redis.conf"); + state::fs::write_sensitive_file( + &config_path, + &format!( + "bind 127.0.0.1\nport {port}\ndir {}\nsave \"\"\nappendonly no\n", + data_dir.as_str() + ), + )?; + command.arg(config_path.as_std_path()); + } + } + drop(port_reservation); + + command.process_group(0); + let mut child = command.spawn()?; + let process_group = process_pid(child.id())?; + let lifecycle = (|| { + let readiness = wait_for_loopback_ports([port], FIXTURE_COMMAND_TIMEOUT)?; + if readiness != [true] { + bail!( + "{} fixture did not become ready on port {port}", + fixture.name() + ); + } + + kill_process_group(process_group, signal)?; + let status = + wait_for_child_status(&mut child, FIXTURE_SHUTDOWN_TIMEOUT)?.ok_or_else(|| { + anyhow!( + "{} fixture did not exit after signal {}", + fixture.name(), + signal.as_raw() + ) + })?; + if status.signal() != Some(signal.as_raw()) { + bail!( + "{} fixture exited with {status} after signal {}; expected signal status {}", + fixture.name(), + signal.as_raw(), + signal.as_raw() + ); + } + + Ok::<(), anyhow::Error>(()) + })(); + let cleanup = kill_process_group_and_reap_child(&mut child, process_group); + + if let Err(error) = lifecycle { + cleanup?; + return Err(error); + } + cleanup +} + +fn assert_multi_server_fixture_exits_after_signal( + fixture: MultiServerFixture, + signal: Signal, +) -> Result<()> { + let tempdir = tempdir()?; + let executable = tempdir.path().join(fixture.executable_name()); + let first_port_reservation = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let first_port = first_port_reservation.local_addr()?.port(); + let second_port_reservation = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let second_port = second_port_reservation.local_addr()?.port(); + let first_address = format!("127.0.0.1:{first_port}"); + let second_address = format!("127.0.0.1:{second_port}"); + let source = match fixture { + MultiServerFixture::FakeMailpit => FAKE_MAILPIT_FIXTURE.to_owned(), + MultiServerFixture::Mailpit => MAILPIT_FIXTURE.to_owned(), + MultiServerFixture::Rustfs => render_rustfs_fixture(false)?, + }; + + materialize_fixture(&executable, &source)?; + let mut command = FixtureCommand::new(executable.as_std_path()); + command.current_dir(tempdir.path()); + match fixture { + MultiServerFixture::FakeMailpit => { + command.args([first_port.to_string(), second_port.to_string()]); + } + MultiServerFixture::Mailpit => { + let data_dir = tempdir.path().join("mailpit-data"); + ensure_user_dir(&data_dir)?; + let database = data_dir.join("mailpit.db"); + command.args([ + "--smtp", + first_address.as_str(), + "--listen", + second_address.as_str(), + "--database", + database.as_str(), + "--disable-version-check", + ]); + } + MultiServerFixture::Rustfs => { + let data_dir = tempdir.path().join("rustfs-data"); + ensure_user_dir(&data_dir)?; + command.args([ + "--address", + first_address.as_str(), + "--console-address", + second_address.as_str(), + data_dir.as_str(), + ]); + } + } + drop(first_port_reservation); + drop(second_port_reservation); + + command.process_group(0); + let mut child = command.spawn()?; + let process_group = process_pid(child.id())?; + let lifecycle = (|| { + let readiness = + wait_for_loopback_ports([first_port, second_port], FIXTURE_COMMAND_TIMEOUT)?; + if readiness != [true, true] { + bail!( + "{} fixture did not become ready on ports {first_port} and {second_port}", + fixture.name() + ); + } + + kill_process_group(process_group, signal)?; + let status = + wait_for_child_status(&mut child, FIXTURE_SHUTDOWN_TIMEOUT)?.ok_or_else(|| { + anyhow!( + "{} fixture did not exit after signal {}", + fixture.name(), + signal.as_raw() + ) + })?; + if status.signal() != Some(signal.as_raw()) { + bail!( + "{} fixture exited with {status} after signal {}; expected signal status {}", + fixture.name(), + signal.as_raw(), + signal.as_raw() + ); + } + + Ok::<(), anyhow::Error>(()) + })(); + let cleanup = kill_process_group_and_reap_child(&mut child, process_group); + + if let Err(error) = lifecycle { + cleanup?; + return Err(error); + } + cleanup +} + +fn materialize_fixture(path: &Utf8Path, source: &str) -> Result<()> { + state::fs::write_sensitive_file(path, source)?; + set_executable(path) +} + +fn run_fixture( + path: &Utf8Path, + arguments: &[&str], + current_dir: &Utf8Path, +) -> Result { + let mut command = FixtureCommand::new(path.as_std_path()); + command.args(arguments).current_dir(current_dir); + + run_fixture_command(&mut command, FIXTURE_COMMAND_TIMEOUT, None) +} + +fn run_fixture_command( + command: &mut FixtureCommand, + timeout: Duration, + child_pid: Option<&mut Option>, +) -> Result { + let mut stdout = tempfile()?; + let mut stderr = tempfile()?; + command + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout.try_clone()?)) + .stderr(Stdio::from(stderr.try_clone()?)); + let mut child = command.spawn()?; + if let Some(child_pid) = child_pid { + *child_pid = Some(child.id()); + } + let deadline = Instant::now() + timeout; + + let error = loop { + match child.try_wait() { + Ok(Some(status)) => return fixture_output(status, &mut stdout, &mut stderr), + Ok(None) => {} + Err(error) => break error.into(), + } + if Instant::now() >= deadline { + break Error::new( + ErrorKind::TimedOut, + format!("fixture command timed out after {} ms", timeout.as_millis()), + ) + .into(); + } + + thread::sleep(FIXTURE_COMMAND_POLL_INTERVAL); + }; + + kill_and_reap_child(&mut child)?; + Err(error) +} + +fn fixture_output( + status: ExitStatus, + stdout: &mut (impl Read + Seek), + stderr: &mut (impl Read + Seek), +) -> Result { + Ok(FixtureOutput { + code: status.code(), + stdout: String::from_utf8(read_fixture_output(stdout)?)?, + stderr: String::from_utf8(read_fixture_output(stderr)?)?, + }) +} + +fn read_fixture_output(file: &mut (impl Read + Seek)) -> Result> { + file.rewind()?; + let mut contents = Vec::new(); + file.read_to_end(&mut contents)?; + + Ok(contents) +} + +fn process_pid(pid: u32) -> Result { + let raw_pid = i32::try_from(pid)?; + Pid::from_raw(raw_pid).ok_or_else(|| anyhow!("invalid process id {raw_pid}")) +} + +fn wait_for_loopback_ports( + ports: [u16; PORT_COUNT], + timeout: Duration, +) -> Result<[bool; PORT_COUNT]> { + let deadline = Instant::now() + timeout; + let mut readiness = [false; PORT_COUNT]; + + loop { + for (index, port) in ports.into_iter().enumerate() { + if !readiness[index] && TcpStream::connect((Ipv4Addr::LOCALHOST, port)).is_ok() { + readiness[index] = true; + } + } + + if readiness.iter().all(|ready| *ready) { + return Ok(readiness); + } + if Instant::now() >= deadline { + bail!("timed out waiting for fixture ports {ports:?}; readiness: {readiness:?}"); + } + + thread::sleep(Duration::from_millis(10)); + } +} + +fn connect_to_loopback(port: u16, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + + loop { + if let Ok(stream) = TcpStream::connect((Ipv4Addr::LOCALHOST, port)) { + return Ok(stream); + } + if Instant::now() >= deadline { + bail!("timed out connecting to fixture port {port}"); + } + + thread::sleep(FIXTURE_COMMAND_POLL_INTERVAL); + } +} + +fn wait_for_handler_marker(path: &Utf8Path, timeout: Duration) -> Result<()> { + let deadline = Instant::now() + timeout; + + loop { + match state::fs::read_to_string(path) { + Ok(contents) if contents == FIXTURE_HANDLER_MARKER_CONTENTS => return Ok(()), + Ok(_) => {} + Err(StateError::Filesystem { source, .. }) if source.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + if Instant::now() >= deadline { + bail!("timed out waiting for fixture handler marker at {path}"); + } + + thread::sleep(FIXTURE_COMMAND_POLL_INTERVAL); + } +} + +fn wait_for_child_exit(child: &mut Child, timeout: Duration) -> Result { + Ok(wait_for_child_status(child, timeout)?.is_some()) +} + +fn wait_for_child_status(child: &mut Child, timeout: Duration) -> Result> { + let deadline = Instant::now() + timeout; + + loop { + if let Some(status) = child.try_wait()? { + return Ok(Some(status)); + } + if Instant::now() >= deadline { + return Ok(None); + } + + thread::sleep(FIXTURE_COMMAND_POLL_INTERVAL); + } +} + +fn kill_process_group_and_reap_child(child: &mut Child, process_group: Pid) -> Result<()> { + if child.try_wait()?.is_some() { + return Ok(()); + } + + let kill_error = match kill_process_group(process_group, Signal::KILL) { + Ok(()) | Err(Errno::SRCH) => None, + Err(error) => Some(error), + }; + let reap_result = wait_for_child_status(child, FIXTURE_SHUTDOWN_TIMEOUT); + + if let Some(error) = kill_error { + return Err(error.into()); + } + if reap_result?.is_none() { + return Err(Error::new( + ErrorKind::TimedOut, + format!( + "timed out reaping fixture process-group leader after {} ms", + FIXTURE_SHUTDOWN_TIMEOUT.as_millis() + ), + ) + .into()); + } + + Ok(()) +} + +fn kill_and_reap_child(child: &mut Child) -> Result<()> { + let kill_error = match child.kill() { + Ok(()) => None, + Err(error) if error.kind() == ErrorKind::InvalidInput => None, + Err(error) => Some(error), + }; + let reap_result = wait_for_child_exit(child, FIXTURE_SHUTDOWN_TIMEOUT); + + if let Some(error) = kill_error { + return Err(error.into()); + } + if !reap_result? { + return Err(Error::new( + ErrorKind::TimedOut, + format!( + "timed out reaping fixture child after {} ms", + FIXTURE_SHUTDOWN_TIMEOUT.as_millis() + ), + ) + .into()); + } + + Ok(()) +} + +fn assert_fixture_snapshot( + tempdir: &Utf8Path, + name: &'static str, + snapshot: impl std::fmt::Debug, +) -> Result<()> { + let mut settings = Settings::clone_current(); + settings.add_filter(®ex_literal(tempdir.as_str()), ""); + settings.add_filter("/private", ""); + settings.bind(|| { + assert_debug_snapshot!(name, snapshot); + Ok::<(), anyhow::Error>(()) + }) +} + +#[expect( + clippy::disallowed_methods, + reason = "daemon fixture contract tests inspect fixture filesystem effects directly" +)] +fn path_exists(path: &Utf8Path) -> Result { + match std::fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } +} + +#[expect( + clippy::disallowed_methods, + reason = "daemon fixture contract tests set materialized fixture executable bits directly" +)] +fn set_executable(path: &Utf8Path) -> Result<()> { + let mut permissions = std::fs::metadata(path)?.permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions)?; + + Ok(()) +} + +fn regex_literal(value: &str) -> String { + let mut literal = String::new(); + + for character in value.chars() { + if matches!( + character, + '\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' + ) { + literal.push('\\'); + } + literal.push(character); + } + + literal +} diff --git a/crates/daemon/tests/gateway_reconciliation.rs b/crates/daemon/tests/gateway_reconciliation.rs index 3bd86730..26e99f33 100644 --- a/crates/daemon/tests/gateway_reconciliation.rs +++ b/crates/daemon/tests/gateway_reconciliation.rs @@ -1,5 +1,5 @@ -use anyhow::Result; -use camino::Utf8Path; +use anyhow::{Result, bail}; +use camino::{Utf8Path, Utf8PathBuf}; use camino_tempfile::tempdir; use daemon::DaemonError; use daemon::gateway::{ @@ -23,6 +23,23 @@ use std::time::Duration; use tokio::time::{sleep, timeout}; const GATEWAY_RECONCILIATION_SUMMARY: &str = "Gateway runtime reconciled"; +const FAKE_FRANKENPHP_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/gateway/fake-frankenphp.sh" +)); +const FAKE_FRANKENPHP_SERVER_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/gateway/fake-frankenphp-server.py" +)); +const FAKE_FRANKENPHP_HANGS_ON_PORT_SCRIPT_TEMPLATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/gateway/fake-frankenphp-hangs-on-port.sh.in" +)); +const FAKE_FRANKENPHP_HANGS_ON_PORT_SERVER_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/gateway/fake-frankenphp-hangs-on-port-server.py" +)); +const FAKE_FRANKENPHP_BLOCKED_PORT_SENTINEL: &str = "__PV_BLOCKED_PORT__"; #[expect( clippy::disallowed_types, @@ -1757,131 +1774,50 @@ exit 2 } fn write_fake_frankenphp(path: &Utf8Path) -> Result<()> { - fs::write_sensitive_file( - path, - r#"#!/bin/sh -set -eu + write_fake_frankenphp_fixture(path, FAKE_FRANKENPHP_SCRIPT, FAKE_FRANKENPHP_SERVER_SCRIPT) +} -if [ "$1" = "validate" ]; then - test -f "$3" - exit 0 -fi +fn write_fake_frankenphp_that_hangs_on_port(path: &Utf8Path, blocked_port: u16) -> Result<()> { + let script = render_fake_frankenphp_hangs_on_port_script(blocked_port)?; -if [ "$1" = "run" ]; then - python3 - "$3" <<'PY' & -import http.server -import re -import signal -import ssl -import sys -import threading - -signal.signal(signal.SIGUSR1, signal.SIG_IGN) - -config = open(sys.argv[1], encoding="utf-8").read() - -def required(pattern): - match = re.search(pattern, config, re.MULTILINE) - if not match: - raise SystemExit(f"missing fake runtime setting: {pattern}") - return match.group(1) - -def optional(pattern): - match = re.search(pattern, config, re.MULTILINE) - if not match: - return None - return match.group(1) - -class Handler(http.server.SimpleHTTPRequestHandler): - def log_message(self, format, *args): - pass - -http_port = int(required(r"^# PV_FAKE_PORT (\d+)$")) -https_port = optional(r"^\s*https_port (\d+)$") -cert_path = optional(r'^\s*cert "([^"]+)"$') -key_path = optional(r'^\s*key "([^"]+)"$') -servers = [http.server.ThreadingHTTPServer(("127.0.0.1", http_port), Handler)] - -if https_port is not None and cert_path is not None and key_path is not None: - context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - context.load_cert_chain(certfile=cert_path, keyfile=key_path) - https_server = http.server.ThreadingHTTPServer(("127.0.0.1", int(https_port)), Handler) - https_server.socket = context.wrap_socket(https_server.socket, server_side=True) - servers.append(https_server) - -for server in servers[1:]: - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - -with servers[0] as server: - server.serve_forever() -PY - child="$!" - trap ':' USR1 - trap 'kill "$child"; wait "$child"; exit 0' TERM INT - while true; do - wait "$child" && exit 0 - status="$?" - if kill -0 "$child" 2>/dev/null; then - continue - fi - exit "$status" - done -fi + write_fake_frankenphp_fixture(path, &script, FAKE_FRANKENPHP_HANGS_ON_PORT_SERVER_SCRIPT) +} -exit 2 -"#, - )?; +fn write_fake_frankenphp_fixture( + path: &Utf8Path, + shell_script: &str, + server_script: &str, +) -> Result<()> { + let server_path = Utf8PathBuf::from(format!("{path}.server.py")); + + fs::write_sensitive_file(&server_path, server_script)?; + fs::write_sensitive_file(path, shell_script)?; set_executable(path)?; Ok(()) } -fn write_fake_frankenphp_that_hangs_on_port(path: &Utf8Path, blocked_port: u16) -> Result<()> { - fs::write_sensitive_file( - path, - &format!( - r#"#!/bin/sh -set -eu - -if [ "$1" = "validate" ]; then - test -f "$3" - exit 0 -fi - -if [ "$1" = "run" ]; then - port="$(awk '/^# PV_FAKE_PORT / {{ print $3; exit }}' "$3")" - if [ "$port" = "{}" ]; then - sleep 30 - exit 0 - fi - python3 -c 'import http.server, signal, sys -signal.signal(signal.SIGUSR1, signal.SIG_IGN) -port = int(sys.argv[1]) -with http.server.ThreadingHTTPServer(("127.0.0.1", port), http.server.SimpleHTTPRequestHandler) as server: - server.serve_forever() -' "$port" & - child="$!" - trap ':' USR1 - trap 'kill "$child"; wait "$child"; exit 0' TERM INT - while true; do - wait "$child" && exit 0 - status="$?" - if kill -0 "$child" 2>/dev/null; then - continue - fi - exit "$status" - done -fi +fn render_fake_frankenphp_hangs_on_port_script(blocked_port: u16) -> Result { + let occurrence_count = FAKE_FRANKENPHP_HANGS_ON_PORT_SCRIPT_TEMPLATE + .matches(FAKE_FRANKENPHP_BLOCKED_PORT_SENTINEL) + .count(); + if occurrence_count != 1 { + bail!( + "fake FrankenPHP blocked-port template must contain exactly one {FAKE_FRANKENPHP_BLOCKED_PORT_SENTINEL} sentinel; found {occurrence_count}" + ); + } -exit 2 -"#, - blocked_port - ), - )?; - set_executable(path)?; + let replacement = blocked_port.to_string(); + let script = FAKE_FRANKENPHP_HANGS_ON_PORT_SCRIPT_TEMPLATE.replacen( + FAKE_FRANKENPHP_BLOCKED_PORT_SENTINEL, + &replacement, + 1, + ); + if script.contains(FAKE_FRANKENPHP_BLOCKED_PORT_SENTINEL) { + bail!("fake FrankenPHP blocked-port sentinel remained after rendering"); + } - Ok(()) + Ok(script) } fn shell_single_quoted(value: &str) -> String { diff --git a/crates/daemon/tests/snapshots/fixture_contracts__fake_mailpit_fixture_cli_ignores_extra_arguments.snap b/crates/daemon/tests/snapshots/fixture_contracts__fake_mailpit_fixture_cli_ignores_extra_arguments.snap new file mode 100644 index 00000000..651eed23 --- /dev/null +++ b/crates/daemon/tests/snapshots/fixture_contracts__fake_mailpit_fixture_cli_ignores_extra_arguments.snap @@ -0,0 +1,11 @@ +--- +source: crates/daemon/tests/fixture_contracts.rs +expression: snapshot +--- +( + [ + true, + true, + ], + true, +) diff --git a/crates/daemon/tests/snapshots/fixture_contracts__fixture_command_captures_verbose_output_without_pipe_backpressure.snap b/crates/daemon/tests/snapshots/fixture_contracts__fixture_command_captures_verbose_output_without_pipe_backpressure.snap new file mode 100644 index 00000000..0dcd81c6 --- /dev/null +++ b/crates/daemon/tests/snapshots/fixture_contracts__fixture_command_captures_verbose_output_without_pipe_backpressure.snap @@ -0,0 +1,11 @@ +--- +source: crates/daemon/tests/fixture_contracts.rs +expression: snapshot +--- +( + Some( + 0, + ), + 2097152, + 2097152, +) diff --git a/crates/daemon/tests/snapshots/fixture_contracts__fixture_handler_marker_requires_complete_contents.snap b/crates/daemon/tests/snapshots/fixture_contracts__fixture_handler_marker_requires_complete_contents.snap new file mode 100644 index 00000000..02728835 --- /dev/null +++ b/crates/daemon/tests/snapshots/fixture_contracts__fixture_handler_marker_requires_complete_contents.snap @@ -0,0 +1,5 @@ +--- +source: crates/daemon/tests/fixture_contracts.rs +expression: snapshot +--- +"timed out waiting for fixture handler marker at /handler-started" diff --git a/crates/daemon/tests/snapshots/fixture_contracts__mailpit_fixture_cli_preserves_shell_contract.snap b/crates/daemon/tests/snapshots/fixture_contracts__mailpit_fixture_cli_preserves_shell_contract.snap new file mode 100644 index 00000000..8ffb5cbe --- /dev/null +++ b/crates/daemon/tests/snapshots/fixture_contracts__mailpit_fixture_cli_preserves_shell_contract.snap @@ -0,0 +1,48 @@ +--- +source: crates/daemon/tests/fixture_contracts.rs +expression: snapshot +--- +( + FixtureOutput { + code: Some( + 2, + ), + stdout: "", + stderr: "unexpected argument: --unexpected\n", + }, + FixtureOutput { + code: Some( + 2, + ), + stdout: "", + stderr: "missing required mailpit argument\n", + }, + FixtureOutput { + code: Some( + 2, + ), + stdout: "", + stderr: "missing --disable-version-check\n", + }, + FixtureOutput { + code: Some( + 2, + ), + stdout: "", + stderr: "unexpected database path: mailpit.db\n", + }, + FixtureOutput { + code: Some( + 2, + ), + stdout: "", + stderr: "database directory does not exist: /missing\n", + }, + FixtureOutput { + code: Some( + 2, + ), + stdout: "", + stderr: "database directory does not exist: /missing\n", + }, +) diff --git a/crates/daemon/tests/snapshots/fixture_contracts__mysql_fixture_cli_preserves_shell_contract.snap b/crates/daemon/tests/snapshots/fixture_contracts__mysql_fixture_cli_preserves_shell_contract.snap new file mode 100644 index 00000000..5fdbb3f5 --- /dev/null +++ b/crates/daemon/tests/snapshots/fixture_contracts__mysql_fixture_cli_preserves_shell_contract.snap @@ -0,0 +1,31 @@ +--- +source: crates/daemon/tests/fixture_contracts.rs +expression: snapshot +--- +( + FixtureOutput { + code: Some( + 64, + ), + stdout: "", + stderr: "mysqld initialization must start with --no-defaults\n", + }, + FixtureOutput { + code: Some( + 0, + ), + stdout: "", + stderr: "", + }, + false, + false, + true, + FixtureOutput { + code: Some( + 0, + ), + stdout: "", + stderr: "", + }, + "/mysql", +) diff --git a/crates/daemon/tests/snapshots/fixture_contracts__postgres_fixture_cli_preserves_shell_contract.snap b/crates/daemon/tests/snapshots/fixture_contracts__postgres_fixture_cli_preserves_shell_contract.snap new file mode 100644 index 00000000..065fda01 --- /dev/null +++ b/crates/daemon/tests/snapshots/fixture_contracts__postgres_fixture_cli_preserves_shell_contract.snap @@ -0,0 +1,20 @@ +--- +source: crates/daemon/tests/fixture_contracts.rs +expression: snapshot +--- +( + FixtureOutput { + code: Some( + 64, + ), + stdout: "", + stderr: "unexpected postgres argument: --unexpected\n", + }, + FixtureOutput { + code: Some( + 64, + ), + stdout: "", + stderr: "postgres data dir is not initialized\n", + }, +) diff --git a/crates/daemon/tests/snapshots/fixture_contracts__rustfs_fixture_cli_preserves_shell_contract.snap b/crates/daemon/tests/snapshots/fixture_contracts__rustfs_fixture_cli_preserves_shell_contract.snap new file mode 100644 index 00000000..959dcc7a --- /dev/null +++ b/crates/daemon/tests/snapshots/fixture_contracts__rustfs_fixture_cli_preserves_shell_contract.snap @@ -0,0 +1,17 @@ +--- +source: crates/daemon/tests/fixture_contracts.rs +expression: snapshot +--- +( + Some( + 1, + ), + "", + true, + false, + true, + true, + false, + false, + false, +) diff --git a/crates/daemon/tests/supervisor_foundation.rs b/crates/daemon/tests/supervisor_foundation.rs index ad602d01..1ad327de 100644 --- a/crates/daemon/tests/supervisor_foundation.rs +++ b/crates/daemon/tests/supervisor_foundation.rs @@ -26,6 +26,12 @@ use tokio_rustls::TlsAcceptor; )] type TestProcessCommand = std::process::Command; +#[cfg(target_os = "macos")] +const OWNED_PYTHON_RUNTIME_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/supervisor/owned-python-runtime.py" +)); + #[tokio::test] async fn tcp_readiness_succeeds_for_listening_ports_and_times_out() -> Result<()> { let listener = TcpListener::bind(("127.0.0.1", 0)).await?; @@ -477,24 +483,7 @@ async fn supervisor_verifies_owned_python_shebang_script() -> Result<()> { let paths = PvPaths::for_home(tempdir.path().join("home")); state::fs::ensure_layout(&paths)?; let runtime = paths.run().join("owned-python-runtime"); - state::fs::write_sensitive_file( - &runtime, - r#"#!/usr/bin/env python3 -import signal -import sys - - -def stop(_signum, _frame): - sys.exit(0) - - -if sys.argv[1:] != ["1025", "8025"]: - sys.exit(2) - -signal.signal(signal.SIGTERM, stop) -signal.pause() -"#, - )?; + state::fs::write_sensitive_file(&runtime, OWNED_PYTHON_RUNTIME_SCRIPT)?; set_executable(&runtime)?; let supervisor = ProcessSupervisor::new(paths.clone()); diff --git a/docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md b/docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md new file mode 100644 index 00000000..6df858a8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md @@ -0,0 +1,2233 @@ +# Daemon Test Fixture Extraction Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace substantial daemon-test Python and shell raw strings with standalone fixture assets while preserving their observable CLI, protocol, lifecycle, and supervision contracts. + +**Architecture:** Fixture source lives under `crates/daemon/test-fixtures/` and is embedded into test binaries with compile-time `include_str!`. Existing test helpers continue writing private temporary copies and setting executable mode; only RustFS reject mode and the blocked FrankenPHP port use exact-once sentinel rendering. Direct Python fixtures replace inert parsing/forwarding shells, while gateway fixtures retain their behaviorally relevant shell parents and materialize sibling Python server files. + +**Tech Stack:** Rust 2024 workspace, `include_str!`, `anyhow`, `camino`, `camino-tempfile`, `insta`, Python 3 standard library, POSIX `sh`, `shellcheck`, Cargo nextest. + +## Global Constraints + +- Consult `DESIGN.md` before any implementation decision not already settled by the approved [design spec](../specs/2026-07-15-daemon-test-fixtures-design.md); ask the user if neither document answers it. +- Scope is daemon-only. Do not change `crates/pv-release`, release recipes, release workflows, CI scheduling, nextest configuration, fixture timing, or production daemon/supervisor code. +- Python fixtures must use only Python 3's standard library. Do not add Node, containers, real third-party daemons, Python packages, a requirements file, or a virtual environment. +- Direct Python is limited to the approved parsing/forwarding-only wrappers. Preserve the gateway shell parent, child wait loop, signal traps, and exit propagation. +- Keep short scenario-local scripts inline, including `fake_sql_script`, validator failure injectors, and dynamic environment/descendant-PID observers. +- Load fixture text with crate-local compile-time `include_str!`; do not add a registry, generic fixture loader, template engine, process harness, or cross-crate abstraction. +- Use only the exact `__PV_REJECT_S3__` and `__PV_BLOCKED_PORT__` sentinels. Require exactly one occurrence and verify none remains after typed `bool`/`u16` substitution. +- Checked-in fixture files remain mode `100644`. Materialized executable copies keep the existing explicit executable mode. +- Directly executed files start with their shebang at byte zero and use LF line endings. Shell fixtures use `#!/bin/sh` and POSIX syntax; no Bash feature is allowed. +- Preserve arguments, validation, stable exit codes/messages, filesystem effects, ports, protocol responses, signal outcomes, process-group ownership, and supervisor-visible executable paths described by the spec. +- Manual Python parsers must preserve permissive behavior as well as rejection behavior; do not use `argparse`. +- Preserve Redis/RustFS helper-thread shutdown and fast-exit Mailpit's flushed HTTP 200 followed by `os._exit(0)` exactly. +- Existing snapshots and assertions must not change. Only the new fixture-contract snapshots may be added. +- Prefer integration coverage and nearby `insta` patterns. Avoid `panic!`, `unreachable!`, `.unwrap()`, `.expect()`, unsafe code, Clippy ignores, and shortened variable names. +- Use Conventional Commit messages exactly as listed in each task. + +### Post-Review Amendment + +The approved [post-review corrections](../specs/2026-07-15-daemon-test-fixtures-design.md#post-review-corrections) supersede this plan only where it previously prohibited fixture timing and shutdown corrections. Follow the [review-corrections implementation plan](2026-07-16-daemon-fixture-review-corrections.md) for the bounded fixture-contract runner, custom `ThreadingMixIn` normalization, their regression tests, and their separate commits. Every other constraint and completed task in this plan remains unchanged. + +## File Map + +**Create managed-resource fixtures:** + +- `crates/daemon/test-fixtures/managed-resources/mysql.py` — fake `mysqld` initialization and TCP server. +- `crates/daemon/test-fixtures/managed-resources/fake-mailpit.py` — positional two-port fake Mailpit runtime. +- `crates/daemon/test-fixtures/managed-resources/mailpit.py` — adapter-compatible Mailpit CLI and SMTP/dashboard servers. +- `crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py` — readiness response followed by immediate process exit. +- `crates/daemon/test-fixtures/managed-resources/mailpit-unready.sh` — TERM-aware non-ready runtime. +- `crates/daemon/test-fixtures/managed-resources/postgres.py` — Postgres CLI validation and wire-protocol server. +- `crates/daemon/test-fixtures/managed-resources/postgres-initdb.sh` — fake `initdb` filesystem CLI. +- `crates/daemon/test-fixtures/managed-resources/postgres-unready.sh` — validated but non-ready Postgres runtime. +- `crates/daemon/test-fixtures/managed-resources/redis-server.py` — fake Redis config parser, RESP responses, and safe shutdown. +- `crates/daemon/test-fixtures/managed-resources/rustfs.py.in` — rendered fake RustFS API/console server. + +**Create gateway and supervisor fixtures:** + +- `crates/daemon/test-fixtures/gateway/fake-frankenphp.sh` — supervised shell parent for normal fake gateway/worker processes. +- `crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py` — HTTP/TLS server body consumed through Python stdin. +- `crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port.sh.in` — blocked-port wrapper template. +- `crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port-server.py` — non-TLS HTTP child for the rollback scenario. +- `crates/daemon/test-fixtures/supervisor/owned-python-runtime.py` — direct shebang ownership fixture. + +**Create integration coverage:** + +- `crates/daemon/tests/fixture_contracts.rs` — executable-level compatibility coverage for shell parsers translated to Python. +- `crates/daemon/tests/snapshots/fixture_contracts__mysql_fixture_cli_preserves_shell_contract.snap` +- `crates/daemon/tests/snapshots/fixture_contracts__fake_mailpit_fixture_cli_ignores_extra_arguments.snap` +- `crates/daemon/tests/snapshots/fixture_contracts__postgres_fixture_cli_preserves_shell_contract.snap` +- `crates/daemon/tests/snapshots/fixture_contracts__mailpit_fixture_cli_preserves_shell_contract.snap` +- `crates/daemon/tests/snapshots/fixture_contracts__rustfs_fixture_cli_preserves_shell_contract.snap` + +**Modify Rust/tests/docs:** + +- `crates/daemon/src/managed_resources/mysql_tests.rs:1-16,302-349,440-509` — include and use `mysql.py`; delete the raw string. +- `crates/daemon/src/managed_resources/tests.rs:1-67,3154-3776,4168-5108` — include and use managed-resource fixtures; retain `fake_sql_script`; add exact RustFS rendering. +- `crates/daemon/tests/gateway_reconciliation.rs:1-31,1759-1885` — materialize wrapper/companion pairs and render the blocked-port template. +- `crates/daemon/tests/supervisor_foundation.rs:1-28,475-500` — include and materialize the ownership fixture. +- `CONTRIBUTING.md:19-39` — document `python3` for daemon tests. + +--- + +### Task 1: Lock down translated fixture CLI contracts + +**Files:** + +- Create: `crates/daemon/tests/fixture_contracts.rs` +- Create: `crates/daemon/test-fixtures/managed-resources/mysql.py` +- Create: `crates/daemon/test-fixtures/managed-resources/fake-mailpit.py` +- Create: `crates/daemon/test-fixtures/managed-resources/postgres.py` +- Create: `crates/daemon/test-fixtures/managed-resources/mailpit.py` +- Create: `crates/daemon/test-fixtures/managed-resources/rustfs.py.in` +- Create: `crates/daemon/tests/snapshots/fixture_contracts__*.snap` (five snapshots generated by `insta`) + +**Interfaces:** + +- Consumes: Existing shell parser contracts from `mysql_fixture_script`, `fake_mailpit_script`, `fake_postgres_script`, `mailpit_script`, and `rustfs_script_source`. +- Produces: Standalone fixture sources consumed by Tasks 2 and 3; integration helpers `materialize_fixture`, `run_fixture`, and `render_rustfs_fixture` remain private to `fixture_contracts.rs`. + +- [ ] **Step 1: Add the failing executable-contract integration test** + +Create `crates/daemon/tests/fixture_contracts.rs` with this complete test harness: + +```rust +use std::io::ErrorKind; +use std::net::{Ipv4Addr, TcpListener, TcpStream}; +use std::os::unix::fs::PermissionsExt; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{Result, bail}; +use camino::Utf8Path; +use camino_tempfile::tempdir; +use insta::{Settings, assert_debug_snapshot}; + +const MYSQL_FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/mysql.py" +)); +const FAKE_MAILPIT_FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/fake-mailpit.py" +)); +const POSTGRES_FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/postgres.py" +)); +const MAILPIT_FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/mailpit.py" +)); +const RUSTFS_FIXTURE_TEMPLATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/rustfs.py.in" +)); +const RUSTFS_REJECT_S3_SENTINEL: &str = "__PV_REJECT_S3__"; + +#[expect( + clippy::disallowed_types, + reason = "daemon fixture contract tests execute materialized test programs" +)] +type FixtureCommand = std::process::Command; + +#[derive(Debug)] +struct FixtureOutput { + code: Option, + stdout: String, + stderr: String, +} + +#[test] +fn mysql_fixture_cli_preserves_shell_contract() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("mysqld"); + let probe_dir = tempdir.path().join("probe"); + let probe_path = tempdir.path().join("mkdir-target"); + let rejected_data_dir = tempdir.path().join("rejected-data"); + let first_data_dir = tempdir.path().join("first-data"); + let selected_data_dir = tempdir.path().join("selected-data"); + + materialize_fixture(&fixture, MYSQL_FIXTURE)?; + state::fs::write_sensitive_file( + &probe_dir.join("sitecustomize.py"), + r#"import os + + +def record_makedirs(path, mode=0o777, exist_ok=False): + with open(os.environ["PV_MYSQL_MKDIR_PROBE"], "w", encoding="utf-8") as probe: + probe.write(os.fspath(path)) + + +os.makedirs = record_makedirs +"#, + )?; + + let first_argument_failure = run_fixture( + &fixture, + &[ + "--initialize-insecure", + "--no-defaults", + "--datadir", + rejected_data_dir.as_str(), + ], + tempdir.path(), + )?; + let successful_initialization = run_fixture( + &fixture, + &[ + "--no-defaults", + "--bind-address=127.0.0.1", + "--future-option", + "--initialize-insecure", + "--datadir", + first_data_dir.as_str(), + "--datadir", + selected_data_dir.as_str(), + "--basedir", + tempdir.path().as_str(), + ], + tempdir.path(), + )?; + let empty_data_dir_initialization = FixtureCommand::new(fixture.as_std_path()) + .args(["--no-defaults", "--initialize-insecure"]) + .current_dir(tempdir.path()) + .env("PYTHONPATH", &probe_dir) + .env("PYTHONDONTWRITEBYTECODE", "1") + .env("PV_MYSQL_MKDIR_PROBE", &probe_path) + .output()?; + let empty_data_dir_initialization = FixtureOutput { + code: empty_data_dir_initialization.status.code(), + stdout: String::from_utf8(empty_data_dir_initialization.stdout)?, + stderr: String::from_utf8(empty_data_dir_initialization.stderr)?, + }; + + assert_fixture_snapshot( + tempdir.path(), + "mysql_fixture_cli_preserves_shell_contract", + ( + first_argument_failure, + successful_initialization, + path_exists(&rejected_data_dir.join("mysql"))?, + path_exists(&first_data_dir.join("mysql"))?, + path_exists(&selected_data_dir.join("mysql"))?, + empty_data_dir_initialization, + state::fs::read_to_string(&probe_path)?, + ), + ) +} + +#[test] +fn fake_mailpit_fixture_cli_ignores_extra_arguments() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("fake-mailpit"); + let smtp_listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let smtp_port = smtp_listener.local_addr()?.port(); + let dashboard_listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let dashboard_port = dashboard_listener.local_addr()?.port(); + + materialize_fixture(&fixture, FAKE_MAILPIT_FIXTURE)?; + drop(smtp_listener); + drop(dashboard_listener); + + let mut child = FixtureCommand::new(fixture.as_std_path()) + .args([ + smtp_port.to_string(), + dashboard_port.to_string(), + "ignored-extra".to_owned(), + ]) + .current_dir(tempdir.path()) + .spawn()?; + let lifecycle = (|| { + let readiness = + wait_for_loopback_ports([smtp_port, dashboard_port], Duration::from_secs(3))?; + let running_after_readiness = child.try_wait()?.is_none(); + + Ok::<_, anyhow::Error>((readiness, running_after_readiness)) + })(); + let kill_result = child.kill(); + let wait_result = child.wait(); + + let lifecycle = lifecycle?; + if let Err(error) = kill_result + && error.kind() != ErrorKind::InvalidInput + { + return Err(error.into()); + } + wait_result?; + + assert_fixture_snapshot( + tempdir.path(), + "fake_mailpit_fixture_cli_ignores_extra_arguments", + lifecycle, + ) +} + +#[test] +fn postgres_fixture_cli_preserves_shell_contract() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("postgres"); + let initialized_data_dir = tempdir.path().join("initialized-postgres"); + let selected_missing_data_dir = tempdir.path().join("selected-missing-postgres"); + + materialize_fixture(&fixture, POSTGRES_FIXTURE)?; + state::fs::write_sensitive_file(&initialized_data_dir.join("PG_VERSION"), "16\n")?; + + let unknown_argument = run_fixture(&fixture, &["--unexpected"], tempdir.path())?; + let last_data_dir_wins = run_fixture( + &fixture, + &[ + "-D", + initialized_data_dir.as_str(), + "-D", + selected_missing_data_dir.as_str(), + "-h", + "127.0.0.1", + "-p", + "5432", + ], + tempdir.path(), + )?; + + assert_fixture_snapshot( + tempdir.path(), + "postgres_fixture_cli_preserves_shell_contract", + (unknown_argument, last_data_dir_wins), + ) +} + +#[test] +fn mailpit_fixture_cli_preserves_shell_contract() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("mailpit"); + let missing_database = tempdir.path().join("missing/mailpit.db"); + + materialize_fixture(&fixture, MAILPIT_FIXTURE)?; + + let unknown_argument = run_fixture(&fixture, &["--unexpected"], tempdir.path())?; + let missing_required_arguments = + run_fixture(&fixture, &["--disable-version-check"], tempdir.path())?; + let missing_version_check = run_fixture( + &fixture, + &[ + "--smtp", + "127.0.0.1:1025", + "--listen", + "127.0.0.1:8025", + "--database", + missing_database.as_str(), + ], + tempdir.path(), + )?; + let invalid_database_path = run_fixture( + &fixture, + &[ + "--smtp", + "127.0.0.1:1025", + "--listen", + "127.0.0.1:8025", + "--database", + "mailpit.db", + "--disable-version-check", + ], + tempdir.path(), + )?; + let missing_database_directory = run_fixture( + &fixture, + &[ + "--smtp", + "127.0.0.1:1025", + "--listen", + "127.0.0.1:8025", + "--database", + missing_database.as_str(), + "--disable-version-check", + ], + tempdir.path(), + )?; + let duplicate_database_last_wins = run_fixture( + &fixture, + &[ + "--smtp", + "127.0.0.1:1025", + "--listen", + "127.0.0.1:8025", + "--database", + "mailpit.db", + "--database", + missing_database.as_str(), + "--disable-version-check", + ], + tempdir.path(), + )?; + + assert_fixture_snapshot( + tempdir.path(), + "mailpit_fixture_cli_preserves_shell_contract", + ( + unknown_argument, + missing_required_arguments, + missing_version_check, + invalid_database_path, + missing_database_directory, + duplicate_database_last_wins, + ), + ) +} + +#[test] +fn rustfs_fixture_cli_preserves_shell_contract() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("rustfs"); + let first_data_dir = tempdir.path().join("first-rustfs-data"); + let selected_data_dir = tempdir.path().join("selected-rustfs-data"); + let rendered = render_rustfs_fixture(false)?; + + materialize_fixture(&fixture, &rendered)?; + let output = run_fixture( + &fixture, + &[ + first_data_dir.as_str(), + "--future-option", + selected_data_dir.as_str(), + "--address", + "invalid-api-address", + "--console-address", + "invalid-console-address", + ], + tempdir.path(), + )?; + + assert_fixture_snapshot( + tempdir.path(), + "rustfs_fixture_cli_preserves_shell_contract", + ( + output.code, + output.stdout, + output.stderr.contains("ValueError"), + path_exists(&first_data_dir)?, + path_exists(&selected_data_dir.join("buckets"))?, + path_exists(&selected_data_dir.join("process-env"))?, + path_exists(&tempdir.path().join("invalid-api-address"))?, + path_exists(&tempdir.path().join("invalid-console-address"))?, + rendered.contains(RUSTFS_REJECT_S3_SENTINEL), + ), + ) +} + +fn render_rustfs_fixture(reject_s3: bool) -> Result { + let occurrence_count = RUSTFS_FIXTURE_TEMPLATE + .matches(RUSTFS_REJECT_S3_SENTINEL) + .count(); + if occurrence_count != 1 { + bail!( + "RustFS fixture must contain exactly one {RUSTFS_REJECT_S3_SENTINEL} sentinel; found {occurrence_count}" + ); + } + + let replacement = if reject_s3 { "True" } else { "False" }; + let rendered = RUSTFS_FIXTURE_TEMPLATE.replacen(RUSTFS_REJECT_S3_SENTINEL, replacement, 1); + if rendered.contains(RUSTFS_REJECT_S3_SENTINEL) { + bail!("RustFS fixture still contains {RUSTFS_REJECT_S3_SENTINEL} after rendering"); + } + + Ok(rendered) +} + +fn materialize_fixture(path: &Utf8Path, source: &str) -> Result<()> { + state::fs::write_sensitive_file(path, source)?; + set_executable(path) +} + +fn run_fixture( + path: &Utf8Path, + arguments: &[&str], + current_dir: &Utf8Path, +) -> Result { + let output = FixtureCommand::new(path.as_std_path()) + .args(arguments) + .current_dir(current_dir) + .output()?; + + Ok(FixtureOutput { + code: output.status.code(), + stdout: String::from_utf8(output.stdout)?, + stderr: String::from_utf8(output.stderr)?, + }) +} + +fn assert_fixture_snapshot( + tempdir: &Utf8Path, + name: &'static str, + snapshot: impl std::fmt::Debug, +) -> Result<()> { + let mut settings = Settings::clone_current(); + settings.add_filter(®ex_literal(tempdir.as_str()), ""); + settings.add_filter("/private", ""); + settings.bind(|| { + assert_debug_snapshot!(name, snapshot); + Ok::<(), anyhow::Error>(()) + }) +} + +#[expect( + clippy::disallowed_methods, + reason = "daemon fixture contract tests inspect fixture filesystem effects directly" +)] +fn path_exists(path: &Utf8Path) -> Result { + match std::fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } +} + +#[expect( + clippy::disallowed_methods, + reason = "daemon fixture contract tests set materialized fixture executable bits directly" +)] +fn set_executable(path: &Utf8Path) -> Result<()> { + let mut permissions = std::fs::metadata(path)?.permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions)?; + + Ok(()) +} + +fn regex_literal(value: &str) -> String { + let mut literal = String::new(); + + for character in value.chars() { + if matches!( + character, + '\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' + ) { + literal.push('\\'); + } + literal.push(character); + } + + literal +} +``` + +- [ ] **Step 2: Run the new integration target and confirm the fixture files are the missing implementation** + +Run: + +```shell +cargo nextest run -p daemon --test fixture_contracts --locked +``` + +Expected: compilation fails at the first `include_str!` with a missing file under `crates/daemon/test-fixtures/managed-resources/`. Do not weaken the test or replace compile-time inclusion. + +- [ ] **Step 3: Add the direct MySQL and fake Mailpit fixture implementations** + +Create `crates/daemon/test-fixtures/managed-resources/mysql.py`: + +```python +#!/usr/bin/env python3 +import os +import signal +import socketserver +import sys + + +arguments = list(sys.argv[1:]) +first_argument = arguments[0] if arguments else "" +data_dir = "" +port = "" +initialize = False + +while arguments: + argument = arguments.pop(0) + if argument == "--initialize-insecure": + initialize = True + elif argument == "--datadir": + data_dir = arguments.pop(0) + elif argument == "--basedir": + arguments.pop(0) + elif argument == "--port": + port = arguments.pop(0) + elif argument in {"--bind-address", "--socket"}: + arguments.pop(0) + elif argument == "--no-defaults": + pass + +if initialize: + if first_argument != "--no-defaults": + print("mysqld initialization must start with --no-defaults", file=sys.stderr) + sys.exit(64) + os.makedirs(f"{data_dir}/mysql", exist_ok=True) + sys.exit(0) + + +class Handler(socketserver.BaseRequestHandler): + def handle(self): + self.request.recv(1024) + + +class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + + +def stop(_signum, _frame): + sys.exit(0) + + +signal.signal(signal.SIGTERM, stop) +signal.signal(signal.SIGINT, stop) + +server = TcpServer(("127.0.0.1", int(port)), Handler) +server.serve_forever() +``` + +Create `crates/daemon/test-fixtures/managed-resources/fake-mailpit.py`: + +```python +#!/usr/bin/env python3 +import http.server +import signal +import socketserver +import sys +import threading + + +smtp_port = sys.argv[1] +dashboard_port = sys.argv[2] + + +class SmtpHandler(socketserver.BaseRequestHandler): + def handle(self): + self.request.sendall(b"220 fake mailpit\r\n") + + +class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + + +def stop(_signum, _frame): + sys.exit(0) + + +smtp = TcpServer(("127.0.0.1", int(smtp_port)), SmtpHandler) +dashboard = http.server.ThreadingHTTPServer( + ("127.0.0.1", int(dashboard_port)), + http.server.SimpleHTTPRequestHandler, +) + +signal.signal(signal.SIGTERM, stop) +signal.signal(signal.SIGINT, stop) + +threading.Thread(target=smtp.serve_forever, daemon=True).start() +dashboard.serve_forever() +``` + +These parsers intentionally ignore unknown/extra arguments exactly as their shell wrappers do. Do not add `else` rejection to MySQL and do not validate `sys.argv[3:]` in fake Mailpit. The MySQL empty-datadir probe must record `/mysql` without creating it, and fake Mailpit must bind both reserved loopback ports, remain alive after readiness, and be cleaned up after the probe. + +- [ ] **Step 4: Add the direct Postgres fixture implementation** + +Create `crates/daemon/test-fixtures/managed-resources/postgres.py`: + +```python +#!/usr/bin/env python3 +import os +import signal +import socketserver +import struct +import sys +import threading + + +arguments = list(sys.argv[1:]) +data_dir = "" +argument_host = "" +argument_port = "" + +while arguments: + argument = arguments.pop(0) + if argument == "-D": + data_dir = arguments.pop(0) + elif argument == "-h": + argument_host = arguments.pop(0) + elif argument == "-p": + argument_port = arguments.pop(0) + else: + print(f"unexpected postgres argument: {argument}", file=sys.stderr) + sys.exit(64) + +if ( + not data_dir + or not argument_host + or not argument_port + or not os.path.isfile(os.path.join(data_dir, "PG_VERSION")) +): + print("postgres data dir is not initialized", file=sys.stderr) + sys.exit(64) + +argument_port = int(argument_port) +config_path = os.path.join(data_dir, "postgresql.conf") +database_dir = os.path.join(data_dir, "databases") + +host = "127.0.0.1" +port = None + +with open(config_path, "r", encoding="utf-8") as config: + for line in config: + line = line.strip() + if line.startswith("listen_addresses"): + host = line.split("=", 1)[1].strip().strip("'\"") + if line.startswith("port"): + port = int(line.split("=", 1)[1].strip()) + +if host != "127.0.0.1" or port is None: + raise SystemExit("postgresql.conf did not set loopback host and port") +if argument_host != host or argument_port != port: + raise SystemExit("postgres arguments did not match generated config") + +os.makedirs(database_dir, exist_ok=True) +with open( + os.path.join(data_dir, "postgres.started"), "w", encoding="utf-8" +) as started: + started.write(f"{host}:{port}\n") + + +def packet(message_type, payload=b""): + return message_type + struct.pack("!I", len(payload) + 4) + payload + + +def auth_ok(): + return packet(b"R", struct.pack("!I", 0)) + + +def parameter_status(key, value): + return packet(b"S", key.encode() + b"\0" + value.encode() + b"\0") + + +def backend_key_data(): + return packet(b"K", struct.pack("!II", os.getpid() & 0x7FFFFFFF, 1)) + + +def ready(): + return packet(b"Z", b"I") + + +def parameter_description(query): + if "$1" in query: + return packet(b"t", struct.pack("!H", 1) + struct.pack("!I", 25)) + return packet(b"t", struct.pack("!H", 0)) + + +def command_complete(tag): + return packet(b"C", tag.encode() + b"\0") + + +def parse_complete(): + return packet(b"1") + + +def bind_complete(): + return packet(b"2") + + +def close_complete(): + return packet(b"3") + + +def no_data(): + return packet(b"n") + + +def row_description(): + field = b"?column?\0" + struct.pack("!IhIhih", 0, 0, 23, 4, -1, 0) + return packet(b"T", struct.pack("!H", 1) + field) + + +def data_row(value): + data = str(value).encode() + return packet(b"D", struct.pack("!H", 1) + struct.pack("!I", len(data)) + data) + + +def error_response(message): + return packet(b"E", b"SERROR\0CXX000\0M" + message.encode() + b"\0\0") + + +def cstring(payload, start): + end = payload.index(b"\0", start) + return payload[start:end].decode(), end + 1 + + +def read_exact(stream, length): + data = b"" + while len(data) < length: + chunk = stream.recv(length - len(data)) + if not chunk: + raise EOFError + data += chunk + return data + + +def read_startup(stream): + length = struct.unpack("!I", read_exact(stream, 4))[0] + payload = read_exact(stream, length - 4) + code = struct.unpack("!I", payload[:4])[0] + if code == 80877103: + stream.sendall(b"N") + return read_startup(stream) + return payload + + +def startup_response(): + return b"".join( + [ + auth_ok(), + parameter_status("server_version", "16.0"), + parameter_status("server_encoding", "UTF8"), + parameter_status("client_encoding", "UTF8"), + parameter_status("DateStyle", "ISO, MDY"), + parameter_status("integer_datetimes", "on"), + parameter_status("standard_conforming_strings", "on"), + backend_key_data(), + ready(), + ] + ) + + +def database_file(database): + safe = "".join(character for character in database if character.isalnum() or character == "_") + if safe != database: + raise ValueError("unsafe database name") + return os.path.join(database_dir, database) + + +def database_exists(database): + return os.path.exists(database_file(database)) + + +def create_database(database): + with open(database_file(database), "w", encoding="utf-8") as marker: + marker.write(database + "\n") + + +def database_from_create(query): + quoted = query.split("CREATE DATABASE", 1)[1].strip() + if quoted.startswith('"') and quoted.endswith('"'): + return quoted[1:-1] + return quoted + + +def query_response(query, params): + normalized = " ".join(query.strip().split()) + if normalized.upper() in {"SELECT 1", "SELECT $1"}: + return row_description() + data_row(1) + command_complete("SELECT 1") + if "FROM pg_database WHERE datname" in normalized: + database = params[0] if params else "" + if database_exists(database): + return row_description() + data_row(1) + command_complete("SELECT 1") + return row_description() + command_complete("SELECT 0") + if normalized.upper().startswith("CREATE DATABASE"): + create_database(database_from_create(normalized)) + return command_complete("CREATE DATABASE") + if normalized.upper().startswith("SET "): + return command_complete("SET") + return error_response("unsupported fixture query: " + normalized) + + +class Handler(socketserver.BaseRequestHandler): + def handle(self): + statements = {} + portals = {} + try: + read_startup(self.request) + self.request.sendall(startup_response()) + while True: + message_type = read_exact(self.request, 1) + length = struct.unpack("!I", read_exact(self.request, 4))[0] + payload = read_exact(self.request, length - 4) + if message_type == b"X": + return + if message_type == b"Q": + query = payload[:-1].decode() + self.request.sendall(query_response(query, []) + ready()) + continue + if message_type == b"P": + statement, offset = cstring(payload, 0) + query, _offset = cstring(payload, offset) + statements[statement] = query + self.request.sendall(parse_complete()) + continue + if message_type == b"B": + portal, offset = cstring(payload, 0) + statement, offset = cstring(payload, offset) + format_count = struct.unpack("!H", payload[offset : offset + 2])[0] + offset += 2 + (format_count * 2) + param_count = struct.unpack("!H", payload[offset : offset + 2])[0] + offset += 2 + params = [] + for _index in range(param_count): + size = struct.unpack("!i", payload[offset : offset + 4])[0] + offset += 4 + if size == -1: + params.append(None) + else: + params.append(payload[offset : offset + size].decode()) + offset += size + portals[portal] = (statements.get(statement, ""), params) + self.request.sendall(bind_complete()) + continue + if message_type == b"D": + describe_kind = payload[:1] + name = payload[1:-1].decode() + query, _params = portals.get(name, (statements.get(name, ""), [])) + response = b"" + if describe_kind == b"S": + response += parameter_description(query) + if query.strip().upper().startswith("CREATE DATABASE"): + response += no_data() + else: + response += row_description() + self.request.sendall(response) + continue + if message_type == b"E": + portal, offset = cstring(payload, 0) + _max_rows = struct.unpack("!I", payload[offset : offset + 4])[0] + query, params = portals.get(portal, ("", [])) + self.request.sendall(query_response(query, params)) + continue + if message_type == b"S": + self.request.sendall(ready()) + continue + if message_type == b"H": + continue + if message_type == b"C": + self.request.sendall(close_complete()) + continue + self.request.sendall(error_response("unsupported message type")) + except (EOFError, ConnectionResetError, BrokenPipeError): + return + + +class Server(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + + +server = Server((host, port), Handler) + + +def stop(_signum, _frame): + server.shutdown() + + +signal.signal(signal.SIGTERM, stop) +signal.signal(signal.SIGINT, stop) + +threading.Thread(target=server.serve_forever, daemon=True).start() +signal.pause() +``` + +The protocol implementation is an extraction of the current body. The only intentional rewrite is the manual option loop at the top. Exact `-D`, `-h`, and `-p` options reject anything else with exit code 64, and repeated options remain last-wins. + +- [ ] **Step 5: Add the direct Mailpit and rendered RustFS fixture implementations** + +Create `crates/daemon/test-fixtures/managed-resources/mailpit.py`: + +```python +#!/usr/bin/env python3 +import http.server +import os +import signal +import socketserver +import sys +import threading + + +arguments = list(sys.argv[1:]) +smtp = "" +listen = "" +database = "" +disable_version_check = False + +while arguments: + argument = arguments.pop(0) + if argument == "--smtp": + smtp = arguments.pop(0) + elif argument == "--listen": + listen = arguments.pop(0) + elif argument == "--database": + database = arguments.pop(0) + elif argument == "--disable-version-check": + disable_version_check = True + else: + print(f"unexpected argument: {argument}", file=sys.stderr) + sys.exit(2) + +if not smtp or not listen or not database: + print("missing required mailpit argument", file=sys.stderr) + sys.exit(2) + +if not disable_version_check: + print("missing --disable-version-check", file=sys.stderr) + sys.exit(2) + +if not database.endswith("/mailpit.db"): + print(f"unexpected database path: {database}", file=sys.stderr) + sys.exit(2) + +database_dir = os.path.dirname(database) +if not os.path.isdir(database_dir): + print(f"database directory does not exist: {database_dir}", file=sys.stderr) + sys.exit(2) + + +def host_port(value): + host, port = value.rsplit(":", 1) + return host, int(port) + + +class SmtpHandler(socketserver.BaseRequestHandler): + def handle(self): + self.request.sendall(b"220 mailpit fixture\r\n") + + +class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + + +smtp_server = TcpServer(host_port(smtp), SmtpHandler) +dashboard = http.server.ThreadingHTTPServer( + host_port(listen), + http.server.SimpleHTTPRequestHandler, +) + + +def stop(_signum, _frame): + sys.exit(0) + + +signal.signal(signal.SIGTERM, stop) +signal.signal(signal.SIGINT, stop) + +threading.Thread(target=smtp_server.serve_forever, daemon=True).start() +dashboard.serve_forever() +``` + +Create `crates/daemon/test-fixtures/managed-resources/rustfs.py.in`: + +```python +#!/usr/bin/env python3 +import hashlib +import http.server +import os +import posixpath +import signal +import sys +import threading +import urllib.parse + + +arguments = list(sys.argv[1:]) +api_address = "" +console_address = "" +data_dir = "" + +while arguments: + argument = arguments.pop(0) + if argument == "--address": + api_address = arguments.pop(0) + elif argument == "--console-address": + console_address = arguments.pop(0) + else: + data_dir = argument + +reject_s3 = __PV_REJECT_S3__ +buckets_dir = os.path.join(data_dir, "buckets") +os.makedirs(buckets_dir, exist_ok=True) +with open(os.path.join(data_dir, "process-env"), "w", encoding="utf-8") as file: + file.write(f"RUSTFS_ACCESS_KEY={os.environ.get('RUSTFS_ACCESS_KEY', '')}\n") + file.write(f"RUSTFS_SECRET_KEY={os.environ.get('RUSTFS_SECRET_KEY', '')}\n") + + +def split_address(value): + host, port = value.rsplit(":", 1) + return host, int(port) + + +def bucket_path(bucket): + return os.path.join(buckets_dir, bucket) + + +def object_path(bucket, key): + clean_key = posixpath.normpath("/" + key).lstrip("/") + return os.path.join(bucket_path(bucket), clean_key) + + +class RustfsHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + path = urllib.parse.urlparse(self.path).path + if path in {"/", "/health"}: + self.send_response(200) + self.end_headers() + self.wfile.write(b"rustfs") + return + + self.send_response(404) + self.end_headers() + + def do_PUT(self): + if reject_s3: + self.send_response(403) + self.end_headers() + return + + path = urllib.parse.urlparse(self.path).path.strip("/") + parts = path.split("/", 1) + length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(length) + if not parts or not parts[0]: + self.send_response(400) + self.end_headers() + return + + bucket = parts[0] + if len(parts) == 1: + os.makedirs(bucket_path(bucket), exist_ok=True) + self.send_response(200) + self.end_headers() + return + + target = object_path(bucket, parts[1]) + os.makedirs(os.path.dirname(target), exist_ok=True) + with open(target, "wb") as file: + file.write(body) + self.send_response(200) + self.send_header("ETag", hashlib.md5(body).hexdigest()) + self.end_headers() + + def do_HEAD(self): + if reject_s3: + self.send_response(403) + self.end_headers() + return + + path = urllib.parse.urlparse(self.path).path.strip("/") + parts = path.split("/", 1) + if len(parts) != 2: + exists = bool(parts and parts[0] and os.path.isdir(bucket_path(parts[0]))) + self.send_response(200 if exists else 404) + self.end_headers() + return + + target = object_path(parts[0], parts[1]) + if not os.path.exists(target): + self.send_response(404) + self.end_headers() + return + + size = os.path.getsize(target) + with open(target, "rb") as file: + digest = hashlib.md5(file.read()).hexdigest() + self.send_response(200) + self.send_header("Content-Length", str(size)) + self.send_header("ETag", digest) + self.end_headers() + + def log_message(self, _format, *_args): + return + + +class ConsoleHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b"rustfs console") + + def log_message(self, _format, *_args): + return + + +class Server(http.server.ThreadingHTTPServer): + allow_reuse_address = True + + +api = Server(split_address(api_address), RustfsHandler) +console = Server(split_address(console_address), ConsoleHandler) + +shutdown_requested = threading.Event() + + +def stop(_signum, _frame): + if shutdown_requested.is_set(): + return + shutdown_requested.set() + threading.Thread(target=api.shutdown, daemon=True).start() + + +signal.signal(signal.SIGTERM, stop) +signal.signal(signal.SIGINT, stop) + +threading.Thread(target=console.serve_forever, daemon=True).start() +api.serve_forever() +console.shutdown() +``` + +Do not use `argparse`: Mailpit's duplicate values must remain last-wins, and every unknown RustFS token must continue becoming the data directory, with the last token winning. + +- [ ] **Step 6: Generate and review the five new snapshots** + +Run: + +```shell +INSTA_UPDATE=always cargo nextest run -p daemon --test fixture_contracts --locked +``` + +Expected: all five tests pass and create five `.snap` files. Inspect them before continuing. The stable outcomes must show: + +```text +MySQL: rejected code 64 with "mysqld initialization must start with --no-defaults"; accepted code 0; only selected-data/mysql exists; the empty-datadir initialization is code 0 and the sitecustomize probe records `/mysql` without creating it. +Fake Mailpit: both reserved loopback ports become ready and the child remains alive after readiness before cleanup. +Postgres: both cases code 64; one "unexpected postgres argument" and one "postgres data dir is not initialized". +Mailpit: all six cases code 2 with the five explicit validation messages; the duplicate-database case repeats the missing-directory result to prove last-wins parsing, and the path is normalized to . +RustFS: code 1 after invalid address parsing; ValueError=true; only selected-rustfs-data contains buckets and process-env; neither consumed address value becomes a directory; sentinel-present=false. +``` + +Then run without snapshot updates: + +```shell +cargo nextest run -p daemon --test fixture_contracts --locked +``` + +Expected: `5 passed` with no `.snap.new` files. + +- [ ] **Step 7: Run formatting and commit the contract boundary** + +Run: + +```shell +cargo fmt --all +cargo fmt --all --check +git diff --check +``` + +Expected: all three commands exit 0. + +Commit: + +```shell +git add crates/daemon/test-fixtures/managed-resources/mysql.py \ + crates/daemon/test-fixtures/managed-resources/fake-mailpit.py \ + crates/daemon/test-fixtures/managed-resources/postgres.py \ + crates/daemon/test-fixtures/managed-resources/mailpit.py \ + crates/daemon/test-fixtures/managed-resources/rustfs.py.in \ + crates/daemon/tests/fixture_contracts.rs \ + crates/daemon/tests/snapshots/fixture_contracts__*.snap +git commit -m "test(daemon): cover managed fixture CLI contracts" +``` + +--- + +### Task 2: Extract MySQL, Postgres, and Mailpit fixture assets + +**Files:** + +- Create: `crates/daemon/test-fixtures/managed-resources/postgres-initdb.sh` +- Create: `crates/daemon/test-fixtures/managed-resources/postgres-unready.sh` +- Create: `crates/daemon/test-fixtures/managed-resources/mailpit-unready.sh` +- Create: `crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py` +- Modify: `crates/daemon/src/managed_resources/mysql_tests.rs:1-16,302-349,440-509` +- Modify: `crates/daemon/src/managed_resources/tests.rs:1-67,3154-3776,4168-4861` +- Test: `crates/daemon/tests/fixture_contracts.rs` + +**Interfaces:** + +- Consumes: The direct Python fixtures and contract snapshots from Task 1; existing `state::fs::write_sensitive_file` and file-local `set_executable` helpers. +- Produces: Compile-time `&'static str` fixture constants used by all MySQL, Postgres, and Mailpit artifact/archive helpers. `fake_sql_script` deliberately remains inline. + +- [ ] **Step 1: Record a green focused baseline before moving source** + +Run: + +```shell +cargo nextest run -p daemon --lib --all-features --locked +cargo nextest run -p daemon --test fixture_contracts --all-features --locked +``` + +Expected: the daemon library target and all five contract tests pass. This is a behavior-preserving extraction, so the same commands must pass afterward without snapshot changes. + +- [ ] **Step 2: Add the standalone POSIX lifecycle/CLI fixtures** + +Create `crates/daemon/test-fixtures/managed-resources/postgres-initdb.sh`: + +```sh +#!/bin/sh +set -eu + +data_dir="" +username="" +password_file="" + +while [ "$#" -gt 0 ]; do + case "$1" in + -D) + data_dir="$2" + shift 2 + ;; + -U) + username="$2" + shift 2 + ;; + --username) + username="$2" + shift 2 + ;; + --pwfile) + password_file="$2" + shift 2 + ;; + --auth-host|--auth-local) + shift 2 + ;; + *) + echo "unexpected initdb argument: $1" >&2 + exit 64 + ;; + esac +done + +if [ -z "$data_dir" ] || [ -z "$username" ] || [ -z "$password_file" ]; then + echo "missing initdb inputs" >&2 + exit 64 +fi + +if [ -d "$data_dir" ] && [ "$(find "$data_dir" -mindepth 1 -maxdepth 1 | wc -l)" -gt 0 ]; then + echo "PGDATA is not empty before initdb" >&2 + exit 65 +fi + +mkdir -p "$data_dir/databases" +printf '16\n' > "$data_dir/PG_VERSION" +printf '%s\n' "$username" > "$data_dir/initdb.username" +cat "$password_file" > "$data_dir/initdb.password" +``` + +Create `crates/daemon/test-fixtures/managed-resources/postgres-unready.sh`: + +```sh +#!/bin/sh +set -eu + +data_dir="" + +while [ "$#" -gt 0 ]; do + case "$1" in + -D) + data_dir="$2" + shift 2 + ;; + -h|-p) + shift 2 + ;; + *) + echo "unexpected postgres argument: $1" >&2 + exit 64 + ;; + esac +done + +if [ -z "$data_dir" ] || [ ! -f "$data_dir/PG_VERSION" ]; then + echo "postgres data dir is not initialized" >&2 + exit 64 +fi + +stop() { + exit 0 +} + +trap stop TERM INT + +while true; do + sleep 1 +done +``` + +Create `crates/daemon/test-fixtures/managed-resources/mailpit-unready.sh`: + +```sh +#!/bin/sh +set -eu + +stop() { + exit 0 +} + +trap stop TERM INT + +while true; do + sleep 1 +done +``` + +Keep these as POSIX shell because their filesystem or signal/wait behavior is the fixture. Do not translate them to Python. + +- [ ] **Step 3: Add the direct fast-exit Mailpit fixture** + +Create `crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py`: + +```python +#!/usr/bin/env python3 +import http.server +import os +import sys + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b"ready") + self.wfile.flush() + os._exit(0) + + def log_message(self, _format, *_args): + pass + + +server = http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[2])), Handler) +server.serve_forever() +``` + +Do not replace `os._exit(0)` with normal shutdown or move it outside the request handler. The response body must be written and flushed first. + +- [ ] **Step 4: Add compile-time fixture constants to the two managed-resource test modules** + +In `crates/daemon/src/managed_resources/mysql_tests.rs`, add this next to the existing test constants: + +```rust +const MYSQL_FIXTURE_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/mysql.py" +)); +``` + +In `crates/daemon/src/managed_resources/tests.rs`, add these next to the other test constants: + +```rust +const FAKE_MAILPIT_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/fake-mailpit.py" +)); +const MAILPIT_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/mailpit.py" +)); +const MAILPIT_FAST_EXIT_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/mailpit-fast-exit.py" +)); +const MAILPIT_UNREADY_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/mailpit-unready.sh" +)); +const POSTGRES_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/postgres.py" +)); +const POSTGRES_INITDB_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/postgres-initdb.sh" +)); +const POSTGRES_UNREADY_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/postgres-unready.sh" +)); +``` + +- [ ] **Step 5: Replace the raw-string accessors with the fixture constants** + +In `mysql_tests.rs`, make both archive/artifact writes use the constant: + +```rust +state::fs::write_sensitive_file(&executable, MYSQL_FIXTURE_SCRIPT)?; +``` + +Delete `mysql_fixture_script` entirely. + +In `managed_resources/tests.rs`, apply these exact expression replacements everywhere in artifact seeding, archive creation, and Postgres binary materialization: + +```text +fake_mailpit_script() -> FAKE_MAILPIT_SCRIPT +mailpit_script() -> MAILPIT_SCRIPT +unready_fake_mailpit_script() -> MAILPIT_UNREADY_SCRIPT +fast_exit_fake_mailpit_script() -> MAILPIT_FAST_EXIT_SCRIPT +fake_postgres_initdb_script() -> POSTGRES_INITDB_SCRIPT +fake_postgres_script() -> POSTGRES_SCRIPT +unready_fake_postgres_script() -> POSTGRES_UNREADY_SCRIPT +``` + +The resulting seed helpers must have this shape: + +```rust +fn seed_fake_mailpit_artifact(paths: &PvPaths, track: &str) -> Result<()> { + seed_fake_mailpit_artifact_with_script(paths, track, FAKE_MAILPIT_SCRIPT) +} + +fn seed_mailpit_fixture_artifact(paths: &PvPaths, track: &str) -> Result<()> { + let release_path = paths + .resources() + .join("mailpit") + .join(track) + .join(format!("releases/{FAKE_MAILPIT_ARTIFACT_VERSION}")); + let executable = release_path.join("bin/mailpit"); + + state::fs::write_sensitive_file(&executable, MAILPIT_SCRIPT)?; + set_executable(&executable)?; + let mut database = Database::open(paths)?; + database.record_managed_resource_track_installed( + "mailpit", + track, + FAKE_MAILPIT_ARTIFACT_VERSION, + &release_path, + )?; + + Ok(()) +} + +fn seed_unready_fake_mailpit_artifact(paths: &PvPaths, track: &str) -> Result<()> { + seed_fake_mailpit_artifact_with_script(paths, track, MAILPIT_UNREADY_SCRIPT) +} + +fn seed_fast_exit_fake_mailpit_artifact(paths: &PvPaths, track: &str) -> Result<()> { + seed_fake_mailpit_artifact_with_script(paths, track, MAILPIT_FAST_EXIT_SCRIPT) +} + +fn seed_postgres_fixture_artifact(paths: &PvPaths, track: &str) -> Result<()> { + seed_postgres_fixture_artifact_with_script(paths, track, POSTGRES_SCRIPT) +} + +fn seed_unready_postgres_fixture_artifact(paths: &PvPaths, track: &str) -> Result<()> { + seed_postgres_fixture_artifact_with_script(paths, track, POSTGRES_UNREADY_SCRIPT) +} +``` + +`write_postgres_fixture_binaries_without_support_files` must write the two constants directly: + +```rust +state::fs::write_sensitive_file(&initdb, POSTGRES_INITDB_SCRIPT)?; +state::fs::write_sensitive_file(&postgres, POSTGRES_SCRIPT)?; +``` + +Delete only these seven obsolete raw-string functions. Keep `fake_sql_script` inline and do not change the shared `*_with_script` helpers because failure scenarios still inject alternate fixture source. + +- [ ] **Step 6: Lint the extracted scripts and rerun the behavioral targets** + +Run: + +```shell +shellcheck \ + crates/daemon/test-fixtures/managed-resources/postgres-initdb.sh \ + crates/daemon/test-fixtures/managed-resources/postgres-unready.sh \ + crates/daemon/test-fixtures/managed-resources/mailpit-unready.sh +cargo fmt --all +cargo nextest run -p daemon --lib --all-features --locked +cargo nextest run -p daemon --test fixture_contracts --all-features --locked +git diff --check +``` + +Expected: shellcheck exits 0, the daemon library target passes, all five fixture-contract snapshots remain unchanged, and the diff check is clean. + +- [ ] **Step 7: Commit the SQL and Mailpit extraction** + +```shell +git add crates/daemon/test-fixtures/managed-resources/postgres-initdb.sh \ + crates/daemon/test-fixtures/managed-resources/postgres-unready.sh \ + crates/daemon/test-fixtures/managed-resources/mailpit-unready.sh \ + crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py \ + crates/daemon/src/managed_resources/mysql_tests.rs \ + crates/daemon/src/managed_resources/tests.rs +git commit -m "refactor(daemon): extract SQL and Mailpit test fixtures" +``` + +--- + +### Task 3: Extract Redis and RustFS server fixtures + +**Files:** + +- Create: `crates/daemon/test-fixtures/managed-resources/redis-server.py` +- Modify: `crates/daemon/src/managed_resources/tests.rs:1-67,3471-3746,4862-5108` +- Test: `crates/daemon/tests/fixture_contracts.rs` + +**Interfaces:** + +- Consumes: `rustfs.py.in` and its executable contract from Task 1; existing Redis and RustFS archive/artifact helpers. +- Produces: `REDIS_SERVER_SCRIPT`, `RUSTFS_SCRIPT_TEMPLATE`, and fallible `rustfs_script_source(bool) -> Result` for all daemon managed-resource tests. + +- [ ] **Step 1: Add the direct Redis server fixture** + +Create `crates/daemon/test-fixtures/managed-resources/redis-server.py` by removing only the forwarding shell from the current fixture: + +```python +#!/usr/bin/env python3 +import os +import signal +import shlex +import socketserver +import sys +import threading + + +def redis_config(argv): + port = None + data_dir = None + arguments = list(argv) + while arguments: + argument = arguments.pop(0) + if argument == "--port" and arguments: + port = int(arguments.pop(0)) + elif argument == "--dir" and arguments: + data_dir = arguments.pop(0) + elif os.path.isfile(argument): + with open(argument, "r", encoding="utf-8") as config: + for line in config: + parts = shlex.split(line) + if len(parts) == 2 and parts[0] == "port": + port = int(parts[1]) + elif len(parts) == 2 and parts[0] == "dir": + data_dir = parts[1] + if port is None: + raise RuntimeError("missing Redis port") + return port, data_dir + + +class RedisPingHandler(socketserver.BaseRequestHandler): + def handle(self): + while True: + data = self.request.recv(4096) + if not data: + return + upper = data.upper() + responses = [] + for _index in range(upper.count(b"CLIENT")): + responses.append(b"+OK\r\n") + for _index in range(upper.count(b"PING")): + responses.append(b"+PONG\r\n") + if not responses: + responses.append(b"+OK\r\n") + self.request.sendall(b"".join(responses)) + + +class RedisServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + daemon_threads = True + + +shutdown_requested = threading.Event() + + +def stop(_signum, _frame): + if shutdown_requested.is_set(): + return + shutdown_requested.set() + threading.Thread(target=server.shutdown, daemon=True).start() + + +port, data_dir = redis_config(sys.argv[1:]) +if data_dir: + os.makedirs(data_dir, exist_ok=True) + +server = RedisServer(("127.0.0.1", port), RedisPingHandler) +signal.signal(signal.SIGTERM, stop) +signal.signal(signal.SIGINT, stop) +server.serve_forever() +``` + +Do not call `server.shutdown()` directly from the signal handler. The event and helper thread prevent Python's same-thread `serve_forever()` deadlock. + +- [ ] **Step 2: Add the Redis and RustFS compile-time constants** + +Add to `crates/daemon/src/managed_resources/tests.rs`: + +```rust +const REDIS_SERVER_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/redis-server.py" +)); +const RUSTFS_SCRIPT_TEMPLATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/rustfs.py.in" +)); +const RUSTFS_REJECT_S3_SENTINEL: &str = "__PV_REJECT_S3__"; +``` + +Replace both `redis_server_script()` call sites with `REDIS_SERVER_SCRIPT`, then delete `redis_server_script`. + +- [ ] **Step 3: Replace the inline RustFS source generator with exact-once typed rendering** + +Replace the current three RustFS source functions with: + +```rust +fn rustfs_script() -> Result { + rustfs_script_source(false) +} + +fn auth_rejecting_rustfs_script() -> Result { + rustfs_script_source(true) +} + +fn rustfs_script_source(reject_s3: bool) -> Result { + let occurrence_count = RUSTFS_SCRIPT_TEMPLATE + .matches(RUSTFS_REJECT_S3_SENTINEL) + .count(); + if occurrence_count != 1 { + bail!( + "RustFS fixture must contain exactly one {RUSTFS_REJECT_S3_SENTINEL} sentinel; found {occurrence_count}" + ); + } + + let replacement = if reject_s3 { "True" } else { "False" }; + let script = RUSTFS_SCRIPT_TEMPLATE.replacen(RUSTFS_REJECT_S3_SENTINEL, replacement, 1); + if script.contains(RUSTFS_REJECT_S3_SENTINEL) { + bail!("RustFS fixture still contains {RUSTFS_REJECT_S3_SENTINEL} after rendering"); + } + + Ok(script) +} +``` + +Propagate the new `Result` explicitly at every generated call site: + +```rust +fn seed_rustfs_fixture_artifact(paths: &PvPaths, track: &str) -> Result<()> { + let script = rustfs_script()?; + seed_rustfs_fixture_artifact_with_script(paths, track, &script) +} + +fn seed_auth_rejecting_rustfs_fixture_artifact(paths: &PvPaths, track: &str) -> Result<()> { + let script = auth_rejecting_rustfs_script()?; + seed_rustfs_fixture_artifact_with_script(paths, track, &script) +} +``` + +In `create_rustfs_archive`, render before writing: + +```rust +let script = rustfs_script()?; +state::fs::write_sensitive_file(&executable, &script)?; +``` + +Delete the old raw RustFS shell/Python heredoc. Do not alter `seed_rustfs_fixture_artifact_with_script`, because the auth-rejection scenario still passes rendered source into it. + +- [ ] **Step 4: Parse the direct Python and both rendered template variants in memory** + +Run: + +```shell +python3 -c 'import ast, pathlib; paths = [pathlib.Path("crates/daemon/test-fixtures/managed-resources/redis-server.py")]; [ast.parse(path.read_text(), filename=str(path)) for path in paths]' +python3 -c 'import ast, pathlib; path = pathlib.Path("crates/daemon/test-fixtures/managed-resources/rustfs.py.in"); source = path.read_text(); sentinel = "__PV_REJECT_S3__"; assert source.count(sentinel) == 1; [ast.parse(source.replace(sentinel, value), filename=f"{path}:{value}") for value in ("False", "True")]' +``` + +Expected: both commands exit 0 and no `__pycache__` directory is created. + +- [ ] **Step 5: Run Redis/RustFS lifecycle coverage and ensure snapshots are unchanged** + +Run: + +```shell +cargo fmt --all +cargo nextest run -p daemon --lib --all-features --locked \ + -E 'test(redis_reconciliation_marks_prefix_allocation_ready_and_renders_env) | test(redis_project_demand_installs_missing_fixture_track_before_start) | test(rustfs_reconciliation_creates_bucket_and_renders_env) | test(rustfs_project_demand_installs_missing_fixture_track_before_start) | test(rustfs_allocation_failure_preserves_project_env_and_records_failed_runtime) | test(rustfs_runtime_receives_private_credentials_without_persisting_them)' +cargo nextest run -p daemon --test fixture_contracts --all-features --locked +git diff --check +``` + +Expected: every selected lifecycle test and all five fixture contracts pass. No existing `.snap` file changes. + +- [ ] **Step 6: Commit the Redis and RustFS extraction** + +```shell +git add crates/daemon/test-fixtures/managed-resources/redis-server.py \ + crates/daemon/src/managed_resources/tests.rs +git commit -m "refactor(daemon): extract Redis and RustFS test fixtures" +``` + +--- + +### Task 4: Extract gateway shell parents and Python server companions + +**Files:** + +- Create: `crates/daemon/test-fixtures/gateway/fake-frankenphp.sh` +- Create: `crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py` +- Create: `crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port.sh.in` +- Create: `crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port-server.py` +- Modify: `crates/daemon/tests/gateway_reconciliation.rs:1-31,1759-1885` + +**Interfaces:** + +- Consumes: Existing absolute `bin/frankenphp` paths beneath each temporary release and `set_executable`; existing gateway reconciliation tests. +- Produces: `write_fake_frankenphp(&Utf8Path) -> Result<()>`, `write_fake_frankenphp_that_hangs_on_port(&Utf8Path, u16) -> Result<()>`, and a private exact-once blocked-port renderer with unchanged call sites. + +- [ ] **Step 1: Add the normal FrankenPHP shell parent and Python server body** + +Create `crates/daemon/test-fixtures/gateway/fake-frankenphp.sh`: + +```sh +#!/bin/sh +set -eu + +if [ "$1" = "validate" ]; then + test -f "$3" + exit 0 +fi + +if [ "$1" = "run" ]; then + python3 - "$3" < "$0.server.py" & + child="$!" + trap ':' USR1 + trap 'kill "$child"; wait "$child"; exit 0' TERM INT + while true; do + wait "$child" && exit 0 + status="$?" + if kill -0 "$child" 2>/dev/null; then + continue + fi + exit "$status" + done +fi + +exit 2 +``` + +Create `crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py`: + +```python +#!/usr/bin/env python3 +import http.server +import re +import signal +import ssl +import sys +import threading + + +signal.signal(signal.SIGUSR1, signal.SIG_IGN) + +config = open(sys.argv[1], encoding="utf-8").read() + + +def required(pattern): + match = re.search(pattern, config, re.MULTILINE) + if not match: + raise SystemExit(f"missing fake runtime setting: {pattern}") + return match.group(1) + + +def optional(pattern): + match = re.search(pattern, config, re.MULTILINE) + if not match: + return None + return match.group(1) + + +class Handler(http.server.SimpleHTTPRequestHandler): + def log_message(self, format, *args): + pass + + +http_port = int(required(r"^# PV_FAKE_PORT (\d+)$")) +https_port = optional(r"^\s*https_port (\d+)$") +cert_path = optional(r'^\s*cert "([^"]+)"$') +key_path = optional(r'^\s*key "([^"]+)"$') +servers = [http.server.ThreadingHTTPServer(("127.0.0.1", http_port), Handler)] + +if https_port is not None and cert_path is not None and key_path is not None: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(certfile=cert_path, keyfile=key_path) + https_server = http.server.ThreadingHTTPServer( + ("127.0.0.1", int(https_port)), Handler + ) + https_server.socket = context.wrap_socket(https_server.socket, server_side=True) + servers.append(https_server) + +for server in servers[1:]: + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + +with servers[0] as server: + server.serve_forever() +``` + +The shell invocation must stay exactly `python3 - "$3" < "$0.server.py" &`. It preserves the existing Python `sys.argv[0] == "-"`, config argument position, shell PID, process group, signal forwarding, and child-status loop. + +- [ ] **Step 2: Add the blocked-port shell template and server companion** + +Create `crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port.sh.in`: + +```sh +#!/bin/sh +set -eu + +if [ "$1" = "validate" ]; then + test -f "$3" + exit 0 +fi + +if [ "$1" = "run" ]; then + port="$(awk '/^# PV_FAKE_PORT / { print $3; exit }' "$3")" + if [ "$port" = "__PV_BLOCKED_PORT__" ]; then + sleep 30 + exit 0 + fi + python3 "$0.server.py" "$port" & + child="$!" + trap ':' USR1 + trap 'kill "$child"; wait "$child"; exit 0' TERM INT + while true; do + wait "$child" && exit 0 + status="$?" + if kill -0 "$child" 2>/dev/null; then + continue + fi + exit "$status" + done +fi + +exit 2 +``` + +Create `crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port-server.py`: + +```python +#!/usr/bin/env python3 +import http.server +import signal +import sys + + +signal.signal(signal.SIGUSR1, signal.SIG_IGN) +port = int(sys.argv[1]) +with http.server.ThreadingHTTPServer( + ("127.0.0.1", port), http.server.SimpleHTTPRequestHandler +) as server: + server.serve_forever() +``` + +The blocked child intentionally changes only its interpreter source argument from `-c` to the sibling file path. Keep every shell-parent behavior unchanged. The `awk` program uses literal single braces now that it is no longer inside Rust `format!`. + +- [ ] **Step 3: Add compile-time sources and exact-once materialization helpers** + +At the top of `crates/daemon/tests/gateway_reconciliation.rs`: + +```rust +use anyhow::{Result, bail}; +use camino::{Utf8Path, Utf8PathBuf}; +``` + +Replace the existing single-name imports; do not duplicate them. Add: + +```rust +const FAKE_FRANKENPHP_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/gateway/fake-frankenphp.sh" +)); +const FAKE_FRANKENPHP_SERVER_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/gateway/fake-frankenphp-server.py" +)); +const FAKE_FRANKENPHP_HANGS_ON_PORT_SCRIPT_TEMPLATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/gateway/fake-frankenphp-hangs-on-port.sh.in" +)); +const FAKE_FRANKENPHP_HANGS_ON_PORT_SERVER_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/gateway/fake-frankenphp-hangs-on-port-server.py" +)); +const FAKE_FRANKENPHP_BLOCKED_PORT_SENTINEL: &str = "__PV_BLOCKED_PORT__"; +``` + +Replace the two raw-string writers with: + +```rust +fn write_fake_frankenphp(path: &Utf8Path) -> Result<()> { + write_fake_frankenphp_fixture( + path, + FAKE_FRANKENPHP_SCRIPT, + FAKE_FRANKENPHP_SERVER_SCRIPT, + ) +} + +fn write_fake_frankenphp_that_hangs_on_port( + path: &Utf8Path, + blocked_port: u16, +) -> Result<()> { + let script = render_fake_frankenphp_hangs_on_port_script(blocked_port)?; + + write_fake_frankenphp_fixture( + path, + &script, + FAKE_FRANKENPHP_HANGS_ON_PORT_SERVER_SCRIPT, + ) +} + +fn write_fake_frankenphp_fixture( + path: &Utf8Path, + shell_script: &str, + server_script: &str, +) -> Result<()> { + let server_path = Utf8PathBuf::from(format!("{path}.server.py")); + + fs::write_sensitive_file(&server_path, server_script)?; + fs::write_sensitive_file(path, shell_script)?; + set_executable(path)?; + + Ok(()) +} + +fn render_fake_frankenphp_hangs_on_port_script(blocked_port: u16) -> Result { + let occurrence_count = FAKE_FRANKENPHP_HANGS_ON_PORT_SCRIPT_TEMPLATE + .matches(FAKE_FRANKENPHP_BLOCKED_PORT_SENTINEL) + .count(); + if occurrence_count != 1 { + bail!( + "fake FrankenPHP blocked-port template must contain exactly one {FAKE_FRANKENPHP_BLOCKED_PORT_SENTINEL} sentinel; found {occurrence_count}" + ); + } + + let replacement = blocked_port.to_string(); + let script = FAKE_FRANKENPHP_HANGS_ON_PORT_SCRIPT_TEMPLATE.replacen( + FAKE_FRANKENPHP_BLOCKED_PORT_SENTINEL, + &replacement, + 1, + ); + if script.contains(FAKE_FRANKENPHP_BLOCKED_PORT_SENTINEL) { + bail!("fake FrankenPHP blocked-port sentinel remained after rendering"); + } + + Ok(script) +} +``` + +Writing the non-executable companion first prevents a runnable wrapper from existing without its server input. Append `.server.py` literally; do not use `with_extension`, canonicalize the path, or embed a checkout path. All current callers already pass absolute temporary paths. Keep validators and `shell_single_quoted` inline. + +- [ ] **Step 4: Lint rendered shell and Python sources** + +Run: + +```shell +shellcheck crates/daemon/test-fixtures/gateway/fake-frankenphp.sh +sed 's/__PV_BLOCKED_PORT__/12345/' \ + crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port.sh.in \ + | shellcheck -s sh - +python3 -c 'import ast, pathlib; paths = [pathlib.Path("crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py"), pathlib.Path("crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port-server.py")]; [ast.parse(path.read_text(), filename=str(path)) for path in paths]' +``` + +Expected: all commands exit 0. The rendered shell contains no sentinel, and Python parsing creates no bytecode. + +- [ ] **Step 5: Run the full gateway target** + +Run: + +```shell +cargo fmt --all +cargo nextest run -p daemon --test gateway_reconciliation --all-features --locked +git diff --check +``` + +Expected: every gateway reconciliation test passes, including `gateway_reconciliation_starts_gateway_and_one_worker_per_php_track` and `gateway_reconciliation_rolls_back_config_when_runtime_readiness_fails`. The existing gateway snapshots remain byte-for-byte unchanged. + +- [ ] **Step 6: Commit the paired gateway fixtures** + +```shell +git add crates/daemon/test-fixtures/gateway \ + crates/daemon/tests/gateway_reconciliation.rs +git commit -m "refactor(gateway): extract FrankenPHP test fixtures" +``` + +--- + +### Task 5: Extract the supervisor ownership fixture and document Python 3 + +**Files:** + +- Create: `crates/daemon/test-fixtures/supervisor/owned-python-runtime.py` +- Modify: `crates/daemon/tests/supervisor_foundation.rs:1-28,475-500` +- Modify: `CONTRIBUTING.md:19-39` + +**Interfaces:** + +- Consumes: Existing macOS `supervisor_verifies_owned_python_shebang_script` behavior and materialization helper. +- Produces: A compile-time macOS-only ownership fixture constant and an explicit contributor prerequisite; no production interface changes. + +- [ ] **Step 1: Add the direct Python ownership fixture** + +Create `crates/daemon/test-fixtures/supervisor/owned-python-runtime.py`: + +```python +#!/usr/bin/env python3 +import signal +import sys + + +def stop(_signum, _frame): + sys.exit(0) + + +if sys.argv[1:] != ["1025", "8025"]: + sys.exit(2) + +signal.signal(signal.SIGTERM, stop) +signal.pause() +``` + +Keep the shebang at byte zero and preserve `signal.pause()`, argument validation, and SIGTERM exit exactly. + +- [ ] **Step 2: Replace the supervisor test's raw string with compile-time inclusion** + +Add near the existing test-only type alias in `crates/daemon/tests/supervisor_foundation.rs`: + +```rust +#[cfg(target_os = "macos")] +const OWNED_PYTHON_RUNTIME_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/supervisor/owned-python-runtime.py" +)); +``` + +The `cfg` prevents an unused private constant on non-macOS all-target Clippy runs. In `supervisor_verifies_owned_python_shebang_script`, replace only the raw-string write: + +```rust +state::fs::write_sensitive_file(&runtime, OWNED_PYTHON_RUNTIME_SCRIPT)?; +``` + +Keep the materialized runtime path without a `.py` extension, executable mode, process name, arguments, ownership polling, PID assertion, and stop behavior unchanged. + +- [ ] **Step 3: Document the existing Python runtime prerequisite** + +Add this paragraph to `CONTRIBUTING.md` immediately after the opening sentence of `## Testing`: + +```markdown +Daemon tests that exercise Managed Resource, gateway, and supervisor fixtures require `python3` on `PATH`. These fixtures use only Python's standard library; no Python packages or virtual environment are required. +``` + +Do not add setup-python, a version pin, dependencies, or CI workflow changes. + +- [ ] **Step 4: Verify ownership and source syntax** + +Run: + +```shell +python3 -c 'import ast, pathlib; path = pathlib.Path("crates/daemon/test-fixtures/supervisor/owned-python-runtime.py"); ast.parse(path.read_text(), filename=str(path))' +cargo fmt --all +cargo nextest run -p daemon --test supervisor_foundation --all-features --locked \ + -E 'test(supervisor_verifies_owned_python_shebang_script)' +git diff --check +``` + +Expected on macOS: the ownership test passes and the materialized direct shebang process stops cleanly. No new test or snapshot is required because the existing test directly exercises the relocated program. + +- [ ] **Step 5: Commit the supervisor fixture and prerequisite documentation** + +```shell +git add crates/daemon/test-fixtures/supervisor/owned-python-runtime.py \ + crates/daemon/tests/supervisor_foundation.rs \ + CONTRIBUTING.md +git commit -m "refactor(supervisor): extract Python ownership fixture" +``` + +--- + +### Task 6: Run complete fixture lint and workspace verification + +**Files:** + +- Verify only: all files changed by Tasks 1-5 +- Verify only: no changes under `crates/pv-release`, `.github/workflows`, `.config`, or production daemon modules + +**Interfaces:** + +- Consumes: All five implementation commits and the approved design constraints. +- Produces: Evidence that extracted assets are syntactically valid, behaviorally compatible, warning-free, and isolated to the approved scope. + +- [ ] **Step 1: Shellcheck every extracted shell source, rendering the template first** + +Run: + +```shell +shellcheck \ + crates/daemon/test-fixtures/managed-resources/postgres-initdb.sh \ + crates/daemon/test-fixtures/managed-resources/postgres-unready.sh \ + crates/daemon/test-fixtures/managed-resources/mailpit-unready.sh \ + crates/daemon/test-fixtures/gateway/fake-frankenphp.sh +sed 's/__PV_BLOCKED_PORT__/12345/' \ + crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port.sh.in \ + | shellcheck -s sh - +``` + +Expected: both shellcheck invocations exit 0. + +- [ ] **Step 2: Parse every Python source and both rendered RustFS variants without bytecode** + +Run: + +```shell +python3 -c 'import ast, pathlib; root = pathlib.Path("crates/daemon/test-fixtures"); paths = sorted(root.rglob("*.py")); [ast.parse(path.read_text(), filename=str(path)) for path in paths]; print(f"parsed {len(paths)} Python fixtures")' +python3 -c 'import ast, pathlib; path = pathlib.Path("crates/daemon/test-fixtures/managed-resources/rustfs.py.in"); source = path.read_text(); sentinel = "__PV_REJECT_S3__"; assert source.count(sentinel) == 1; [ast.parse(source.replace(sentinel, value), filename=f"{path}:{value}") for value in ("False", "True")]' +``` + +Expected: the first command reports nine `.py` fixtures, both RustFS variants parse, no assertion fails, and no `__pycache__` is created. + +- [ ] **Step 3: Confirm the intended raw strings are gone and inline exceptions remain** + +Run: + +```shell +if rg -n "fn (mysql_fixture_script|fake_mailpit_script|fake_postgres_initdb_script|fake_postgres_script|unready_fake_postgres_script|unready_fake_mailpit_script|fast_exit_fake_mailpit_script|mailpit_script|redis_server_script)" crates/daemon; then + exit 1 +fi +if rg -n "python3 .*<<'PY'|python3 -c" crates/daemon --glob '*.rs'; then + exit 1 +fi +if rg -n "<<'PY'|python3 -c" crates/daemon/test-fixtures --glob '*.sh' --glob '*.sh.in'; then + exit 1 +fi +rg -n "fn fake_sql_script|fn write_failing_validator|fn write_hanging_frankenphp_validator|const HANGING_FIXTURE" crates/daemon --glob '*.rs' +``` + +Expected: all three negative searches print nothing; the final search finds the intentionally inline scenario-local fixtures, including the approved `HANGING_FIXTURE` scenario-local Rust string. + +- [ ] **Step 4: Run formatting, Clippy, and the complete locked workspace suite** + +Run: + +```shell +cargo fmt --all --check +cargo clippy --workspace --all-targets --all-features --locked -- -D warnings +cargo nextest run --workspace --all-features --locked +``` + +Expected: formatting and Clippy exit 0, and every non-skipped workspace test passes. The five new fixture-contract tests are included in the total; existing snapshots remain unchanged. + +- [ ] **Step 5: Audit scope, modes, sentinels, and final repository state** + +Run: + +```shell +merge_base="$(git merge-base main HEAD)" +git diff "$merge_base" --check +git diff --stat "$merge_base" +git diff --name-only "$merge_base" +git diff --name-status "$merge_base" -- 'crates/daemon/**/snapshots/*.snap' +find crates/daemon/test-fixtures -type f -exec stat -f '%Sp %N' {} \; | sort +rg -n "__PV_REJECT_S3__|__PV_BLOCKED_PORT__" crates/daemon/test-fixtures crates/daemon/src crates/daemon/tests +git status --short +``` + +Expected: + +- `git diff --check` is clean. +- Changed paths are limited to the approved design/plan documents, `crates/daemon/test-fixtures/`, the four named daemon test files, the new fixture-contract target/snapshots, and `CONTRIBUTING.md`. +- Snapshot status lists exactly five added fixture-contract snapshots and no modified existing snapshot. +- Every checked-in fixture is non-executable (`-rw-r--r--`, mode `100644`). +- Each sentinel appears once in its template and only in the corresponding Rust renderer/contract test where expected. +- `git status --short` prints nothing. If any generated or formatted file remains, inspect and commit it with the task that owns it before reporting completion. diff --git a/docs/superpowers/plans/2026-07-16-daemon-fixture-review-corrections.md b/docs/superpowers/plans/2026-07-16-daemon-fixture-review-corrections.md new file mode 100644 index 00000000..06cb684a --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-daemon-fixture-review-corrections.md @@ -0,0 +1,1098 @@ +# Daemon Fixture Review Corrections Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. + +**Goal:** Preserve the accepted historical fixture-contract corrections and specify the final timeout-budget, complete-marker, backpressure-safe output-capture, deterministic PostgreSQL shutdown, and historical TERM/INT signal-status preservation follow-ups in Tasks 5–10. + +**Architecture:** Tasks 1–4 are an immutable historical sequence: a private synchronous fixture-command runner owns deadline polling and bounded cleanup, and MySQL/PostgreSQL lifecycle tests coordinate through a handler marker. Tasks 5–7 supersede the accepted final details by adding scheduling slack to the test-only timeout budget, requiring the complete marker contents, and redirecting child output to anonymous temporary files before reading it after confirmed exit. Tasks 8–10 add deterministic PostgreSQL shutdown and preserve the historical TERM/INT signal-derived status for all six fixture producers while retaining their normal lifecycle and protocol behavior. + +**Tech Stack:** Rust 2024, anyhow, camino, rustix, Python 3 standard library, cargo-nextest, Clippy. + +## Global Constraints + +- Work only in the existing refactor/daemon-test-fixtures worktree and preserve unrelated user changes. +- Do not change production daemon or supervisor behavior, process ownership matching, nextest configuration, CI scheduling, fixture process-group topology, Cargo.toml, Cargo.lock, dependencies, or manifests. Only Tasks 8–10 may change the six listed Python fixture producers and their lifecycle implementation. +- Python fixtures remain standard-library-only. Preserve non-signal normal exits, stdout, stderr, custom MySQL environment variables, CLI behavior, and protocol behavior. Explicitly preserve the historical signal-derived status for TERM and INT exits. +- Tasks 1–4 are historical implementations, including their commits and code blocks. Do not amend them beyond correcting the PostgreSQL marker prose: the marker is written at handler entry before the first blocking `read_startup`, and `statements`/`portals` initialize after the marker. +- Tasks 5–7 supersede the accepted final details. Final behavior uses a 3-second command deadline, 10-millisecond poll interval, 1-second bounded cleanup deadline, and a 100-millisecond test-only scheduling margin. Every post-spawn timeout or `try_wait` error routes through bounded cleanup. +- Tasks 5–7 use a per-test `PV_FIXTURE_HANDLER_STARTED` marker rather than a fixed delay. MySQL and PostgreSQL Python producers remain unchanged for Tasks 5–7; Tasks 8–10 intentionally change fixture lifecycle and shutdown behavior. Rust accepts only the exact UTF-8 contents `"started\n"` before signalling the fixture. +- The final runner captures stdout and stderr in two anonymous `camino_tempfile::tempfile` files, reads each only after confirmed exit, and preserves `String::from_utf8` conversion and `ExitStatus::code`. Do not use threads, pipes, new dependencies, manifest/lockfile changes, unsafe code, `panic!`, `unwrap()`, or Clippy ignores. +- Set daemon_threads = True on custom ThreadingMixIn servers for MySQL, PostgreSQL, fake Mailpit, and Mailpit. Redis retains its existing setting. Do not add Mailpit implementation-detail lifecycle tests because its handlers already return promptly. +- Preserve the positive HANGING_FIXTURE extraction-plan correction in docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md; this plan does not alter it. +- The four historical commit boundaries map one-to-one to Tasks 1–4. Their blocking reap and fixed 100-millisecond settle sleep are historical interim behavior. Tasks 5–10 have six additional, separate commit boundaries and define the final accepted details. + +## File Map + +- Modify: crates/daemon/tests/fixture_contracts.rs + - Final state: bounded command cleanup with a scheduling-margin test budget, complete-marker synchronization, backpressure-safe temporary-file output capture, deterministic PostgreSQL race coverage, and TERM/INT signal-status coverage for all six fixtures. +- Create: crates/daemon/tests/snapshots/fixture_contracts__fixture_handler_marker_requires_complete_contents.snap + - Captures the complete-marker regression's timeout result. +- Create: crates/daemon/tests/snapshots/fixture_contracts__fixture_command_captures_verbose_output_without_pipe_backpressure.snap + - Captures the successful verbose fixture exit code and exact stdout/stderr lengths. +- Modify: crates/daemon/test-fixtures/managed-resources/mysql.py + - Final state: daemon request threads, optional handler marker before recv, and historical TERM/INT signal-derived status after orderly shutdown. +- Modify: crates/daemon/test-fixtures/managed-resources/postgres.py + - Final state: daemon request threads, the optional marker at handler entry before `statements`/`portals` initialization and the first blocking `read_startup`, deterministic main-thread serving, and historical TERM/INT signal-derived status. +- Modify: crates/daemon/test-fixtures/managed-resources/fake-mailpit.py + - Final state: daemon SMTP request threads and historical TERM/INT signal-derived status after all listeners close. +- Modify: crates/daemon/test-fixtures/managed-resources/mailpit.py + - Final state: daemon SMTP request threads and historical TERM/INT signal-derived status after all listeners close. +- Modify: crates/daemon/test-fixtures/managed-resources/redis-server.py + - Final state: preserved process-group TERM/INT signal exit status. +- Modify: crates/daemon/test-fixtures/managed-resources/rustfs.py.in + - Final state: preserved process-group TERM/INT signal exit status. +- The six producer final states are: MySQL and PostgreSQL request-thread lifecycle support (with PostgreSQL deterministic main-thread serving), fake Mailpit and Mailpit request-thread lifecycle support, and preserved process-group TERM/INT signal status for Redis and RustFS. + +### Task 1: Bound Fixture-Contract Subprocesses + +**Historical commit:** b07cb793882ba59224b6a59ce9492013b1930e11 — first implementation commit. + +**Files:** +- Modify: crates/daemon/tests/fixture_contracts.rs + +**Interfaces:** +- Produces FIXTURE_COMMAND_TIMEOUT, FIXTURE_COMMAND_POLL_INTERVAL, run_fixture_command, fixture_output, and process_pid. +- This task deliberately has no lifecycle cleanup helper. Its timeout cleanup is an interim blocking implementation, superseded by Task 3 before final verification. + +- [ ] **Step 1: Add the hanging-child regression and runner** + +Add the Error, Output, Stdio, anyhow, Pid, and test_kill_process imports; add the 3-second command timeout and 10-millisecond poll interval; then add the scenario-local HANGING_FIXTURE and the PID-reaping regression. Keep the accepted extraction-plan fixture text unchanged in the separate extraction plan. + +~~~rust +const HANGING_FIXTURE: &str = r#"#!/usr/bin/env python3 +import os +import signal +import sys + + +with open(sys.argv[1], "w", encoding="utf-8") as pid_file: + pid_file.write(f"{os.getpid()}\n") + +signal.pause() +"#; + +#[test] +fn fixture_command_timeout_kills_and_reaps_child() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("hanging-fixture"); + let pid_path = tempdir.path().join("hanging-fixture.pid"); + + materialize_fixture(&fixture, HANGING_FIXTURE)?; + let mut command = FixtureCommand::new(fixture.as_std_path()); + command + .arg(pid_path.as_std_path()) + .current_dir(tempdir.path()); + + let error = match run_fixture_command(&mut command, Duration::from_secs(1)) { + Ok(output) => bail!("hanging fixture unexpectedly exited: {output:?}"), + Err(error) => error, + }; + let io_error = error + .downcast_ref::() + .ok_or_else(|| anyhow!("fixture timeout did not return an I/O error: {error}"))?; + assert_eq!(io_error.kind(), ErrorKind::TimedOut); + + let raw_pid = state::fs::read_to_string(&pid_path)? + .trim() + .parse::()?; + let pid = process_pid(raw_pid)?; + assert!(test_kill_process(pid).is_err()); + + Ok(()) +} +~~~ + +- [ ] **Step 2: Implement the b07-era runner** + +Use this historical runner exactly. It intentionally performs a blocking wait_with_output after timeout; Task 3 replaces that behavior. + +~~~rust +fn run_fixture_command(command: &mut FixtureCommand, timeout: Duration) -> Result { + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn()?; + let deadline = Instant::now() + timeout; + + loop { + if child.try_wait()?.is_some() { + return fixture_output(child.wait_with_output()?); + } + if Instant::now() >= deadline { + let kill_result = child.kill(); + let wait_result = child.wait_with_output(); + if let Err(error) = wait_result { + return Err(error.into()); + } + if let Err(error) = kill_result + && error.kind() != ErrorKind::InvalidInput + { + return Err(error.into()); + } + + return Err(Error::new( + ErrorKind::TimedOut, + format!("fixture command timed out after {} ms", timeout.as_millis()), + ) + .into()); + } + + thread::sleep(FIXTURE_COMMAND_POLL_INTERVAL); + } +} +~~~ + +Add these b07-era private helpers immediately after the runner. The runner uses `fixture_output` for confirmed-exit capture, and the regression uses `process_pid` to convert the recorded child PID for `test_kill_process`. + +~~~rust +fn fixture_output(output: Output) -> Result { + Ok(FixtureOutput { + code: output.status.code(), + stdout: String::from_utf8(output.stdout)?, + stderr: String::from_utf8(output.stderr)?, + }) +} + +fn process_pid(pid: u32) -> Result { + let raw_pid = i32::try_from(pid)?; + Pid::from_raw(raw_pid).ok_or_else(|| anyhow!("invalid process id {raw_pid}")) +} +~~~ + +Route run_fixture and the direct MySQL empty-data-directory command through that runner. + +- [ ] **Step 3: Verify and self-review the historical first commit** + +Run: + +~~~shell +cargo nextest run -p daemon --test fixture_contracts -E 'test(fixture_command_timeout_kills_and_reaps_child)' +cargo nextest run -p daemon --test fixture_contracts +cargo fmt --all --check +cargo clippy -p daemon --test fixture_contracts --locked -- -D warnings +git diff --check +~~~ + +Confirm that both former output() callers use run_fixture_command, the child PID is gone after the timeout regression, and no dependency, lockfile, snapshot, or unrelated change exists. This interim self-review must not claim blocking cleanup is final; Task 3 hardens it before final verification. + +Commit: + +~~~shell +git add crates/daemon/tests/fixture_contracts.rs +git commit -m "fix(daemon): bound fixture contract subprocesses" +~~~ + +### Task 2: Daemonize Custom Fixture Request Handlers + +**Historical commit:** 89affe2eb9ac6fb01dc932a2ec0a69d53d78e125 — second implementation commit. + +**Files:** +- Modify: crates/daemon/tests/fixture_contracts.rs +- Modify: crates/daemon/test-fixtures/managed-resources/mysql.py +- Modify: crates/daemon/test-fixtures/managed-resources/postgres.py +- Modify: crates/daemon/test-fixtures/managed-resources/fake-mailpit.py +- Modify: crates/daemon/test-fixtures/managed-resources/mailpit.py + +**Interfaces:** +- Consumes Task 1's runner and process_pid. +- Produces connect_to_loopback, wait_for_child_exit, the initial kill_and_reap_child, idle-client lifecycle tests, and daemon request threads. +- The 100-millisecond settle delay and this helper's blocking wait are interim. Tasks 3 and 4 supersede them before final verification. + +- [ ] **Step 1: Add the historical lifecycle constants, RED tests, and helpers** + +Add: + +~~~rust +const FIXTURE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(1); +const FIXTURE_HANDLER_SETTLE_TIME: Duration = Duration::from_millis(100); +~~~ + +Add MySQL and PostgreSQL tests that start the fixture, connect an idle client, retain it in scope, sleep for FIXTURE_HANDLER_SETTLE_TIME, send SIGTERM, and require exit within FIXTURE_SHUTDOWN_TIMEOUT. Use this historical cleanup shape: + +~~~rust +let lifecycle = (|| { + let _idle_client = connect_to_loopback(port, FIXTURE_COMMAND_TIMEOUT)?; + thread::sleep(FIXTURE_HANDLER_SETTLE_TIME); + kill_process(process_pid(child.id())?, Signal::TERM)?; + if !wait_for_child_exit(&mut child, FIXTURE_SHUTDOWN_TIMEOUT)? { + bail!("fixture did not exit after SIGTERM with an idle client"); + } + + Ok::<(), anyhow::Error>(()) +})(); +let cleanup = kill_and_reap_child(&mut child); + +if let Err(error) = lifecycle { + cleanup?; + return Err(error); +} +cleanup +~~~ + +Add the initial helpers. Its blocking wait is retained only for historical accuracy and is replaced in Task 3: + +~~~rust +fn connect_to_loopback(port: u16, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + + loop { + if let Ok(stream) = TcpStream::connect((Ipv4Addr::LOCALHOST, port)) { + return Ok(stream); + } + if Instant::now() >= deadline { + bail!("timed out connecting to fixture port {port}"); + } + + thread::sleep(FIXTURE_COMMAND_POLL_INTERVAL); + } +} + +fn wait_for_child_exit(child: &mut Child, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + + loop { + if child.try_wait()?.is_some() { + return Ok(true); + } + if Instant::now() >= deadline { + return Ok(false); + } + + thread::sleep(FIXTURE_COMMAND_POLL_INTERVAL); + } +} + +fn kill_and_reap_child(child: &mut Child) -> Result<()> { + let kill_result = child.kill(); + let wait_result = child.wait(); + if let Err(error) = wait_result { + return Err(error.into()); + } + if let Err(error) = kill_result + && error.kind() != ErrorKind::InvalidInput + { + return Err(error.into()); + } + + Ok(()) +} +~~~ + +Extend the Task 1 imports with: + +~~~rust +use std::process::{Child, Output, Stdio}; +use rustix::process::{Pid, Signal, kill_process, test_kill_process}; +~~~ + +Run the two lifecycle regressions. RED is the explicit did-not-exit failure after the bounded lifecycle wait; cleanup must still kill and reap both fixtures. + +- [ ] **Step 2: Apply the minimal threaded-server GREEN change** + +Add daemon_threads = True to each custom ThreadingMixIn class: + +~~~python +class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + daemon_threads = True +~~~ + +Use the same extra line in MySQL, fake Mailpit, and Mailpit; use it in PostgreSQL's Server class. Do not change Redis or any ThreadingHTTPServer. + +- [ ] **Step 3: Verify and self-review the historical second commit** + +Run: + +~~~shell +cargo nextest run -p daemon --test fixture_contracts -E 'test(mysql_fixture_exits_after_sigterm_with_idle_client) | test(postgres_fixture_exits_after_sigterm_with_idle_client)' +python3 -c 'import ast, pathlib; paths = [pathlib.Path(path) for path in ["crates/daemon/test-fixtures/managed-resources/mysql.py", "crates/daemon/test-fixtures/managed-resources/postgres.py", "crates/daemon/test-fixtures/managed-resources/fake-mailpit.py", "crates/daemon/test-fixtures/managed-resources/mailpit.py"]]; [ast.parse(path.read_text(), filename=str(path)) for path in paths]; print(f"parsed {len(paths)} Python fixtures")' +cargo nextest run -p daemon --test fixture_contracts +cargo fmt --all --check +cargo clippy -p daemon --test fixture_contracts --locked -- -D warnings +git diff --check +~~~ + +Confirm the RED-to-GREEN transition is caused only by daemon request threads and the idle client remains open through the exit assertion. Record that the settle constant and blocking helper are interim and must be replaced by Tasks 3–4 before final verification. + +Commit: + +~~~shell +git add crates/daemon/tests/fixture_contracts.rs +git add crates/daemon/test-fixtures/managed-resources/mysql.py +git add crates/daemon/test-fixtures/managed-resources/postgres.py +git add crates/daemon/test-fixtures/managed-resources/fake-mailpit.py +git add crates/daemon/test-fixtures/managed-resources/mailpit.py +git commit -m "fix(daemon): daemonize fixture request handlers" +~~~ + +### Task 3: Harden Fixture Child Cleanup + +**Historical commit:** 7efda23ec96991d18505d5855fb602d9e27c8275 — supersedes Tasks 1–2's blocking cleanup before final verification. + +**Files:** +- Modify: crates/daemon/tests/fixture_contracts.rs + +**Interfaces:** +- Keeps wait_for_child_exit and replaces the initial cleanup helper with bounded cleanup. +- Strengthens the timeout regression's elapsed-time bound and makes all post-spawn timeout and try_wait errors use cleanup. + +- [ ] **Step 1: Strengthen the timeout regression** + +Use a named one-second timeout and require the timeout-plus-cleanup path to finish within the command, cleanup, and poll budgets: + +~~~rust +let timeout = Duration::from_secs(1); +let started_at = Instant::now(); +let error = match run_fixture_command(&mut command, timeout) { + Ok(output) => bail!("hanging fixture unexpectedly exited: {output:?}"), + Err(error) => error, +}; +// ... assert ErrorKind::TimedOut ... +assert!( + started_at.elapsed() < timeout + FIXTURE_SHUTDOWN_TIMEOUT + FIXTURE_COMMAND_POLL_INTERVAL, + "fixture command timeout exceeded its cleanup deadline" +); +~~~ + +- [ ] **Step 2: Funnel every post-spawn failure through bounded cleanup** + +Replace the runner with the current behavior. The confirmed-exit wait_with_output path remains unchanged; only timeout and try_wait failures fall through to cleanup. + +~~~rust +fn run_fixture_command(command: &mut FixtureCommand, timeout: Duration) -> Result { + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn()?; + let deadline = Instant::now() + timeout; + + let error = loop { + match child.try_wait() { + Ok(Some(_)) => return fixture_output(child.wait_with_output()?), + Ok(None) => {} + Err(error) => break error.into(), + } + if Instant::now() >= deadline { + break Error::new( + ErrorKind::TimedOut, + format!("fixture command timed out after {} ms", timeout.as_millis()), + ) + .into(); + } + + thread::sleep(FIXTURE_COMMAND_POLL_INTERVAL); + }; + + kill_and_reap_child(&mut child)?; + Err(error) +} +~~~ + +- [ ] **Step 3: Make fake Mailpit cleanup use the same helper** + +After its readiness lifecycle closure, clean up on success and failure with the Task 3 helper: + +~~~rust +let cleanup = kill_and_reap_child(&mut child); + +let lifecycle = match lifecycle { + Ok(lifecycle) => lifecycle, + Err(error) => { + cleanup?; + return Err(error); + } +}; +cleanup?; +~~~ + +Keep the snapshot assertion after lifecycle and cleanup both succeed. + +- [ ] **Step 4: Replace the blocking reaper with the bounded final helper** + +Capture a genuine kill error, always attempt bounded wait_for_child_exit, tolerate InvalidInput only as the already-exited race, never block after an unconfirmed exit, and return the genuine kill error before the typed cleanup timeout: + +~~~rust +fn kill_and_reap_child(child: &mut Child) -> Result<()> { + let kill_error = match child.kill() { + Ok(()) => None, + Err(error) if error.kind() == ErrorKind::InvalidInput => None, + Err(error) => Some(error), + }; + let reap_result = wait_for_child_exit(child, FIXTURE_SHUTDOWN_TIMEOUT); + + if let Some(error) = kill_error { + return Err(error.into()); + } + if !reap_result? { + return Err(Error::new( + ErrorKind::TimedOut, + format!( + "timed out reaping fixture child after {} ms", + FIXTURE_SHUTDOWN_TIMEOUT.as_millis() + ), + ) + .into()); + } + + Ok(()) +} +~~~ + +- [ ] **Step 5: Verify focused cleanup behavior and commit** + +Run: + +~~~shell +cargo nextest run -p daemon --test fixture_contracts -E 'test(fixture_command_timeout_kills_and_reaps_child) | test(fake_mailpit_fixture_cli_ignores_extra_arguments)' +cargo nextest run -p daemon --test fixture_contracts +cargo fmt --all --check +cargo clippy -p daemon --test fixture_contracts --locked -- -D warnings +git diff --check +~~~ + +Confirm the elapsed bound, post-spawn cleanup funnel, unmodified confirmed-exit output path, fake-Mailpit success/failure cleanup, and bounded error precedence. Then commit: + +~~~shell +git add crates/daemon/tests/fixture_contracts.rs +git commit -m "fix(daemon): bound fixture child cleanup" +~~~ + +### Task 4: Synchronize Fixture Handler Entry + +**Historical commit:** fafd3c4f470ed2ed328801ce43f7b0cf778e8931 — supersedes Task 2's settle delay before final verification. + +**Files:** +- Modify: crates/daemon/tests/fixture_contracts.rs +- Modify: crates/daemon/test-fixtures/managed-resources/mysql.py +- Modify: crates/daemon/test-fixtures/managed-resources/postgres.py + +**Interfaces:** +- Produces wait_for_path(path: &Utf8Path, timeout: Duration) -> Result<()>. +- Rust tests opt in through PV_FIXTURE_HANDLER_STARTED; Python handlers write and close the UTF-8 marker before blocking reads. + +- [ ] **Step 1: RED — add only Rust marker synchronization** + +Remove FIXTURE_HANDLER_SETTLE_TIME and both settle sleeps. Add isolated marker paths, the environment variable, and bounded marker waits to both lifecycle tests: + +~~~rust +let handler_marker = tempdir.path().join("mysql-handler-started"); +// ... spawn ... +.env("PV_FIXTURE_HANDLER_STARTED", handler_marker.as_std_path()) +// ... after connect ... +wait_for_path(&handler_marker, FIXTURE_COMMAND_TIMEOUT)?; +~~~ + +Use the analogous postgres-handler-started path for PostgreSQL. Add the helper near the other bounded waits: + +~~~rust +fn wait_for_path(path: &Utf8Path, timeout: Duration) -> Result<()> { + let deadline = Instant::now() + timeout; + + loop { + if path_exists(path)? { + return Ok(()); + } + if Instant::now() >= deadline { + bail!("timed out waiting for fixture handler marker at {path}"); + } + + thread::sleep(FIXTURE_COMMAND_POLL_INTERVAL); + } +} +~~~ + +Do not change either Python fixture in this RED step. Both lifecycle tests must fail by the bounded marker timeout and still use Task 3's bounded cleanup. + +- [ ] **Step 2: GREEN — write and close the optional marker before blocking reads** + +At the beginning of each `Handler.handle`, add this exact env-gated UTF-8 write-and-close block. In MySQL it is immediately before `self.request.recv(1024)`. In PostgreSQL it is at handler entry; `statements` and `portals` initialize after the marker, and the first blocking `read_startup(self.request)` follows those initializations. + +~~~python +handler_marker = os.environ.get("PV_FIXTURE_HANDLER_STARTED") +if handler_marker: + with open(handler_marker, "w", encoding="utf-8") as marker: + marker.write("started\n") +~~~ + +The Task 4 MySQL shape is: + +~~~python +class Handler(socketserver.BaseRequestHandler): + def handle(self): + handler_marker = os.environ.get("PV_FIXTURE_HANDLER_STARTED") + if handler_marker: + with open(handler_marker, "w", encoding="utf-8") as marker: + marker.write("started\n") + self.request.recv(1024) +~~~ + +The Task 4 PostgreSQL shape places the completed marker write at handler entry, before `statements`/`portals` initialization and its first blocking read: + +~~~python +class Handler(socketserver.BaseRequestHandler): + def handle(self): + handler_marker = os.environ.get("PV_FIXTURE_HANDLER_STARTED") + if handler_marker: + with open(handler_marker, "w", encoding="utf-8") as marker: + marker.write("started\n") + statements = {} + portals = {} + try: + read_startup(self.request) +~~~ + +- [ ] **Step 3: Verify GREEN and the historical marker synchronization** + +Run the lifecycle tests, full fixture-contract suite, Python parsing, formatting, Clippy, diff checks, and 20 repetitions of each lifecycle regression: + +~~~shell +cargo nextest run -p daemon --test fixture_contracts -E 'test(mysql_fixture_exits_after_sigterm_with_idle_client) | test(postgres_fixture_exits_after_sigterm_with_idle_client)' +cargo nextest run -p daemon --test fixture_contracts +python3 -c 'import ast, pathlib; paths = [pathlib.Path(path) for path in ["crates/daemon/test-fixtures/managed-resources/mysql.py", "crates/daemon/test-fixtures/managed-resources/postgres.py"]]; [ast.parse(path.read_text(), filename=str(path)) for path in paths]; print(f"parsed {len(paths)} Python fixtures")' +cargo fmt --all --check +cargo clippy -p daemon --test fixture_contracts --locked -- -D warnings +git diff --check +iteration=1 +while [ "$iteration" -le 20 ]; do + cargo nextest run -p daemon --test fixture_contracts -E 'test(mysql_fixture_exits_after_sigterm_with_idle_client)' + iteration=$((iteration + 1)) +done +iteration=1 +while [ "$iteration" -le 20 ]; do + cargo nextest run -p daemon --test fixture_contracts -E 'test(postgres_fixture_exits_after_sigterm_with_idle_client)' + iteration=$((iteration + 1)) +done +~~~ + +Confirm the RED failures were marker-timeout failures with cleanup, GREEN waits for an actually-entered handler, no fixed settle sleep remains, and marker writes occur before both blocking reads. + +Commit: + +~~~shell +git add crates/daemon/tests/fixture_contracts.rs +git add crates/daemon/test-fixtures/managed-resources/mysql.py +git add crates/daemon/test-fixtures/managed-resources/postgres.py +git commit -m "test(daemon): synchronize fixture handler shutdown" +~~~ + +### Task 5: Correct the Timeout Timing Budget + +**Files:** +- Modify: crates/daemon/tests/fixture_contracts.rs + +**Interfaces:** +- Consumes the historical `FIXTURE_COMMAND_TIMEOUT`, `FIXTURE_SHUTDOWN_TIMEOUT`, and `FIXTURE_COMMAND_POLL_INTERVAL` constants and the timeout regression. +- Produces `FIXTURE_COMMAND_TIMEOUT_SCHEDULING_MARGIN: Duration = Duration::from_millis(100)` and the final test-only elapsed-time budget. + +- [ ] **Step 1: Add the scheduling margin and make the regression reliably GREEN** + +Add the named test-budget-only constant alongside the timeout and polling constants: + +~~~rust +const FIXTURE_COMMAND_TIMEOUT_SCHEDULING_MARGIN: Duration = Duration::from_millis(100); +~~~ + +In `fixture_command_timeout_kills_and_reaps_child`, replace the historical elapsed-time assertion with the final complete budget. The old one-poll budget is intentionally too tight when scheduling delays occur; do not change runtime deadlines, poll behavior, or cleanup behavior. + +~~~rust +assert!( + started_at.elapsed() + < timeout + + FIXTURE_SHUTDOWN_TIMEOUT + + FIXTURE_COMMAND_POLL_INTERVAL + + FIXTURE_COMMAND_POLL_INTERVAL + + FIXTURE_COMMAND_TIMEOUT_SCHEDULING_MARGIN, + "fixture command timeout exceeded its cleanup deadline" +); +~~~ + +Run: + +~~~shell +cargo nextest run -p daemon --test fixture_contracts -E 'test(fixture_command_timeout_kills_and_reaps_child)' +~~~ + +Expected: PASS. This test-only budget makes no production or fixture-runner timing change. + +- [ ] **Step 2: Verify scope and commit** + +Run: + +~~~shell +cargo fmt --all --check +cargo clippy -p daemon --test fixture_contracts --locked -- -D warnings +git diff --check +~~~ + +Confirm only `crates/daemon/tests/fixture_contracts.rs` changed and that the runtime timeout, shutdown timeout, and poll interval values are unchanged. Commit: + +~~~shell +git add crates/daemon/tests/fixture_contracts.rs +git commit -m "test(daemon): allow fixture timeout scheduling margin" +~~~ + +### Task 6: Require Complete Handler Markers + +**Files:** +- Modify: crates/daemon/tests/fixture_contracts.rs +- Create: crates/daemon/tests/snapshots/fixture_contracts__fixture_handler_marker_requires_complete_contents.snap + +**Interfaces:** +- Consumes the historical marker paths and `PV_FIXTURE_HANDLER_STARTED` producer contract. +- Replaces `wait_for_path(path: &Utf8Path, timeout: Duration) -> Result<()>` with `wait_for_handler_marker(path: &Utf8Path, timeout: Duration) -> Result<()>`. +- Produces `FIXTURE_HANDLER_MARKER_CONTENTS: &str = "started\n"`; the existing Python producers remain unchanged. + +- [ ] **Step 1: Add the incomplete-marker RED regression and snapshot target** + +Add the exact expected contents beside the timing constants: + +~~~rust +const FIXTURE_HANDLER_MARKER_CONTENTS: &str = "started\n"; +~~~ + +Add this regression beside the lifecycle tests. It creates an already-existing but incomplete marker and uses the existing `wait_for_path` helper with a zero timeout, so it has no sleep-based coordination. Under the old existence-only helper, the `Ok(())` branch makes the test fail before the snapshot is reached. + +~~~rust +#[test] +fn fixture_handler_marker_requires_complete_contents() -> Result<()> { + let tempdir = tempdir()?; + let handler_marker = tempdir.path().join("handler-started"); + + state::fs::write_sensitive_file(&handler_marker, "started")?; + + let error = match wait_for_path(&handler_marker, Duration::ZERO) { + Ok(()) => bail!("incomplete fixture handler marker unexpectedly satisfied waiter"), + Err(error) => error, + }; + + assert_fixture_snapshot( + tempdir.path(), + "fixture_handler_marker_requires_complete_contents", + error.to_string(), + ) +} +~~~ + +Run: + +~~~shell +cargo nextest run -p daemon --test fixture_contracts -E 'test(fixture_handler_marker_requires_complete_contents)' +~~~ + +Expected: FAIL with `incomplete fixture handler marker unexpectedly satisfied waiter`; no timing delay is involved. + +- [ ] **Step 2: Replace the existence-only waiter with exact-content polling** + +Replace `wait_for_path` with `wait_for_handler_marker`, update both lifecycle tests to call the new name after connecting the idle client, and update the new regression call from `wait_for_path(&handler_marker, Duration::ZERO)` to `wait_for_handler_marker(&handler_marker, Duration::ZERO)`. Add `use state::StateError;` with the top-level imports. Poll `state::fs::read_to_string`: return only for the exact marker contents, retry `NotFound` and incomplete contents, and immediately propagate every other state/filesystem error. Keep the historical timeout message shape and polling interval. + +~~~rust +fn wait_for_handler_marker(path: &Utf8Path, timeout: Duration) -> Result<()> { + let deadline = Instant::now() + timeout; + + loop { + match state::fs::read_to_string(path) { + Ok(contents) if contents == FIXTURE_HANDLER_MARKER_CONTENTS => return Ok(()), + Ok(_) => {} + Err(StateError::Filesystem { source, .. }) if source.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + if Instant::now() >= deadline { + bail!("timed out waiting for fixture handler marker at {path}"); + } + + thread::sleep(FIXTURE_COMMAND_POLL_INTERVAL); + } +} +~~~ + +Use the exact final calls in both lifecycle closures: + +~~~rust +wait_for_handler_marker(&handler_marker, FIXTURE_COMMAND_TIMEOUT)?; +~~~ + +Do not edit `mysql.py` or `postgres.py`: their existing write-and-close `"started\n"` producers already satisfy this contract. + +- [ ] **Step 3: Verify GREEN, accept the snapshot, and commit** + +Run: + +~~~shell +cargo insta test --accept --test-runner nextest -p daemon --test fixture_contracts -- fixture_handler_marker_requires_complete_contents +cargo nextest run -p daemon --test fixture_contracts -E 'test(fixture_handler_marker_requires_complete_contents) | test(mysql_fixture_exits_after_sigterm_with_idle_client) | test(postgres_fixture_exits_after_sigterm_with_idle_client)' +cargo fmt --all --check +cargo clippy -p daemon --test fixture_contracts --locked -- -D warnings +git diff --check +~~~ + +Verify the created snapshot is exactly: + +~~~text +--- +source: crates/daemon/tests/fixture_contracts.rs +expression: snapshot +--- +"timed out waiting for fixture handler marker at /handler-started" +~~~ + +Confirm incomplete existing contents time out, a missing marker still retries until its deadline, other read errors propagate, and both lifecycle tests require complete contents. Commit: + +~~~shell +git add crates/daemon/tests/fixture_contracts.rs +git add crates/daemon/tests/snapshots/fixture_contracts__fixture_handler_marker_requires_complete_contents.snap +git commit -m "test(daemon): require complete fixture handler markers" +~~~ + +### Task 7: Capture Fixture Output Without Pipe Backpressure + +**Files:** +- Modify: crates/daemon/tests/fixture_contracts.rs +- Create: crates/daemon/tests/snapshots/fixture_contracts__fixture_command_captures_verbose_output_without_pipe_backpressure.snap + +**Interfaces:** +- Consumes `run_fixture_command`, bounded `kill_and_reap_child`, `FixtureOutput`, and `camino_tempfile::tempfile` already available to the daemon test crate. +- Replaces `std::process::Output` with `std::process::ExitStatus` in `fixture_output` and introduces `read_fixture_output(file: &mut (impl Read + Seek)) -> Result>`. +- Produces a backpressure-safe runner that returns the child `ExitStatus::code` and UTF-8 stdout/stderr after confirmed exit. + +- [ ] **Step 1: Add the 2 MiB-per-stream RED regression** + +Add this standard-library-only scenario-local fixture beside `HANGING_FIXTURE`. It writes and flushes deterministic UTF-8 output totaling 2 MiB to each stream, then exits successfully. + +~~~rust +const VERBOSE_FIXTURE: &str = r#"#!/usr/bin/env python3 +import sys + + +contents = "v" * (2 * 1024 * 1024) +sys.stdout.write(contents) +sys.stdout.flush() +sys.stderr.write(contents) +sys.stderr.flush() +"#; +~~~ + +Add this test beside the timeout regression. It snapshots the successful exit code and both byte lengths, and explicitly checks the exact `2 * 1024 * 1024` expected length for each UTF-8 string. + +~~~rust +#[test] +fn fixture_command_captures_verbose_output_without_pipe_backpressure() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("verbose-fixture"); + + materialize_fixture(&fixture, VERBOSE_FIXTURE)?; + let mut command = FixtureCommand::new(fixture.as_std_path()); + command.current_dir(tempdir.path()); + + let output = run_fixture_command(&mut command, FIXTURE_COMMAND_TIMEOUT)?; + assert_eq!(output.stdout.len(), 2 * 1024 * 1024); + assert_eq!(output.stderr.len(), 2 * 1024 * 1024); + assert_fixture_snapshot( + tempdir.path(), + "fixture_command_captures_verbose_output_without_pipe_backpressure", + (output.code, output.stdout.len(), output.stderr.len()), + ) +} +~~~ + +Run: + +~~~shell +cargo nextest run -p daemon --test fixture_contracts -E 'test(fixture_command_captures_verbose_output_without_pipe_backpressure)' +~~~ + +Expected: FAIL with the existing piped implementation timing out. The 2 MiB written and flushed to each stream exceeds dynamically grown pipe capacity across supported Unix environments while remaining small for anonymous temporary files. + +- [ ] **Step 2: Redirect child output to anonymous files and read it after confirmed exit** + +Replace the `Output` import with `ExitStatus`; add `Read` and `Seek` imports; and import `camino_tempfile::tempfile` alongside `tempdir`: + +~~~rust +use std::io::{Error, ErrorKind, Read, Seek}; +use std::process::{Child, ExitStatus, Stdio}; + +use camino_tempfile::{tempdir, tempfile}; +~~~ + +Use two anonymous files for child stdout and stderr. Clone each file only for its child `Stdio`; retain the parent handles. Preserve the existing `try_wait`, deadline, and `kill_and_reap_child` error flow. On confirmed exit, pass the `ExitStatus` and retained files to the output helper. Do not add threads, pipes, dependencies, manifests, lockfile changes, unsafe code, `panic!`, `unwrap()`, or Clippy ignores. + +~~~rust +fn run_fixture_command(command: &mut FixtureCommand, timeout: Duration) -> Result { + let mut stdout = tempfile()?; + let mut stderr = tempfile()?; + command + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout.try_clone()?)) + .stderr(Stdio::from(stderr.try_clone()?)); + let mut child = command.spawn()?; + let deadline = Instant::now() + timeout; + + let error = loop { + match child.try_wait() { + Ok(Some(status)) => return fixture_output(status, &mut stdout, &mut stderr), + Ok(None) => {} + Err(error) => break error.into(), + } + if Instant::now() >= deadline { + break Error::new( + ErrorKind::TimedOut, + format!("fixture command timed out after {} ms", timeout.as_millis()), + ) + .into(); + } + + thread::sleep(FIXTURE_COMMAND_POLL_INTERVAL); + }; + + kill_and_reap_child(&mut child)?; + Err(error) +} + +fn fixture_output( + status: ExitStatus, + stdout: &mut (impl Read + Seek), + stderr: &mut (impl Read + Seek), +) -> Result { + Ok(FixtureOutput { + code: status.code(), + stdout: String::from_utf8(read_fixture_output(stdout)?)?, + stderr: String::from_utf8(read_fixture_output(stderr)?)?, + }) +} + +fn read_fixture_output(file: &mut (impl Read + Seek)) -> Result> { + file.rewind()?; + let mut contents = Vec::new(); + file.read_to_end(&mut contents)?; + + Ok(contents) +} +~~~ + +- [ ] **Step 3: Verify GREEN, accept the snapshot, and commit** + +Run: + +~~~shell +cargo insta test --accept --test-runner nextest -p daemon --test fixture_contracts -- fixture_command_captures_verbose_output_without_pipe_backpressure +cargo nextest run -p daemon --test fixture_contracts -E 'test(fixture_command_captures_verbose_output_without_pipe_backpressure) | test(fixture_command_timeout_kills_and_reaps_child)' +cargo fmt --all --check +cargo clippy -p daemon --test fixture_contracts --locked -- -D warnings +git diff --check +~~~ + +Verify the created snapshot is exactly: + +~~~text +--- +source: crates/daemon/tests/fixture_contracts.rs +expression: snapshot +--- +( + Some( + 0, + ), + 2097152, + 2097152, +) +~~~ + +Confirm both `2 * 1024 * 1024` outputs are captured without a timeout; output is read only after a confirmed exit; the timeout and `try_wait` failure path still uses bounded cleanup; and only the intended source file plus new snapshot changed. Commit: + +~~~shell +git add crates/daemon/tests/fixture_contracts.rs +git add crates/daemon/tests/snapshots/fixture_contracts__fixture_command_captures_verbose_output_without_pipe_backpressure.snap +git commit -m "fix(daemon): capture fixture output without pipe backpressure" +~~~ + +### Task 8: Make PostgreSQL Signal Shutdown Deterministic + +**Files:** +- Modify: crates/daemon/test-fixtures/managed-resources/postgres.py +- Modify: crates/daemon/tests/fixture_contracts.rs + +**Interfaces:** +- Consumes the existing PostgreSQL idle-client lifecycle test and the Redis/RustFS signal-shutdown pattern. +- Produces deterministic PostgreSQL shutdown without `signal.pause()` or a background-only `serve_forever`. + +- [ ] **Step 1: Add a no-sleep regression that exposes the current race** + +Use a per-test `sitecustomize.py` that patches `signal.pause` so old code writes `injected\n`, then calls `os.kill(os.getpid(), signal.SIGTERM)` immediately before the original pause. Patch `socketserver.BaseServer.serve_forever` so fixed code writes the same marker, then self-sends SIGTERM immediately before a main-thread `serve_forever`; make `inject()` idempotent. The `BaseServer.serve_forever` injection MUST run only when `threading.current_thread() is threading.main_thread()`; old background `serve_forever` must never call `inject()`, while old `signal.pause` does. Wait for the exact marker, require exit within `FIXTURE_SHUTDOWN_TIMEOUT`, accept clean exit for Task 8 and SIGTERM after Task 9, and always boundedly kill/reap on failure. The old background-server-plus-`signal.pause()` code shuts down its background server, then enters the original pause and times out; the fixed code starts a shutdown helper, enters main-thread `serve_forever` with shutdown already requested, and exits. + +- [ ] **Step 2: Move PostgreSQL serving to the main thread** + +Replace the background `serve_forever` thread and `signal.pause()` with this shape: + +~~~python +shutdown_requested = threading.Event() +shutdown_thread = None + + +def stop(signum, frame): + global shutdown_thread + if not shutdown_requested.is_set(): + shutdown_requested.set() + shutdown_thread = threading.Thread( + target=server.shutdown, + daemon=True, + ) + shutdown_thread.start() + + +signal.signal(signal.SIGTERM, stop) +signal.signal(signal.SIGINT, stop) +server.serve_forever() +if shutdown_thread is not None: + shutdown_thread.join() +server.server_close() +~~~ + +The idempotent handler sets the Event and starts the optional helper thread; `server.serve_forever()` remains on the main thread. Never call `shutdown()` synchronously from the serving-thread signal handler. Preserve the existing handler marker ordering and PostgreSQL protocol behavior. + +- [ ] **Step 3: Verify and commit** + +Run the focused PostgreSQL regression, the full fixture-contract test, formatting, locked Clippy, and `git diff --check`. Repeat the PostgreSQL lifecycle regression 20 times and confirm no sleep is used to establish handler readiness. Commit: + +~~~shell +git add crates/daemon/tests/fixture_contracts.rs +git add crates/daemon/test-fixtures/managed-resources/postgres.py +git commit -m "fix(daemon): make postgres fixture shutdown deterministic" +~~~ + +### Task 9: Preserve Single-Server Signal Status + +**Files:** +- Modify: crates/daemon/test-fixtures/managed-resources/mysql.py +- Modify: crates/daemon/test-fixtures/managed-resources/postgres.py +- Modify: crates/daemon/test-fixtures/managed-resources/redis-server.py +- Modify: crates/daemon/tests/fixture_contracts.rs + +**Interfaces:** +- Consumes each single-server fixture's orderly shutdown helper and the existing process-group fixture launcher. +- Produces bounded Rust helpers that materialize direct fixture executables, launch them in their own process groups, signal the group, and assert direct-child signal status. The expected status is historical from the removed shell wrappers; Task 10 reuses these helpers. + +- [ ] **Step 1: Add bounded TERM/INT coverage for the single-server fixtures** + +Add cases covering MySQL, PostgreSQL, and Redis with both SIGTERM and SIGINT. Materialize each direct fixture executable (rendering is not needed for these three), call `CommandExt::process_group(0)`, use `kill_process_group`, wait for the direct child/group leader `ExitStatus`, and assert `ExitStatusExt::signal() == Some(signal.as_raw())`. Do not invoke a shell or interpose a shell wrapper. Use bounded readiness and cleanup; on failure, kill and reap the process group. + +- [ ] **Step 2: Restore the default disposition after each orderly shutdown** + +In mysql.py, postgres.py, and redis-server.py, record the received signum and use one idempotent `threading.Event` plus one helper thread to stop all servers. The main thread joins the helper and closes every listener, then restores `SIG_DFL` and calls `os.kill(os.getpid(), signum)` only after orderly shutdown. Add `threading` where MySQL needs it; do not add a shared abstraction or dependency. + +- [ ] **Step 3: Verify and commit the single-server slice** + +Run the focused single-server TERM/INT status tests, the full fixture-contract suite, Python parsing, formatting, locked workspace Clippy, the locked workspace suite, and `git diff --check`. Verify only the three listed fixtures plus `fixture_contracts.rs` changed for this commit. Commit: + +~~~shell +git add crates/daemon/tests/fixture_contracts.rs +git add crates/daemon/test-fixtures/managed-resources/mysql.py +git add crates/daemon/test-fixtures/managed-resources/postgres.py +git add crates/daemon/test-fixtures/managed-resources/redis-server.py +git commit -m "fix(daemon): preserve single-server fixture signal status" +~~~ + +### Task 10: Preserve Multi-Server Signal Status + +**Files:** +- Modify: crates/daemon/test-fixtures/managed-resources/fake-mailpit.py +- Modify: crates/daemon/test-fixtures/managed-resources/mailpit.py +- Modify: crates/daemon/test-fixtures/managed-resources/rustfs.py.in +- Modify: crates/daemon/tests/fixture_contracts.rs + +**Interfaces:** +- Consumes the safe materialization, process-group launch, bounded readiness/cleanup, `kill_process_group`, and `ExitStatus` signal-status helpers from Task 9. +- Produces TERM/INT process-group status coverage and orderly multi-server shutdown for the remaining fixtures. The expected status is historical from the removed shell wrappers. + +- [ ] **Step 1: Add bounded TERM/INT coverage for the multi-server fixtures** + +Add cases covering fake Mailpit, Mailpit, and rendered RustFS with both SIGTERM and SIGINT. Materialize each direct executable, render `rustfs.py.in` for the required variants, launch with `CommandExt::process_group(0)`, signal with `kill_process_group`, wait for the direct child/group leader `ExitStatus`, and assert `ExitStatusExt::signal() == Some(signal.as_raw())`. Use no shell interposition, and always perform bounded group kill/reap cleanup on failure. + +- [ ] **Step 2: Restore the default disposition after all listeners close** + +In fake-mailpit.py, mailpit.py, and rustfs.py.in, record the received signum and use one idempotent `threading.Event` plus one helper thread to stop all servers. The main thread joins the helper and closes every listener before restoring `SIG_DFL` and calling `os.kill(os.getpid(), signum)`. Add `threading` where needed and `os` to fake-mailpit.py; do not add a shared abstraction or dependency. + +- [ ] **Step 3: Verify and commit the multi-server slice** + +Run the focused multi-server TERM/INT status tests, the full fixture-contract suite, Python parsing including both RustFS renderings, formatting, locked workspace Clippy, the locked workspace suite, and `git diff --check`. Verify this commit adds only the three listed fixtures and the additional Rust cases. Commit: + +~~~shell +git add crates/daemon/tests/fixture_contracts.rs +git add crates/daemon/test-fixtures/managed-resources/fake-mailpit.py +git add crates/daemon/test-fixtures/managed-resources/mailpit.py +git add crates/daemon/test-fixtures/managed-resources/rustfs.py.in +git commit -m "fix(daemon): preserve multi-server fixture signal status" +~~~ + +## Final Verification + +Run final verification only after Tasks 8–10 and their commits. Tasks 1–4 are historical; Tasks 5–10 define the accepted final details. + +~~~shell +cargo nextest run -p daemon --test fixture_contracts -E 'test(fixture_command_timeout_kills_and_reaps_child) | test(fixture_handler_marker_requires_complete_contents) | test(fixture_command_captures_verbose_output_without_pipe_backpressure)' +cargo nextest run -p daemon --test fixture_contracts +iteration=1 +while [ "$iteration" -le 20 ]; do + cargo nextest run -p daemon --test fixture_contracts -E 'test(mysql_fixture_exits_after_sigterm_with_idle_client)' + iteration=$((iteration + 1)) +done +iteration=1 +while [ "$iteration" -le 20 ]; do + cargo nextest run -p daemon --test fixture_contracts -E 'test(single_server_fixture_exits_after_signal_status)' + iteration=$((iteration + 1)) +done +iteration=1 +while [ "$iteration" -le 20 ]; do + cargo nextest run -p daemon --test fixture_contracts -E 'test(multi_server_fixture_exits_after_signal_status)' + iteration=$((iteration + 1)) +done +iteration=1 +while [ "$iteration" -le 20 ]; do + cargo nextest run -p daemon --test fixture_contracts -E 'test(postgres_fixture_exits_after_sigterm_with_idle_client)' + iteration=$((iteration + 1)) +done +cargo fmt --all --check +cargo clippy --workspace --all-targets --all-features --locked -- -D warnings +cargo nextest run --workspace --all-features --locked +python3 -c 'import ast, pathlib; root = pathlib.Path("crates/daemon/test-fixtures"); paths = sorted(root.rglob("*.py")); [ast.parse(path.read_text(), filename=str(path)) for path in paths]; template = root / "managed-resources/rustfs.py.in"; source = template.read_text(); sentinel = "__PV_REJECT_S3__"; assert source.count(sentinel) == 1; rendered = [source.replace(sentinel, value) for value in ("False", "True")]; assert all(sentinel not in value for value in rendered); [ast.parse(value, filename=f"{template}:{index}") for index, value in enumerate(rendered)]; print(f"parsed {len(paths)} checked-in Python fixtures and rendered rustfs.py.in twice")' +find crates/daemon/test-fixtures \ + -type f \( -name '*.sh' -o -name '*.sh.in' \) \ + -exec shellcheck -s sh {} + +sed 's/__PV_BLOCKED_PORT__/12345/' \ + crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port.sh.in \ + | shellcheck -s sh - +git diff --check c36e9763ad3120c98b9c9df4d141b3052c348806..HEAD +! rg --files crates/daemon/tests/snapshots -g '*.snap.new' -g '*.snap.tmp' +expected_files=$(cat <<'EOF' +crates/daemon/src/managed_resources/tests.rs +crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py +crates/daemon/test-fixtures/managed-resources/fake-mailpit.py +crates/daemon/test-fixtures/managed-resources/mailpit.py +crates/daemon/test-fixtures/managed-resources/mysql.py +crates/daemon/test-fixtures/managed-resources/postgres.py +crates/daemon/test-fixtures/managed-resources/redis-server.py +crates/daemon/test-fixtures/managed-resources/rustfs.py.in +crates/daemon/tests/fixture_contracts.rs +crates/daemon/tests/snapshots/fixture_contracts__fixture_command_captures_verbose_output_without_pipe_backpressure.snap +crates/daemon/tests/snapshots/fixture_contracts__fixture_handler_marker_requires_complete_contents.snap +docs/superpowers/plans/2026-07-16-daemon-fixture-review-corrections.md +EOF +) +actual_files=$(git diff --name-only c36e9763ad3120c98b9c9df4d141b3052c348806..HEAD) +if [ "$actual_files" != "$expected_files" ]; then + printf 'unexpected diff file set:\n%s\n' "$actual_files" >&2 + exit 1 +fi +untracked_files=$(git ls-files --others --exclude-standard) +if [ -n "$untracked_files" ]; then + printf 'unexpected untracked files:\n%s\n' "$untracked_files" >&2 + exit 1 +fi +worktree_status=$(git status --short) +if [ -n "$worktree_status" ]; then + printf 'working tree is dirty:\n%s\n' "$worktree_status" >&2 + exit 1 +fi +~~~ + +Expected: the focused new regressions and full fixture-contract suite pass; PostgreSQL and all six fixture TERM/INT process-group signal-status paths are deterministic; each lifecycle test passes 20 times; formatting, locked workspace Clippy, and the locked workspace suite pass; every checked-in `*.py` under `crates/daemon/test-fixtures` parses, and `rustfs.py.in` parses after rendering with both `False` and `True` after asserting exactly one sentinel and none after replacement; ShellCheck passes for every checked-in `.sh` and `.sh.in` fixture and for the rendered blocked-port template; the follow-up artifact diff is exactly these 12 paths: `crates/daemon/src/managed_resources/tests.rs`, `crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py`, `crates/daemon/test-fixtures/managed-resources/fake-mailpit.py`, `crates/daemon/test-fixtures/managed-resources/mailpit.py`, `crates/daemon/test-fixtures/managed-resources/mysql.py`, `crates/daemon/test-fixtures/managed-resources/postgres.py`, `crates/daemon/test-fixtures/managed-resources/redis-server.py`, `crates/daemon/test-fixtures/managed-resources/rustfs.py.in`, `crates/daemon/tests/fixture_contracts.rs`, `crates/daemon/tests/snapshots/fixture_contracts__fixture_command_captures_verbose_output_without_pipe_backpressure.snap`, `crates/daemon/tests/snapshots/fixture_contracts__fixture_handler_marker_requires_complete_contents.snap`, and this correction plan; this includes the gateway fixture/config-file-close correction and the `managed_resources/tests.rs` RustFS cleanup correction, with no whitespace errors or temporary snapshot artifacts; and both untracked-file and status assertions pass. The final behavior retains bounded cleanup, includes the test-only scheduling margin, accepts only complete handler markers, captures 2 MiB of UTF-8 output from each stream without pipe backpressure, and preserves the historical SIGTERM/SIGINT process-group exit outcomes. diff --git a/docs/superpowers/specs/2026-07-15-daemon-test-fixtures-design.md b/docs/superpowers/specs/2026-07-15-daemon-test-fixtures-design.md new file mode 100644 index 00000000..627fb047 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-daemon-test-fixtures-design.md @@ -0,0 +1,260 @@ +# Daemon Test Fixture Extraction Design + +## Summary + +PV will move substantial fake executable programs used by daemon tests out of Rust raw-string literals and into standalone fixture files under `crates/daemon/test-fixtures/`. + +Python remains an appropriate implementation language for the fake network services. The problem is not Python itself; it is embedding sizeable Python programs inside shell heredocs inside Rust strings. Standalone files make each fake executable readable, directly lintable, and easier to maintain while retaining the lightweight Python standard-library implementation. + +Shell remains appropriate where the fixture's behavior is fundamentally shell process behavior, such as signal traps, waiting for a child, or intentionally staying alive without becoming ready. Shell wrappers that only parse or forward arguments before starting Python will be replaced by directly executable Python fixtures. Short, scenario-specific scripts will remain inline when extracting them would only move a few obvious lines away from the test that explains them. + +This is a daemon-only structural refactor. It does not include `pv-release` scripts, CI scheduling, test parallelism, or production lifecycle changes. Post-review corrections may harden the test-only subprocess runner and normalize request-thread shutdown for the extracted fixtures as described below. + +## Goals + +- Make substantial daemon fake executables ordinary `.py` and `.sh` files. +- Keep fixture source next to the daemon crate and organized by test domain. +- Give Python and shell tooling direct access to complete source files. +- Preserve every fixture's current command-line contract, protocol responses, filesystem effects, signal outcomes, and process-group ownership. Preserve process topology wherever the shell parent is part of the scenario; remove it only for the explicitly identified parsing/forwarding-only wrappers. +- Keep temporary test isolation by materializing fixtures into each test's existing temporary runtime or artifact directory. +- Document Python 3 as a daemon-test prerequisite. +- Keep the extraction small and explicit rather than introducing a fixture framework. + +## Non-Goals + +- Do not replace Python fake servers with Rust, Node, containers, or real third-party daemons. +- Do not extract every inline shell snippet in the repository. +- Do not change scripts in `crates/pv-release`, release recipes, or release workflows. +- Do not change production daemon or supervisor behavior. +- Do not otherwise change fixture readiness, shutdown, retry, timeout, or failure behavior beyond the post-review corrections described below. +- Do not change nextest configuration, CI job structure, test scheduling, or suite performance policy. +- Do not introduce third-party Python dependencies. +- Do not add a generic fixture loader, templating engine, process harness, or cross-crate abstraction. + +## Extraction Boundary + +A daemon test executable belongs in `test-fixtures/` when it has at least one of these properties: + +- implements a network protocol or HTTP service, +- contains meaningful command-line parsing or validation, +- owns signal, child-process, or long-running lifecycle behavior, +- is reused by multiple tests or represents a named fake product executable, or +- is large enough that the Rust test is easier to understand when the program is viewed separately. + +An inline script stays with its test when it is short, used by one narrow scenario, and its values or behavior are primarily explained by that test. Examples include a validator that exits with a fixed status, a one-off child-PID observer, and a tiny TERM-aware loop used by a local test adapter. + +The language boundary follows behavior: + +- Use direct Python for fake servers and fake product CLIs whose shell layer only parses or forwards arguments. +- Use POSIX `sh` for fixtures whose behavior is specifically shell lifecycle behavior. +- Keep a shell parent plus a Python child when the shell parent is part of the process-supervision scenario under test. + +Extracted shell fixtures use `#!/bin/sh` and portable POSIX syntax. No fixture requires Bash. + +## Fixture Layout + +The new source layout is: + +```text +crates/daemon/test-fixtures/ +├── gateway/ +│ ├── fake-frankenphp.sh +│ ├── fake-frankenphp-server.py +│ ├── fake-frankenphp-hangs-on-port.sh.in +│ └── fake-frankenphp-hangs-on-port-server.py +├── managed-resources/ +│ ├── fake-mailpit.py +│ ├── mailpit.py +│ ├── mailpit-fast-exit.py +│ ├── mailpit-unready.sh +│ ├── mysql.py +│ ├── postgres.py +│ ├── postgres-initdb.sh +│ ├── postgres-unready.sh +│ ├── redis-server.py +│ └── rustfs.py.in +└── supervisor/ + └── owned-python-runtime.py +``` + +The mapping from current Rust helpers to fixture files is: + +| Current helper | New fixture | Treatment | +| --- | --- | --- | +| `mysql_fixture_script` | `managed-resources/mysql.py` | Port initialization and server-mode argument handling to Python; preserve the initialization directory and TCP readiness behavior. | +| `fake_mailpit_script` | `managed-resources/fake-mailpit.py` | Direct Python executable retaining the two positional port arguments. | +| `fake_postgres_initdb_script` | `managed-resources/postgres-initdb.sh` | Keep as POSIX shell because it is a filesystem-oriented fake CLI with no embedded Python. | +| `fake_postgres_script` | `managed-resources/postgres.py` | Port argument/config validation and the PostgreSQL wire-protocol server to one direct Python executable. | +| `unready_fake_postgres_script` | `managed-resources/postgres-unready.sh` | Keep its TERM/INT traps and non-ready wait loop in POSIX shell. | +| `unready_fake_mailpit_script` | `managed-resources/mailpit-unready.sh` | Keep its TERM/INT traps and non-ready wait loop in POSIX shell. | +| `fast_exit_fake_mailpit_script` | `managed-resources/mailpit-fast-exit.py` | Move the already-direct Python executable without changing its immediate-exit readiness behavior. | +| `mailpit_script` | `managed-resources/mailpit.py` | Port the real adapter-style option validation and both fake services to direct Python. | +| `redis_server_script` | `managed-resources/redis-server.py` | Remove the forwarding shell and retain all Redis parsing, protocol, and shutdown behavior in Python. | +| `rustfs_script_source` | `managed-resources/rustfs.py.in` | Port shell argument parsing to Python and retain one narrowly parameterized reject-mode template. | +| `write_fake_frankenphp` | `gateway/fake-frankenphp.sh` plus `gateway/fake-frankenphp-server.py` | Preserve the shell parent, child wait loop, signal traps, and validation branch; move only the embedded server body to a sibling Python fixture. | +| `write_fake_frankenphp_that_hangs_on_port` | `gateway/fake-frankenphp-hangs-on-port.sh.in` plus `gateway/fake-frankenphp-hangs-on-port-server.py` | Preserve the shell parent and blocked-port hang branch; move the HTTP server body to a sibling Python fixture. | +| `supervisor_verifies_owned_python_shebang_script` body | `supervisor/owned-python-runtime.py` | Reuse a standalone direct Python executable in the existing macOS ownership test. | + +The following representative scripts remain inline: + +- `fake_sql_script`, because it is a tiny test-local TERM-aware loop, +- failing and hanging validator scripts in `gateway_reconciliation.rs`, because their short behavior and dynamic paths belong to individual scenarios, and +- environment and descendant-PID observer scripts in supervisor tests, because they are small and constructed from test-specific paths or values. + +## Compile-Time Loading + +Rust loads fixture source with crate-local compile-time inclusion: + +```rust +const FAKE_MAILPIT_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-fixtures/managed-resources/fake-mailpit.py" +)); +``` + +This makes missing fixture files a compile error and avoids dependence on the test process's current working directory. Each test module may define the constants it needs; there will be no global registry or loader abstraction. + +Existing helpers such as `state::fs::write_sensitive_file` and the local `set_executable` functions continue materializing the source into the test's temporary directory. Tests execute those temporary copies, not files from the repository checkout. This preserves isolation, executable permissions, command paths, supervisor ownership checks, and cleanup behavior. + +The checked-in fixture assets do not need executable Git modes. Their materialized copies receive the same explicit executable mode as the current inline scripts. + +Every directly executed fixture starts with its interpreter shebang at byte zero and uses LF line endings. + +## Gateway Parent And Child Materialization + +The fake FrankenPHP shell process is behaviorally important: PV supervises that parent while it starts a Python server child, handles `USR1`, forwards termination, waits, and reports the child's exit status. The extraction must preserve that topology. + +For each generated fake FrankenPHP executable, the Rust helper writes: + +- the shell fixture to the requested executable path, and +- its Python server fixture to the same path with `.server.py` appended. + +The shell fixture refers to the companion as `"$0.server.py"`, so no runtime checkout path or additional path template is needed. All current callers execute the exact absolute fixture path derived from their temporary directory, making `$0` a stable companion-path base. The regular fake FrankenPHP wrapper runs `python3 - "$3" < "$0.server.py" &`, preserving its current Python child argument shape and standard-input execution. The blocked-port variant invokes its companion by path; this intentionally changes only that child interpreter's source argument from `-c` to a file path, while its shell parent, signal handling, and supervised identity remain unchanged. Neither companion replaces the supervised parent process. + +## Narrow Template Substitution + +Among the extracted fixtures, only two values require source generation: + +- `rustfs.py.in` contains one `__PV_REJECT_S3__` sentinel, replaced with Python `False` or `True`. +- `fake-frankenphp-hangs-on-port.sh.in` contains one `__PV_BLOCKED_PORT__` sentinel, replaced with the test's allocated port. + +The Rust substitution helper for each template must verify that the expected sentinel exists exactly once and is absent after replacement. Failure returns a test error rather than silently producing a malformed executable. No general-purpose template syntax or engine will be added. + +All other dynamic values in extracted fixtures continue to arrive through the fixture's existing command-line arguments, environment, config file, or temporary path. Short inline scripts may still interpolate their scenario-specific paths and values as they do today. + +## Behavioral Compatibility + +This work is a source-layout refactor with explicitly identified inert-wrapper removals, so the Rust-visible executable interface is the compatibility boundary. + +For every extracted fixture, preserve: + +- argument order, accepted flags, rejected flags, validation rules, exit codes, and stderr messages used by tests, +- created directories, marker files, credentials, and other filesystem effects, +- bound addresses and ports, +- readiness responses and supported protocol messages, +- signal handlers and normal exit behavior, +- parent/child relationships where the shell parent is behaviorally relevant, +- the executable path, arguments, process group, and PID PV supervises after removing an inert shell wrapper, and +- paths visible in process command lines where ownership verification depends on them. + +Porting shell parsing into Python must not relax validation. These small CLIs use explicit manual parsing rather than `argparse`, whose default validation and output would change the fixtures' behavior. Preserve repeated-option last-wins behavior, MySQL's unknown-option tolerance and raw first-argument rule, fake Mailpit's ignored extra arguments, Postgres and Mailpit's rejection codes, and RustFS's treatment of every unknown token as the last-wins data directory. The direct Python files report the same failures and codes as the current shell layer wherever those outcomes are observable. + +The extraction also preserves the current lifecycle fixes exactly: Redis and RustFS use an idempotent `threading.Event` plus a helper thread to request server shutdown, while the fast-exit Mailpit handler sends and flushes its successful response before calling `os._exit(0)`. + +No opportunistic cleanup or behavioral correction belongs in this change beyond the explicitly approved post-review corrections below. + +## Post-Review Corrections + +Review identified two narrow test-only hardening opportunities that are included in this branch: + +- Every custom `socketserver.ThreadingMixIn` fixture server daemonizes its request threads. MySQL, PostgreSQL, fake Mailpit, and Mailpit set `daemon_threads = True`; Redis retains its existing setting. Python's `ThreadingHTTPServer` fixtures need no corresponding change because that class already daemonizes request threads. +- Every fixture-contract subprocess expected to exit uses one private standard-library runner with a 3-second deadline and a 10-millisecond poll interval. On timeout, the runner kills and reaps the direct child before returning `ErrorKind::TimedOut`. The MySQL environment probe uses the same runner while retaining its custom environment. + +The subprocess runner preserves the current exit status, stdout, and stderr on normal completion. It closes stdin, captures stdout and stderr, and polls the direct child. At the deadline it requests a kill, tolerates the normal already-exited race, and always attempts to reap the child. A kill or reap failure is reported as the cleanup error; otherwise the runner returns the typed timeout error and does not present partial killed-process output as a normal fixture result. These fixture-contract commands do not intentionally create descendants, so test-only process-group management is unnecessary. + +The lifecycle regression coverage holds an accepted idle client open against MySQL and PostgreSQL, sends `SIGTERM`, and requires prompt exit with bounded cleanup. These are the two fixtures whose handlers can remain blocked indefinitely. Fake Mailpit and Mailpit receive the same server configuration for consistency, but their current greeting handlers already return promptly and therefore have no distinct failing lifecycle case to protect. + +These corrections do not change fixture command-line contracts, protocol responses, readiness behavior, process-group topology, production supervisor behavior, nextest configuration, or CI scheduling. They do not broaden ownership matching for non-`exec` interpreter wrappers, add metric-driven Python docstrings, or include unrelated fixture cleanup. + +## Documentation And Dependencies + +`CONTRIBUTING.md` will state that daemon integration tests require `python3` on `PATH`. All Python fixtures use only the standard library, so there is no virtual environment, requirements file, package installation, or version-management addition. + +Shell remains a normal macOS system dependency. The extracted shell files are POSIX `sh`, not Bash scripts. + +## Test And Lint Strategy + +The existing daemon integration tests remain the primary behavioral coverage. They already execute the fake programs through the same artifact, adapter, gateway, and supervisor paths affected by the extraction. + +Moving an unchanged program into a file does not by itself require a new test. Assertions that only prove `include_str!` returned known text would duplicate the compiler and would not protect behavior. + +Porting command-line parsing from shell to Python is different: it rewrites observable fixture behavior even though the intended contract is unchanged. Add a daemon integration test for the translated fixture CLIs' currently uncovered compatibility cases. The test will materialize and invoke the final fixture executables, and use nearby `insta` patterns to snapshot exit status, stdout, and stderr where those outputs are stable. At minimum it covers: + +- MySQL's initialization-first-argument rule and its existing tolerance of otherwise unknown arguments, +- fake Mailpit's two-port positional contract and ignored extra arguments, +- Postgres's unknown-argument and uninitialized-data-directory failures, +- Mailpit's unknown argument, required option, version-check, database-path, and database-directory validation, and +- RustFS's address-option consumption and last positional data-directory behavior. + +Existing happy-path integration tests continue covering network protocols, readiness, filesystem effects, and lifecycle behavior. Existing snapshots and assertions must not change; only new fixture-contract snapshots may be added. + +Implementation verification will include: + +1. Run `shellcheck` on every extracted `.sh` source and on a rendered blocked-port `.sh.in` fixture. +2. Parse every extracted `.py` source plus both rendered `False` and `True` variants of `rustfs.py.in` with Python 3, verifying that no sentinel remains and without adding bytecode to the worktree. +3. Run the new fixture CLI compatibility integration test and review its snapshots. +4. Run focused daemon tests for MySQL, Postgres, Mailpit, Redis, RustFS, gateway reconciliation, and macOS supervisor ownership. +5. Run `cargo fmt --all --check`. +6. Run `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings`. +7. Run `cargo nextest run --workspace --all-features --locked`. +8. Run `git diff --check` and inspect the final fixture permissions, shebangs, and source mapping. + +The refactor will not add or change a CI workflow. The standalone files make direct linting possible, while this change's correctness gate remains the repository's normal Rust checks plus the explicit fixture lint commands above. + +## Implementation Sequence + +1. Add the fixture directory tree and copy each program into its target file without changing behavior. +2. Convert shell-to-Python wrappers into direct Python executables while preserving their CLI contracts. +3. Split the two gateway shell parents from their Python server children and retain the original parent lifecycle. +4. Replace Rust raw strings with compile-time fixture constants and the two narrow substitutions. +5. Add focused integration snapshots for command-line compatibility branches translated from shell to Python. +6. Remove only helper code, imports, and formatting logic made obsolete by this extraction. +7. Document the Python 3 prerequisite. +8. Run focused and full verification, then inspect the diff specifically for accidental behavior changes. + +## Risks And Mitigations + +### Process identity changes + +Replacing a shell wrapper with Python intentionally removes that wrapper process and makes Python the tracked process. The Rust-visible executable path and arguments, process group, signal/exit outcomes, and cleanup contract remain the same. This is limited to wrappers whose shell did not contribute lifecycle behavior. The gateway shell parents remain in place because their identity and lifecycle are part of the tests. The existing supervisor ownership test protects direct Python shebang execution on macOS. + +### Argument-parsing drift + +Moving shell parsing into Python could accidentally accept an invalid flag or change an exit status. Each Python CLI mirrors the current branches with a manual parser, existing adapter integration tests exercise supported paths, and new compatibility snapshots cover representative failure and edge paths. Final review compares each old helper against its extracted counterpart. + +### Broken companion paths + +Gateway child scripts are materialized beside the shell executable and referenced relative to `$0`, avoiding dependence on the repository or current working directory. Gateway reconciliation tests cover startup, reload, validation, TLS, blocked-port, and shutdown paths. + +### Template mistakes + +Only two single-sentinel templates are permitted. Exact sentinel-count checks prevent partial or silent replacements. + +### Hidden dependency expansion + +Python fixtures remain standard-library-only, and shell fixtures remain POSIX-compatible. `CONTRIBUTING.md` makes the existing Python runtime dependency explicit. + +## Acceptance Criteria + +The extraction is complete when: + +- all substantial daemon fake executable bodies listed above live under `crates/daemon/test-fixtures/`, +- the intentionally small or scenario-generated scripts remain inline, +- no Python heredoc remains inside an extracted daemon shell fixture, +- existing observable fixture contracts, assertions, snapshots, and production code remain unchanged apart from the documented inert-wrapper, blocked-port child-argument, and post-review test-lifecycle corrections, +- fixture-contract subprocesses that should exit are bounded, killed, and reaped on timeout, +- every custom `ThreadingMixIn` fixture server daemonizes request threads, with active-client shutdown regressions for MySQL and PostgreSQL, +- focused fixture-contract snapshots cover shell parsing that is reimplemented in Python, +- Python 3 is documented as a test prerequisite, +- extracted Python and shell sources pass their syntax and lint checks, and +- formatting, Clippy, the focused daemon tests, and the complete locked workspace nextest suite pass.