From fd6d229af9705ef0308e88da711c56b8e109be37 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Wed, 15 Jul 2026 04:11:51 -0400 Subject: [PATCH 01/33] docs(test): design daemon fixture extraction --- .../2026-07-15-daemon-test-fixtures-design.md | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-15-daemon-test-fixtures-design.md 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..c0bff471 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-daemon-test-fixtures-design.md @@ -0,0 +1,245 @@ +# 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 and does not include CI scheduling, test parallelism, fixture timing, or shutdown-behavior changes. + +## 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 change fixture readiness, shutdown, retry, timeout, or failure behavior. +- 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. If extraction reveals a fixture defect, that defect should be reported and handled separately unless leaving it unchanged makes the extraction impossible. + +## 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, timeouts, and production code remain unchanged apart from the documented inert-wrapper and blocked-port child-argument changes, +- 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. From 035d1a4b98120c5b19a6bad6e6d953da4d2f86fa Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Wed, 15 Jul 2026 10:42:38 -0400 Subject: [PATCH 02/33] docs(test): plan daemon fixture extraction --- .../plans/2026-07-15-daemon-test-fixtures.md | 2173 +++++++++++++++++ 1 file changed, 2173 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md 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..705f1f4b --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md @@ -0,0 +1,2173 @@ +# 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. + +## 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::os::unix::fs::PermissionsExt; + +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 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)?; + + 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(), + )?; + + 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"))?, + ), + ) +} + +#[test] +fn fake_mailpit_fixture_cli_ignores_extra_arguments() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("fake-mailpit"); + + materialize_fixture(&fixture, FAKE_MAILPIT_FIXTURE)?; + let output = run_fixture( + &fixture, + &["not-a-port", "also-not-a-port", "ignored-extra"], + tempdir.path(), + )?; + + assert_fixture_snapshot( + tempdir.path(), + "fake_mailpit_fixture_cli_ignores_extra_arguments", + ( + output.code, + output.stdout, + output.stderr.contains("ValueError"), + output.stderr.contains("ignored-extra"), + ), + ) +} + +#[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(os.path.join(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. + +- [ ] **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. +Fake Mailpit: code 1 from invalid integer conversion; ValueError=true; ignored-extra is absent from stderr. +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|#!/usr/bin/env python3" 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" crates/daemon +``` + +Expected: all three negative searches print nothing; the final search finds the intentionally inline scenario-local fixtures. + +- [ ] **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 +git diff HEAD~5 --check +git diff --stat HEAD~5 +git diff --name-only HEAD~5 +git diff --name-status HEAD~5 -- '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 `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. From f7890a612dc6bbc22a1fd1603235be761121263d Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Wed, 15 Jul 2026 11:04:56 -0400 Subject: [PATCH 03/33] docs(test): make fixture verification range robust --- .../plans/2026-07-15-daemon-test-fixtures.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md b/docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md index 705f1f4b..e9e5b331 100644 --- a/docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md +++ b/docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md @@ -2154,10 +2154,11 @@ Expected: formatting and Clippy exit 0, and every non-skipped workspace test pas Run: ```shell -git diff HEAD~5 --check -git diff --stat HEAD~5 -git diff --name-only HEAD~5 -git diff --name-status HEAD~5 -- 'crates/daemon/**/snapshots/*.snap' +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 @@ -2166,7 +2167,7 @@ git status --short Expected: - `git diff --check` is clean. -- Changed paths are limited to `crates/daemon/test-fixtures/`, the four named daemon test files, the new fixture-contract target/snapshots, and `CONTRIBUTING.md`. +- 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. From 7eacaa352588847d646effe753951e7b8a520536 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Wed, 15 Jul 2026 11:15:51 -0400 Subject: [PATCH 04/33] test(daemon): cover managed fixture CLI contracts --- .../managed-resources/fake-mailpit.py | 36 ++ .../managed-resources/mailpit.py | 77 ++++ .../test-fixtures/managed-resources/mysql.py | 54 +++ .../managed-resources/postgres.py | 293 ++++++++++++++ .../managed-resources/rustfs.py.in | 154 ++++++++ crates/daemon/tests/fixture_contracts.rs | 362 ++++++++++++++++++ ...t_fixture_cli_ignores_extra_arguments.snap | 12 + ..._fixture_cli_preserves_shell_contract.snap | 48 +++ ..._fixture_cli_preserves_shell_contract.snap | 23 ++ ..._fixture_cli_preserves_shell_contract.snap | 20 + ..._fixture_cli_preserves_shell_contract.snap | 17 + 11 files changed, 1096 insertions(+) create mode 100644 crates/daemon/test-fixtures/managed-resources/fake-mailpit.py create mode 100644 crates/daemon/test-fixtures/managed-resources/mailpit.py create mode 100644 crates/daemon/test-fixtures/managed-resources/mysql.py create mode 100644 crates/daemon/test-fixtures/managed-resources/postgres.py create mode 100644 crates/daemon/test-fixtures/managed-resources/rustfs.py.in create mode 100644 crates/daemon/tests/fixture_contracts.rs create mode 100644 crates/daemon/tests/snapshots/fixture_contracts__fake_mailpit_fixture_cli_ignores_extra_arguments.snap create mode 100644 crates/daemon/tests/snapshots/fixture_contracts__mailpit_fixture_cli_preserves_shell_contract.snap create mode 100644 crates/daemon/tests/snapshots/fixture_contracts__mysql_fixture_cli_preserves_shell_contract.snap create mode 100644 crates/daemon/tests/snapshots/fixture_contracts__postgres_fixture_cli_preserves_shell_contract.snap create mode 100644 crates/daemon/tests/snapshots/fixture_contracts__rustfs_fixture_cli_preserves_shell_contract.snap 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..9deebc01 --- /dev/null +++ b/crates/daemon/test-fixtures/managed-resources/fake-mailpit.py @@ -0,0 +1,36 @@ +#!/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() 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..ef5bd95f --- /dev/null +++ b/crates/daemon/test-fixtures/managed-resources/mailpit.py @@ -0,0 +1,77 @@ +#!/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() 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..c1bc60d4 --- /dev/null +++ b/crates/daemon/test-fixtures/managed-resources/mysql.py @@ -0,0 +1,54 @@ +#!/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(os.path.join(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() 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..09a735ac --- /dev/null +++ b/crates/daemon/test-fixtures/managed-resources/postgres.py @@ -0,0 +1,293 @@ +#!/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() 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..3f4dafc2 --- /dev/null +++ b/crates/daemon/test-fixtures/managed-resources/rustfs.py.in @@ -0,0 +1,154 @@ +#!/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() diff --git a/crates/daemon/tests/fixture_contracts.rs b/crates/daemon/tests/fixture_contracts.rs new file mode 100644 index 00000000..4ff6c3c1 --- /dev/null +++ b/crates/daemon/tests/fixture_contracts.rs @@ -0,0 +1,362 @@ +use std::os::unix::fs::PermissionsExt; + +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 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)?; + + 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(), + )?; + + 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"))?, + ), + ) +} + +#[test] +fn fake_mailpit_fixture_cli_ignores_extra_arguments() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("fake-mailpit"); + + materialize_fixture(&fixture, FAKE_MAILPIT_FIXTURE)?; + let output = run_fixture( + &fixture, + &["not-a-port", "also-not-a-port", "ignored-extra"], + tempdir.path(), + )?; + + assert_fixture_snapshot( + tempdir.path(), + "fake_mailpit_fixture_cli_ignores_extra_arguments", + ( + output.code, + output.stdout, + output.stderr.contains("ValueError"), + output.stderr.contains("ignored-extra"), + ), + ) +} + +#[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 +} 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..56a5a9a4 --- /dev/null +++ b/crates/daemon/tests/snapshots/fixture_contracts__fake_mailpit_fixture_cli_ignores_extra_arguments.snap @@ -0,0 +1,12 @@ +--- +source: crates/daemon/tests/fixture_contracts.rs +expression: snapshot +--- +( + Some( + 1, + ), + "", + true, + false, +) 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..0d33584d --- /dev/null +++ b/crates/daemon/tests/snapshots/fixture_contracts__mysql_fixture_cli_preserves_shell_contract.snap @@ -0,0 +1,23 @@ +--- +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, +) 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, +) From 957ca33c286c8536a565d05a3cac71547dd7db84 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Wed, 15 Jul 2026 11:24:34 -0400 Subject: [PATCH 05/33] refactor(daemon): extract SQL and Mailpit test fixtures --- .../src/managed_resources/mysql_tests.rs | 78 +-- crates/daemon/src/managed_resources/tests.rs | 587 ++---------------- .../managed-resources/mailpit-fast-exit.py | 20 + .../managed-resources/mailpit-unready.sh | 12 + .../managed-resources/postgres-initdb.sh | 49 ++ .../managed-resources/postgres-unready.sh | 35 ++ 6 files changed, 161 insertions(+), 620 deletions(-) create mode 100644 crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py create mode 100644 crates/daemon/test-fixtures/managed-resources/mailpit-unready.sh create mode 100644 crates/daemon/test-fixtures/managed-resources/postgres-initdb.sh create mode 100644 crates/daemon/test-fixtures/managed-resources/postgres-unready.sh 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..77d7e764 100644 --- a/crates/daemon/src/managed_resources/tests.rs +++ b/crates/daemon/src/managed_resources/tests.rs @@ -56,6 +56,34 @@ 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 EMPTY_ARTIFACT_MANIFEST: &str = r#" { "schema_version": 1, @@ -3152,7 +3180,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 +3191,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 +3382,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 +3431,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( @@ -3664,7 +3692,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 +3714,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", @@ -3747,7 +3775,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 +3794,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 +4193,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,101 +4350,6 @@ 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 redis_server_script() -> &'static str { r#"#!/bin/sh set -eu 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/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 From 985cf63b4e4b57fe62572c6091655a4c7e713f2f Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Wed, 15 Jul 2026 11:33:35 -0400 Subject: [PATCH 06/33] refactor(daemon): extract Redis and RustFS test fixtures --- crates/daemon/src/managed_resources/tests.rs | 280 +++--------------- .../managed-resources/redis-server.py | 72 +++++ 2 files changed, 108 insertions(+), 244 deletions(-) create mode 100644 crates/daemon/test-fixtures/managed-resources/redis-server.py diff --git a/crates/daemon/src/managed_resources/tests.rs b/crates/daemon/src/managed_resources/tests.rs index 77d7e764..22e6dbac 100644 --- a/crates/daemon/src/managed_resources/tests.rs +++ b/crates/daemon/src/managed_resources/tests.rs @@ -84,6 +84,15 @@ 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, @@ -3504,7 +3513,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( @@ -3518,11 +3527,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( @@ -3736,7 +3747,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", @@ -3758,7 +3769,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", @@ -4350,251 +4362,31 @@ impl super::ManagedResourceRuntimeAdapter for AsyncSqlHookRuntimeAdapter { } } -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 rustfs_script() -> String { - rustfs_script_source("False") +fn rustfs_script() -> Result { + rustfs_script_source(false) } -fn auth_rejecting_rustfs_script() -> String { - rustfs_script_source("True") +fn auth_rejecting_rustfs_script() -> Result { + 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/managed-resources/redis-server.py b/crates/daemon/test-fixtures/managed-resources/redis-server.py new file mode 100644 index 00000000..416151da --- /dev/null +++ b/crates/daemon/test-fixtures/managed-resources/redis-server.py @@ -0,0 +1,72 @@ +#!/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() From 12538cabae6b9bde1774bef33060dceb847fd33a Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Wed, 15 Jul 2026 11:48:22 -0400 Subject: [PATCH 07/33] refactor(gateway): extract FrankenPHP test fixtures --- .../fake-frankenphp-hangs-on-port-server.py | 12 ++ .../fake-frankenphp-hangs-on-port.sh.in | 29 +++ .../gateway/fake-frankenphp-server.py | 54 ++++++ .../test-fixtures/gateway/fake-frankenphp.sh | 24 +++ crates/daemon/tests/gateway_reconciliation.rs | 170 ++++++------------ 5 files changed, 172 insertions(+), 117 deletions(-) create mode 100644 crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port-server.py create mode 100644 crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port.sh.in create mode 100644 crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py create mode 100644 crates/daemon/test-fixtures/gateway/fake-frankenphp.sh 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..72ac28a7 --- /dev/null +++ b/crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py @@ -0,0 +1,54 @@ +#!/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() 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/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 { From b469a3dd586fe24ba38b5a7cc1fc5ad820317554 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Wed, 15 Jul 2026 11:56:53 -0400 Subject: [PATCH 08/33] refactor(supervisor): extract Python ownership fixture --- CONTRIBUTING.md | 2 ++ .../supervisor/owned-python-runtime.py | 14 +++++++++++ crates/daemon/tests/supervisor_foundation.rs | 25 ++++++------------- 3 files changed, 23 insertions(+), 18 deletions(-) create mode 100644 crates/daemon/test-fixtures/supervisor/owned-python-runtime.py 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/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/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()); From 8be302ff3cb01a1ba62e7ebe978c115ba3c8af2b Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Wed, 15 Jul 2026 12:40:51 -0400 Subject: [PATCH 09/33] fix(daemon): preserve fixture CLI contracts --- .../test-fixtures/managed-resources/mysql.py | 2 +- crates/daemon/tests/fixture_contracts.rs | 99 ++++++++++++++++--- ...t_fixture_cli_ignores_extra_arguments.snap | 9 +- ..._fixture_cli_preserves_shell_contract.snap | 8 ++ 4 files changed, 101 insertions(+), 17 deletions(-) diff --git a/crates/daemon/test-fixtures/managed-resources/mysql.py b/crates/daemon/test-fixtures/managed-resources/mysql.py index c1bc60d4..92e5c5c3 100644 --- a/crates/daemon/test-fixtures/managed-resources/mysql.py +++ b/crates/daemon/test-fixtures/managed-resources/mysql.py @@ -30,7 +30,7 @@ if first_argument != "--no-defaults": print("mysqld initialization must start with --no-defaults", file=sys.stderr) sys.exit(64) - os.makedirs(os.path.join(data_dir, "mysql"), exist_ok=True) + os.makedirs(f"{data_dir}/mysql", exist_ok=True) sys.exit(0) diff --git a/crates/daemon/tests/fixture_contracts.rs b/crates/daemon/tests/fixture_contracts.rs index 4ff6c3c1..ccb2d68f 100644 --- a/crates/daemon/tests/fixture_contracts.rs +++ b/crates/daemon/tests/fixture_contracts.rs @@ -1,4 +1,8 @@ +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; @@ -44,11 +48,26 @@ struct FixtureOutput { 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, @@ -76,6 +95,18 @@ fn mysql_fixture_cli_preserves_shell_contract() -> Result<()> { ], 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(), @@ -86,6 +117,8 @@ fn mysql_fixture_cli_preserves_shell_contract() -> Result<()> { 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)?, ), ) } @@ -94,23 +127,45 @@ fn mysql_fixture_cli_preserves_shell_contract() -> Result<()> { 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)?; - let output = run_fixture( - &fixture, - &["not-a-port", "also-not-a-port", "ignored-extra"], - tempdir.path(), - )?; + 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", - ( - output.code, - output.stdout, - output.stderr.contains("ValueError"), - output.stderr.contains("ignored-extra"), - ), + lifecycle, ) } @@ -307,6 +362,28 @@ fn run_fixture( }) } +fn wait_for_loopback_ports(ports: [u16; 2], timeout: Duration) -> Result<[bool; 2]> { + let deadline = Instant::now() + timeout; + let mut readiness = [false; 2]; + + 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 fake Mailpit ports {ports:?}; readiness: {readiness:?}"); + } + + thread::sleep(Duration::from_millis(10)); + } +} + fn assert_fixture_snapshot( tempdir: &Utf8Path, name: &'static str, 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 index 56a5a9a4..651eed23 100644 --- 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 @@ -3,10 +3,9 @@ source: crates/daemon/tests/fixture_contracts.rs expression: snapshot --- ( - Some( - 1, - ), - "", + [ + true, + true, + ], true, - false, ) 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 index 0d33584d..5fdbb3f5 100644 --- 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 @@ -20,4 +20,12 @@ expression: snapshot false, false, true, + FixtureOutput { + code: Some( + 0, + ), + stdout: "", + stderr: "", + }, + "/mysql", ) From 249488c82496523f09cc35a2d700cd68aa991f7b Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Wed, 15 Jul 2026 13:09:06 -0400 Subject: [PATCH 10/33] docs(test): align fixture plan with final contracts --- .../plans/2026-07-15-daemon-test-fixtures.md | 85 +++++++++++++++---- 1 file changed, 70 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md b/docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md index e9e5b331..3819ab27 100644 --- a/docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md +++ b/docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md @@ -90,7 +90,11 @@ 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; @@ -136,11 +140,26 @@ struct FixtureOutput { 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, @@ -168,6 +187,18 @@ fn mysql_fixture_cli_preserves_shell_contract() -> Result<()> { ], 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(), @@ -178,6 +209,8 @@ fn mysql_fixture_cli_preserves_shell_contract() -> Result<()> { 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)?, ), ) } @@ -186,23 +219,45 @@ fn mysql_fixture_cli_preserves_shell_contract() -> Result<()> { 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)?; - let output = run_fixture( - &fixture, - &["not-a-port", "also-not-a-port", "ignored-extra"], - tempdir.path(), - )?; + 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", - ( - output.code, - output.stdout, - output.stderr.contains("ValueError"), - output.stderr.contains("ignored-extra"), - ), + lifecycle, ) } @@ -501,7 +556,7 @@ if initialize: if first_argument != "--no-defaults": print("mysqld initialization must start with --no-defaults", file=sys.stderr) sys.exit(64) - os.makedirs(os.path.join(data_dir, "mysql"), exist_ok=True) + os.makedirs(f"{data_dir}/mysql", exist_ok=True) sys.exit(0) @@ -566,7 +621,7 @@ 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. +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** @@ -1126,8 +1181,8 @@ INSTA_UPDATE=always cargo nextest run -p daemon --test fixture_contracts --locke 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. -Fake Mailpit: code 1 from invalid integer conversion; ValueError=true; ignored-extra is absent from stderr. +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. From 5fde492017e15e237878c8c409b753c340449eb1 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 16 Jul 2026 11:02:53 -0400 Subject: [PATCH 11/33] docs(test): approve fixture review corrections --- .../2026-07-15-daemon-test-fixtures-design.md | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) 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 index c0bff471..627fb047 100644 --- a/docs/superpowers/specs/2026-07-15-daemon-test-fixtures-design.md +++ b/docs/superpowers/specs/2026-07-15-daemon-test-fixtures-design.md @@ -8,7 +8,7 @@ Python remains an appropriate implementation language for the fake network servi 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 and does not include CI scheduling, test parallelism, fixture timing, or shutdown-behavior changes. +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 @@ -26,7 +26,7 @@ This is a daemon-only structural refactor. It does not include `pv-release` scri - 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 change fixture readiness, shutdown, retry, timeout, or failure 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. @@ -161,7 +161,20 @@ Porting shell parsing into Python must not relax validation. These small CLIs us 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. If extraction reveals a fixture defect, that defect should be reported and handled separately unless leaving it unchanged makes the extraction impossible. +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 @@ -238,7 +251,9 @@ 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, timeouts, and production code remain unchanged apart from the documented inert-wrapper and blocked-port child-argument changes, +- 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 From 4900f34d2bca7bb981b8c894fd30e30bb800439f Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 16 Jul 2026 11:09:49 -0400 Subject: [PATCH 12/33] docs(test): plan fixture review corrections --- .../plans/2026-07-15-daemon-test-fixtures.md | 4 + ...07-16-daemon-fixture-review-corrections.md | 575 ++++++++++++++++++ 2 files changed, 579 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-daemon-fixture-review-corrections.md diff --git a/docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md b/docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md index 3819ab27..177991da 100644 --- a/docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md +++ b/docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md @@ -26,6 +26,10 @@ - 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:** 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..2fb3de14 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-daemon-fixture-review-corrections.md @@ -0,0 +1,575 @@ +# 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:** Bound fixture-contract subprocesses and make every custom threaded TCP fixture unable to delay interpreter shutdown through non-daemon request threads. + +**Architecture:** Keep all changes inside daemon test fixtures and their executable-level integration test. One private synchronous command runner owns the deadline, output capture, kill, and reap behavior for commands expected to exit; the fixture classes themselves opt into daemon request threads without changing their CLI or 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, or fixture process-group topology. +- Python fixtures remain standard-library-only; do not add dependencies or modify Cargo.lock. +- Normal fixture-contract commands use a 3-second deadline and a 10-millisecond poll interval. +- On timeout, kill and reap the direct child before returning std::io::ErrorKind::TimedOut; report kill or reap failures instead of the timeout. +- Preserve normal exit status, stdout, stderr, snapshots, custom MySQL environment variables, CLI behavior, and protocol behavior. +- Set daemon_threads = True on every custom socketserver.ThreadingMixIn fixture server: MySQL, PostgreSQL, fake Mailpit, and Mailpit. Redis retains its existing setting. +- Add behavior regressions for MySQL and PostgreSQL, whose handlers can remain blocked on accepted idle clients. Do not add implementation-detail tests for Mailpit handlers that already return promptly. +- Do not change adoption matching, add metric-driven docstrings, or include the unrelated gateway configuration-file cleanup. +- Follow test-driven development: observe the new test fail for the intended reason before implementing each behavior change. +- Keep implementation commits separate: timeout runner first, threaded-server shutdown second. + +--- + +## File Map + +- Modify: crates/daemon/tests/fixture_contracts.rs + - Owns executable-level fixture CLI, timeout, cleanup, and shutdown regressions. +- Modify: crates/daemon/test-fixtures/managed-resources/mysql.py + - Daemonizes MySQL request handler threads. +- Modify: crates/daemon/test-fixtures/managed-resources/postgres.py + - Daemonizes PostgreSQL request handler threads. +- Modify: crates/daemon/test-fixtures/managed-resources/fake-mailpit.py + - Normalizes its custom SMTP server thread policy. +- Modify: crates/daemon/test-fixtures/managed-resources/mailpit.py + - Normalizes its custom SMTP server thread policy. + +### Task 1: Bound Fixture-Contract Subprocesses + +**Files:** +- Modify: crates/daemon/tests/fixture_contracts.rs + +**Interfaces:** +- Consumes: the existing FixtureCommand alias, FixtureOutput type, materialize_fixture helper, and MySQL environment probe. +- Produces: + - FIXTURE_COMMAND_TIMEOUT: Duration = 3 seconds + - FIXTURE_COMMAND_POLL_INTERVAL: Duration = 10 milliseconds + - run_fixture_command(command: &mut FixtureCommand, timeout: Duration) -> Result + - fixture_output(output: std::process::Output) -> Result + - process_pid(pid: u32) -> Result + +- [ ] **Step 1: Run the focused baseline** + +Run: + +~~~shell +cargo nextest run -p daemon --test fixture_contracts +~~~ + +Expected: all existing fixture-contract tests pass before the helper changes. + +- [ ] **Step 2: Add the failing timeout regression and behavior-preserving scaffold** + +Add these top-level imports and constants: + +~~~rust +use std::io::{Error, ErrorKind}; +use std::process::{Output, Stdio}; + +use anyhow::{Result, anyhow, bail}; +use rustix::process::{Pid, test_kill_process}; + +const FIXTURE_COMMAND_TIMEOUT: Duration = Duration::from_secs(3); +const FIXTURE_COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(10); +~~~ + +Keep the existing imports that are not replaced. Add this short scenario-local fixture beside the other fixture constants: + +~~~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() +"#; +~~~ + +Add the regression after FixtureOutput: + +~~~rust +#[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(()) +} +~~~ + +Add this temporary behavior-preserving scaffold so the test compiles but still demonstrates the old unbounded behavior: + +~~~rust +fn run_fixture_command( + command: &mut FixtureCommand, + _timeout: Duration, +) -> Result { + fixture_output(command.output()?) +} + +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}")) +} +~~~ + +- [ ] **Step 3: Run the regression under a bounded outer process group and verify RED** + +Run: + +~~~shell +python3 - <<'PY' +import os +import signal +import subprocess +import sys + +command = [ + "cargo", + "nextest", + "run", + "-p", + "daemon", + "--test", + "fixture_contracts", + "-E", + "test(fixture_command_timeout_kills_and_reaps_child)", +] +process = subprocess.Popen(command, start_new_session=True) +try: + return_code = process.wait(timeout=3) +except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + print("RED: unbounded fixture command exceeded the outer deadline") + sys.exit(1) + +print(f"unexpected early exit from RED test: {return_code}") +sys.exit(2) +PY +~~~ + +Expected: the harness prints RED, kills the whole temporary test process group, reaps it, and exits 1. Confirm no hanging-fixture or fixture_contracts process remains before continuing. + +- [ ] **Step 4: Implement the bounded runner** + +Replace the scaffolded run_fixture_command with: + +~~~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); + } +} +~~~ + +Keep fixture_output and process_pid exactly as introduced in Step 2. + +Change run_fixture to build the command first and use the normal 3-second deadline: + +~~~rust +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) +} +~~~ + +Replace the direct MySQL empty-data-directory output call and manual conversion with: + +~~~rust +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)?; +~~~ + +- [ ] **Step 5: Run the timeout regression and verify GREEN** + +Run: + +~~~shell +cargo nextest run -p daemon --test fixture_contracts -E 'test(fixture_command_timeout_kills_and_reaps_child)' +~~~ + +Expected: one test passes in approximately one second, and the recorded child PID no longer exists. + +- [ ] **Step 6: Run focused regression and lint checks** + +Run: + +~~~shell +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 +~~~ + +Expected: all fixture-contract tests pass with the existing snapshots unchanged; formatting, Clippy, and diff checks pass. + +- [ ] **Step 7: Self-review and commit** + +Inspect the diff for these exact properties: + +- both commands formerly using output() now call run_fixture_command, +- normal output conversion occurs only in fixture_output, +- timeout cleanup attempts wait_with_output even after a kill error, +- ErrorKind::InvalidInput is tolerated only as the exit race, +- no dependency or lockfile change exists. + +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 + +**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 process_pid(pid: u32) -> Result, FixtureCommand alias, fixture materialization helper, and timeout constants. +- Produces: + - connect_to_loopback(port: u16, timeout: Duration) -> Result + - wait_for_child_exit(child: &mut std::process::Child, timeout: Duration) -> Result + - kill_and_reap_child(child: &mut std::process::Child) -> Result<()> + - MySQL and PostgreSQL active-idle-client shutdown regressions + - daemon_threads = True on every custom ThreadingMixIn server + +- [ ] **Step 1: Add top-level lifecycle imports and constants** + +Extend the Task 1 imports: + +~~~rust +use std::process::{Child, Output, Stdio}; + +use rustix::process::{Pid, Signal, kill_process, test_kill_process}; + +const FIXTURE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(1); +const FIXTURE_HANDLER_SETTLE_TIME: Duration = Duration::from_millis(100); +~~~ + +- [ ] **Step 2: Add the MySQL and PostgreSQL shutdown regressions before changing fixtures** + +Add these tests after the timeout regression: + +~~~rust +#[test] +fn mysql_fixture_exits_after_sigterm_with_idle_client() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("mysqld"); + 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()) + .spawn()?; + 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!("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 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()) + .spawn()?; + 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!("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 +} +~~~ + +Add these helpers near wait_for_loopback_ports: + +~~~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(()) +} +~~~ + +- [ ] **Step 3: Run both lifecycle regressions and verify RED** + +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)' +~~~ + +Expected: both tests fail with their explicit did-not-exit messages after bounded waits. Their cleanup paths kill and reap both children, leaving no fixture listener behind. + +- [ ] **Step 4: Apply the minimal threaded-server configuration** + +In mysql.py: + +~~~python +class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + daemon_threads = True +~~~ + +In postgres.py: + +~~~python +class Server(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + daemon_threads = True +~~~ + +In fake-mailpit.py: + +~~~python +class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + daemon_threads = True +~~~ + +In mailpit.py: + +~~~python +class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + daemon_threads = True +~~~ + +Do not modify redis-server.py because it already has the required setting. Do not modify ThreadingHTTPServer subclasses or instances. + +- [ ] **Step 5: Run both lifecycle regressions and verify GREEN** + +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)' +~~~ + +Expected: both tests pass; each fixture exits during the one-second shutdown deadline while the idle client remains in scope. + +- [ ] **Step 6: Verify Python syntax and focused daemon behavior** + +Run: + +~~~shell +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 +~~~ + +Expected: four Python fixtures parse, all fixture-contract tests pass with no snapshot changes, and formatting, Clippy, and diff checks pass. + +- [ ] **Step 7: Self-review and commit** + +Inspect the diff for these exact properties: + +- all four custom ThreadingMixIn classes set daemon_threads = True, +- Redis and all ThreadingHTTPServer code remain unchanged, +- shutdown tests keep the idle client alive until after the exit assertion, +- every failure path kills and reaps its child, +- no sleeps are used as the exit assertion; sleeps only allow the accepted handler to begin, +- adoption, docstrings, gateway configuration reading, dependencies, and snapshots remain unchanged. + +Commit: + +~~~shell +git add crates/daemon/tests/fixture_contracts.rs \ + 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 +git commit -m "fix(daemon): daemonize fixture request handlers" +~~~ + +## Final Verification + +After both implementation tasks and their task-scoped reviews are clean, run: + +~~~shell +cargo fmt --all --check +cargo clippy --workspace --all-targets --all-features --locked -- -D warnings +cargo nextest run --workspace --all-features --locked +git diff --check 04ddff4802be29d14ae1bf78a6f03d5256543a9d..HEAD +git status --short +~~~ + +Expected: formatting and Clippy pass, the complete locked workspace suite passes, the branch diff has no whitespace errors, and the worktree is clean. From b07cb793882ba59224b6a59ce9492013b1930e11 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 16 Jul 2026 11:17:02 -0400 Subject: [PATCH 13/33] fix(daemon): bound fixture contract subprocesses --- crates/daemon/tests/fixture_contracts.rs | 113 ++++++++++++++++++++--- 1 file changed, 99 insertions(+), 14 deletions(-) diff --git a/crates/daemon/tests/fixture_contracts.rs b/crates/daemon/tests/fixture_contracts.rs index ccb2d68f..507b6442 100644 --- a/crates/daemon/tests/fixture_contracts.rs +++ b/crates/daemon/tests/fixture_contracts.rs @@ -1,13 +1,18 @@ -use std::io::ErrorKind; +use std::io::{Error, ErrorKind}; use std::net::{Ipv4Addr, TcpListener, TcpStream}; use std::os::unix::fs::PermissionsExt; +use std::process::{Output, Stdio}; use std::thread; use std::time::{Duration, Instant}; -use anyhow::{Result, bail}; +use anyhow::{Result, anyhow, bail}; use camino::Utf8Path; use camino_tempfile::tempdir; use insta::{Settings, assert_debug_snapshot}; +use rustix::process::{Pid, test_kill_process}; + +const FIXTURE_COMMAND_TIMEOUT: Duration = Duration::from_secs(3); +const FIXTURE_COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(10); const MYSQL_FIXTURE: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -30,6 +35,17 @@ const RUSTFS_FIXTURE_TEMPLATE: &str = include_str!(concat!( "/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 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() +"#; #[expect( clippy::disallowed_types, @@ -44,6 +60,36 @@ struct FixtureOutput { stderr: String, } +#[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(()) +} + #[test] fn mysql_fixture_cli_preserves_shell_contract() -> Result<()> { let tempdir = tempdir()?; @@ -95,18 +141,15 @@ os.makedirs = record_makedirs ], tempdir.path(), )?; - let empty_data_dir_initialization = FixtureCommand::new(fixture.as_std_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) - .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)?, - }; + .env("PV_MYSQL_MKDIR_PROBE", &probe_path); + let empty_data_dir_initialization = + run_fixture_command(&mut empty_data_dir_command, FIXTURE_COMMAND_TIMEOUT)?; assert_fixture_snapshot( tempdir.path(), @@ -350,11 +393,48 @@ fn run_fixture( arguments: &[&str], current_dir: &Utf8Path, ) -> Result { - let output = FixtureCommand::new(path.as_std_path()) - .args(arguments) - .current_dir(current_dir) - .output()?; + let mut command = FixtureCommand::new(path.as_std_path()); + command.args(arguments).current_dir(current_dir); + + run_fixture_command(&mut command, FIXTURE_COMMAND_TIMEOUT) +} +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); + } +} + +fn fixture_output(output: Output) -> Result { Ok(FixtureOutput { code: output.status.code(), stdout: String::from_utf8(output.stdout)?, @@ -362,6 +442,11 @@ fn run_fixture( }) } +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; 2], timeout: Duration) -> Result<[bool; 2]> { let deadline = Instant::now() + timeout; let mut readiness = [false; 2]; From 07e07cef0e7f94794170bcd14c9d1dfed4702a13 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 16 Jul 2026 11:23:55 -0400 Subject: [PATCH 14/33] docs(test): correct timeout RED cleanup --- ...07-16-daemon-fixture-review-corrections.md | 107 +++++++++++++++--- 1 file changed, 89 insertions(+), 18 deletions(-) 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 index 2fb3de14..e9aa9e17 100644 --- a/docs/superpowers/plans/2026-07-16-daemon-fixture-review-corrections.md +++ b/docs/superpowers/plans/2026-07-16-daemon-fixture-review-corrections.md @@ -148,43 +148,114 @@ fn process_pid(pid: u32) -> Result { } ~~~ -- [ ] **Step 3: Run the regression under a bounded outer process group and verify RED** +- [ ] **Step 3: Run the regression directly and verify RED cleanup** Run: ~~~shell python3 - <<'PY' +import json import os import signal import subprocess import sys -command = [ - "cargo", - "nextest", - "run", - "-p", - "daemon", - "--test", - "fixture_contracts", - "-E", - "test(fixture_command_timeout_kills_and_reaps_child)", -] -process = subprocess.Popen(command, start_new_session=True) +try: + build = subprocess.run( + [ + "cargo", + "test", + "-p", + "daemon", + "--test", + "fixture_contracts", + "--no-run", + "--message-format=json", + ], + check=True, + capture_output=True, + text=True, + ) +except subprocess.CalledProcessError as error: + print(f"unexpected error: fixture-contract build failed: {error}") + if error.stderr: + print(error.stderr, end="") + sys.exit(2) +except OSError as error: + print(f"unexpected error: fixture-contract build launch failed: {error}") + sys.exit(2) +test_binary = None +for line in build.stdout.splitlines(): + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + if ( + message.get("reason") == "compiler-artifact" + and message.get("target", {}).get("name") == "fixture_contracts" + and message.get("executable") + ): + test_binary = message["executable"] + break +if test_binary is None: + print("unexpected error: Cargo JSON did not identify the fixture_contracts test binary") + sys.exit(2) + +try: + process = subprocess.Popen( + [ + test_binary, + "--exact", + "fixture_command_timeout_kills_and_reaps_child", + "--nocapture", + ], + start_new_session=True, + ) +except OSError as error: + print(f"unexpected error: fixture-contract test binary launch failed: {error}") + sys.exit(2) try: return_code = process.wait(timeout=3) except subprocess.TimeoutExpired: - os.killpg(process.pid, signal.SIGKILL) - process.wait() - print("RED: unbounded fixture command exceeded the outer deadline") - sys.exit(1) + try: + os.killpg(process.pid, signal.SIGKILL) + except OSError as error: + print(f"unexpected error: failed to kill direct test process group: {error}") + sys.exit(2) + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + print("unexpected error: direct test process did not exit after process-group cleanup") + sys.exit(2) + except OSError as error: + print(f"unexpected error: failed to reap direct test process: {error}") + sys.exit(2) + try: + os.killpg(process.pid, 0) + except ProcessLookupError: + print("RED: unbounded fixture command exceeded the direct test deadline; process group was cleaned up") + sys.exit(1) + except OSError as error: + print(f"unexpected error: failed to probe direct test process group: {error}") + sys.exit(2) + print("unexpected error: direct test process group survived cleanup") + sys.exit(2) +except OSError as error: + print(f"unexpected error: initial direct test wait failed: {error}") + sys.exit(2) print(f"unexpected early exit from RED test: {return_code}") sys.exit(2) PY ~~~ -Expected: the harness prints RED, kills the whole temporary test process group, reaps it, and exits 1. Confirm no hanging-fixture or fixture_contracts process remains before continuing. +Expected: Cargo JSON identifies the `fixture_contracts` executable, the direct test binary hangs in the old scaffold, and after three seconds the harness kills and reaps its process group. The harness then verifies `os.killpg(process.pid, 0)` raises `ProcessLookupError`, prints: + +~~~text +RED: unbounded fixture command exceeded the direct test deadline; process group was cleaned up +~~~ + +and exits 1 only after timeout, successful kill/wait cleanup, and cleanup verification. An early test exit, build launch or nonzero-build failure, build/JSON failure, test-binary launch failure, initial wait failure, kill/wait failure, or non-`ProcessLookupError` process-group probe failure is an unexpected error and exits 2. - [ ] **Step 4: Implement the bounded runner** From 89affe2eb9ac6fb01dc932a2ec0a69d53d78e125 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 16 Jul 2026 11:36:02 -0400 Subject: [PATCH 15/33] fix(daemon): daemonize fixture request handlers --- .../managed-resources/fake-mailpit.py | 1 + .../managed-resources/mailpit.py | 1 + .../test-fixtures/managed-resources/mysql.py | 1 + .../managed-resources/postgres.py | 1 + crates/daemon/tests/fixture_contracts.rs | 132 +++++++++++++++++- 5 files changed, 134 insertions(+), 2 deletions(-) diff --git a/crates/daemon/test-fixtures/managed-resources/fake-mailpit.py b/crates/daemon/test-fixtures/managed-resources/fake-mailpit.py index 9deebc01..2d8ea5ce 100644 --- a/crates/daemon/test-fixtures/managed-resources/fake-mailpit.py +++ b/crates/daemon/test-fixtures/managed-resources/fake-mailpit.py @@ -17,6 +17,7 @@ def handle(self): class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): allow_reuse_address = True + daemon_threads = True def stop(_signum, _frame): diff --git a/crates/daemon/test-fixtures/managed-resources/mailpit.py b/crates/daemon/test-fixtures/managed-resources/mailpit.py index ef5bd95f..7b813884 100644 --- a/crates/daemon/test-fixtures/managed-resources/mailpit.py +++ b/crates/daemon/test-fixtures/managed-resources/mailpit.py @@ -57,6 +57,7 @@ def handle(self): class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): allow_reuse_address = True + daemon_threads = True smtp_server = TcpServer(host_port(smtp), SmtpHandler) diff --git a/crates/daemon/test-fixtures/managed-resources/mysql.py b/crates/daemon/test-fixtures/managed-resources/mysql.py index 92e5c5c3..e22efa1b 100644 --- a/crates/daemon/test-fixtures/managed-resources/mysql.py +++ b/crates/daemon/test-fixtures/managed-resources/mysql.py @@ -41,6 +41,7 @@ def handle(self): class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): allow_reuse_address = True + daemon_threads = True def stop(_signum, _frame): diff --git a/crates/daemon/test-fixtures/managed-resources/postgres.py b/crates/daemon/test-fixtures/managed-resources/postgres.py index 09a735ac..c1ae3954 100644 --- a/crates/daemon/test-fixtures/managed-resources/postgres.py +++ b/crates/daemon/test-fixtures/managed-resources/postgres.py @@ -277,6 +277,7 @@ def handle(self): class Server(socketserver.ThreadingMixIn, socketserver.TCPServer): allow_reuse_address = True + daemon_threads = True server = Server((host, port), Handler) diff --git a/crates/daemon/tests/fixture_contracts.rs b/crates/daemon/tests/fixture_contracts.rs index 507b6442..56816bd9 100644 --- a/crates/daemon/tests/fixture_contracts.rs +++ b/crates/daemon/tests/fixture_contracts.rs @@ -1,7 +1,7 @@ use std::io::{Error, ErrorKind}; use std::net::{Ipv4Addr, TcpListener, TcpStream}; use std::os::unix::fs::PermissionsExt; -use std::process::{Output, Stdio}; +use std::process::{Child, Output, Stdio}; use std::thread; use std::time::{Duration, Instant}; @@ -9,10 +9,12 @@ use anyhow::{Result, anyhow, bail}; use camino::Utf8Path; use camino_tempfile::tempdir; use insta::{Settings, assert_debug_snapshot}; -use rustix::process::{Pid, test_kill_process}; +use rustix::process::{Pid, Signal, kill_process, test_kill_process}; 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_HANDLER_SETTLE_TIME: Duration = Duration::from_millis(100); const MYSQL_FIXTURE: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -90,6 +92,87 @@ fn fixture_command_timeout_kills_and_reaps_child() -> Result<()> { Ok(()) } +#[test] +fn mysql_fixture_exits_after_sigterm_with_idle_client() -> Result<()> { + let tempdir = tempdir()?; + let fixture = tempdir.path().join("mysqld"); + 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()) + .spawn()?; + 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!("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 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()) + .spawn()?; + 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!("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 mysql_fixture_cli_preserves_shell_contract() -> Result<()> { let tempdir = tempdir()?; @@ -469,6 +552,51 @@ fn wait_for_loopback_ports(ports: [u16; 2], timeout: Duration) -> Result<[bool; } } +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(()) +} + fn assert_fixture_snapshot( tempdir: &Utf8Path, name: &'static str, From 7efda23ec96991d18505d5855fb602d9e27c8275 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 16 Jul 2026 13:53:14 -0400 Subject: [PATCH 16/33] fix(daemon): bound fixture child cleanup --- crates/daemon/tests/fixture_contracts.rs | 78 +++++++++++++----------- 1 file changed, 44 insertions(+), 34 deletions(-) diff --git a/crates/daemon/tests/fixture_contracts.rs b/crates/daemon/tests/fixture_contracts.rs index 56816bd9..69f120b6 100644 --- a/crates/daemon/tests/fixture_contracts.rs +++ b/crates/daemon/tests/fixture_contracts.rs @@ -67,6 +67,7 @@ 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"); + let timeout = Duration::from_secs(1); materialize_fixture(&fixture, HANGING_FIXTURE)?; let mut command = FixtureCommand::new(fixture.as_std_path()); @@ -74,7 +75,8 @@ fn fixture_command_timeout_kills_and_reaps_child() -> Result<()> { .arg(pid_path.as_std_path()) .current_dir(tempdir.path()); - let error = match run_fixture_command(&mut command, 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, }; @@ -82,6 +84,10 @@ fn fixture_command_timeout_kills_and_reaps_child() -> Result<()> { .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 timeout exceeded its cleanup deadline" + ); let raw_pid = state::fs::read_to_string(&pid_path)? .trim() @@ -277,16 +283,16 @@ fn fake_mailpit_fixture_cli_ignores_extra_arguments() -> Result<()> { Ok::<_, anyhow::Error>((readiness, running_after_readiness)) })(); - let kill_result = child.kill(); - let wait_result = child.wait(); + let cleanup = kill_and_reap_child(&mut child); - let lifecycle = lifecycle?; - if let Err(error) = kill_result - && error.kind() != ErrorKind::InvalidInput - { - return Err(error.into()); - } - wait_result?; + let lifecycle = match lifecycle { + Ok(lifecycle) => lifecycle, + Err(error) => { + cleanup?; + return Err(error); + } + }; + cleanup?; assert_fixture_snapshot( tempdir.path(), @@ -490,31 +496,25 @@ fn run_fixture_command(command: &mut FixtureCommand, timeout: Duration) -> Resul let mut child = command.spawn()?; let deadline = Instant::now() + timeout; - loop { - if child.try_wait()?.is_some() { - return fixture_output(child.wait_with_output()?); + 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 { - 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( + break Error::new( ErrorKind::TimedOut, format!("fixture command timed out after {} ms", timeout.as_millis()), ) - .into()); + .into(); } thread::sleep(FIXTURE_COMMAND_POLL_INTERVAL); - } + }; + + kill_and_reap_child(&mut child)?; + Err(error) } fn fixture_output(output: Output) -> Result { @@ -583,15 +583,25 @@ fn wait_for_child_exit(child: &mut Child, timeout: Duration) -> Result { } 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 { + 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 let Err(error) = kill_result - && error.kind() != ErrorKind::InvalidInput - { - 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(()) From fafd3c4f470ed2ed328801ce43f7b0cf778e8931 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 16 Jul 2026 14:03:20 -0400 Subject: [PATCH 17/33] test(daemon): synchronize fixture handler shutdown --- .../test-fixtures/managed-resources/mysql.py | 4 ++++ .../managed-resources/postgres.py | 4 ++++ crates/daemon/tests/fixture_contracts.rs | 24 ++++++++++++++++--- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/daemon/test-fixtures/managed-resources/mysql.py b/crates/daemon/test-fixtures/managed-resources/mysql.py index e22efa1b..c630d925 100644 --- a/crates/daemon/test-fixtures/managed-resources/mysql.py +++ b/crates/daemon/test-fixtures/managed-resources/mysql.py @@ -36,6 +36,10 @@ 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) diff --git a/crates/daemon/test-fixtures/managed-resources/postgres.py b/crates/daemon/test-fixtures/managed-resources/postgres.py index c1ae3954..a4dfc5bf 100644 --- a/crates/daemon/test-fixtures/managed-resources/postgres.py +++ b/crates/daemon/test-fixtures/managed-resources/postgres.py @@ -203,6 +203,10 @@ def query_response(query, params): 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: diff --git a/crates/daemon/tests/fixture_contracts.rs b/crates/daemon/tests/fixture_contracts.rs index 69f120b6..53bae1ee 100644 --- a/crates/daemon/tests/fixture_contracts.rs +++ b/crates/daemon/tests/fixture_contracts.rs @@ -14,7 +14,6 @@ use rustix::process::{Pid, Signal, kill_process, test_kill_process}; 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_HANDLER_SETTLE_TIME: Duration = Duration::from_millis(100); const MYSQL_FIXTURE: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -102,6 +101,7 @@ fn fixture_command_timeout_kills_and_reaps_child() -> Result<()> { 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(); @@ -112,10 +112,11 @@ fn mysql_fixture_exits_after_sigterm_with_idle_client() -> Result<()> { 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)?; - thread::sleep(FIXTURE_HANDLER_SETTLE_TIME); + wait_for_path(&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"); @@ -137,6 +138,7 @@ 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(); @@ -159,10 +161,11 @@ fn postgres_fixture_exits_after_sigterm_with_idle_client() -> Result<()> { 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)?; - thread::sleep(FIXTURE_HANDLER_SETTLE_TIME); + wait_for_path(&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"); @@ -567,6 +570,21 @@ fn connect_to_loopback(port: u16, timeout: Duration) -> Result { } } +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); + } +} + fn wait_for_child_exit(child: &mut Child, timeout: Duration) -> Result { let deadline = Instant::now() + timeout; From 2fb213f7aa3af017a0e5a842b03ec9152d230072 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 16 Jul 2026 14:11:05 -0400 Subject: [PATCH 18/33] docs(test): align fixture lifecycle verification --- .../plans/2026-07-15-daemon-test-fixtures.md | 6 +- ...07-16-daemon-fixture-review-corrections.md | 695 ++++++++---------- 2 files changed, 316 insertions(+), 385 deletions(-) diff --git a/docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md b/docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md index 177991da..6df858a8 100644 --- a/docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md +++ b/docs/superpowers/plans/2026-07-15-daemon-test-fixtures.md @@ -2185,16 +2185,16 @@ Run: 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|#!/usr/bin/env python3" crates/daemon --glob '*.rs'; then +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" crates/daemon +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. +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** 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 index e9aa9e17..c3de6b7d 100644 --- a/docs/superpowers/plans/2026-07-16-daemon-fixture-review-corrections.md +++ b/docs/superpowers/plans/2026-07-16-daemon-fixture-review-corrections.md @@ -2,81 +2,50 @@ > **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:** Bound fixture-contract subprocesses and make every custom threaded TCP fixture unable to delay interpreter shutdown through non-daemon request threads. +**Goal:** Document the accepted fixture-contract subprocess cleanup hardening and deterministic threaded-handler synchronization in an executable historical sequence. -**Architecture:** Keep all changes inside daemon test fixtures and their executable-level integration test. One private synchronous command runner owns the deadline, output capture, kill, and reap behavior for commands expected to exit; the fixture classes themselves opt into daemon request threads without changing their CLI or protocol behavior. +**Architecture:** A private synchronous fixture-command runner owns deadline polling, confirmed-exit output capture, and bounded cleanup. MySQL and PostgreSQL lifecycle tests wait for an opt-in UTF-8 handler-acceptance marker before signalling the fixture. Task 1's blocking cleanup and Task 2's fixed settle delay are historical interim states only; Tasks 3 and 4 supersede them before final verification. **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, or fixture process-group topology. -- Python fixtures remain standard-library-only; do not add dependencies or modify Cargo.lock. -- Normal fixture-contract commands use a 3-second deadline and a 10-millisecond poll interval. -- On timeout, kill and reap the direct child before returning std::io::ErrorKind::TimedOut; report kill or reap failures instead of the timeout. -- Preserve normal exit status, stdout, stderr, snapshots, custom MySQL environment variables, CLI behavior, and protocol behavior. -- Set daemon_threads = True on every custom socketserver.ThreadingMixIn fixture server: MySQL, PostgreSQL, fake Mailpit, and Mailpit. Redis retains its existing setting. -- Add behavior regressions for MySQL and PostgreSQL, whose handlers can remain blocked on accepted idle clients. Do not add implementation-detail tests for Mailpit handlers that already return promptly. -- Do not change adoption matching, add metric-driven docstrings, or include the unrelated gateway configuration-file cleanup. -- Follow test-driven development: observe the new test fail for the intended reason before implementing each behavior change. -- Keep implementation commits separate: timeout runner first, threaded-server shutdown second. - ---- +- Do not change production daemon or supervisor behavior, process ownership matching, nextest configuration, CI scheduling, fixture process-group topology, snapshots, or Cargo.lock. +- Python fixtures remain standard-library-only. Preserve normal exit status, stdout, stderr, custom MySQL environment variables, CLI behavior, and protocol behavior. +- Final behavior uses a 3-second command deadline, 10-millisecond poll interval, and 1-second bounded cleanup deadline. Every post-spawn timeout or try_wait error routes through bounded cleanup; the confirmed-exit wait_with_output path is unchanged. +- Final lifecycle tests use a per-test PV_FIXTURE_HANDLER_STARTED marker rather than a fixed delay. MySQL and PostgreSQL write and close that UTF-8 marker at the start of Handler.handle, before their blocking reads. +- 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. Tasks 1 and 2 are historical interim states and must be followed by Tasks 3 and 4; neither their blocking reap nor their 100-millisecond settle sleep is acceptable final behavior. ## File Map - Modify: crates/daemon/tests/fixture_contracts.rs - - Owns executable-level fixture CLI, timeout, cleanup, and shutdown regressions. + - Final state: bounded command cleanup, handler-marker synchronization, and MySQL/PostgreSQL idle-client shutdown coverage. - Modify: crates/daemon/test-fixtures/managed-resources/mysql.py - - Daemonizes MySQL request handler threads. + - Final state: daemon request threads and optional handler marker before recv. - Modify: crates/daemon/test-fixtures/managed-resources/postgres.py - - Daemonizes PostgreSQL request handler threads. + - Final state: daemon request threads and optional handler marker before read_startup. - Modify: crates/daemon/test-fixtures/managed-resources/fake-mailpit.py - - Normalizes its custom SMTP server thread policy. + - Final state: daemon SMTP request threads. - Modify: crates/daemon/test-fixtures/managed-resources/mailpit.py - - Normalizes its custom SMTP server thread policy. + - Final state: daemon SMTP request threads. ### Task 1: Bound Fixture-Contract Subprocesses +**Historical commit:** b07cb793882ba59224b6a59ce9492013b1930e11 — first implementation commit. + **Files:** - Modify: crates/daemon/tests/fixture_contracts.rs **Interfaces:** -- Consumes: the existing FixtureCommand alias, FixtureOutput type, materialize_fixture helper, and MySQL environment probe. -- Produces: - - FIXTURE_COMMAND_TIMEOUT: Duration = 3 seconds - - FIXTURE_COMMAND_POLL_INTERVAL: Duration = 10 milliseconds - - run_fixture_command(command: &mut FixtureCommand, timeout: Duration) -> Result - - fixture_output(output: std::process::Output) -> Result - - process_pid(pid: u32) -> Result - -- [ ] **Step 1: Run the focused baseline** - -Run: - -~~~shell -cargo nextest run -p daemon --test fixture_contracts -~~~ +- 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. -Expected: all existing fixture-contract tests pass before the helper changes. - -- [ ] **Step 2: Add the failing timeout regression and behavior-preserving scaffold** - -Add these top-level imports and constants: - -~~~rust -use std::io::{Error, ErrorKind}; -use std::process::{Output, Stdio}; - -use anyhow::{Result, anyhow, bail}; -use rustix::process::{Pid, test_kill_process}; - -const FIXTURE_COMMAND_TIMEOUT: Duration = Duration::from_secs(3); -const FIXTURE_COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(10); -~~~ +- [ ] **Step 1: Add the hanging-child regression and runner** -Keep the existing imports that are not replaced. Add this short scenario-local fixture beside the other fixture constants: +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 @@ -90,11 +59,7 @@ with open(sys.argv[1], "w", encoding="utf-8") as pid_file: signal.pause() "#; -~~~ - -Add the regression after FixtureOutput: -~~~rust #[test] fn fixture_command_timeout_kills_and_reaps_child() -> Result<()> { let tempdir = tempdir()?; @@ -103,7 +68,9 @@ fn fixture_command_timeout_kills_and_reaps_child() -> Result<()> { 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()); + 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:?}"), @@ -124,148 +91,12 @@ fn fixture_command_timeout_kills_and_reaps_child() -> Result<()> { } ~~~ -Add this temporary behavior-preserving scaffold so the test compiles but still demonstrates the old unbounded behavior: - -~~~rust -fn run_fixture_command( - command: &mut FixtureCommand, - _timeout: Duration, -) -> Result { - fixture_output(command.output()?) -} - -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}")) -} -~~~ - -- [ ] **Step 3: Run the regression directly and verify RED cleanup** - -Run: - -~~~shell -python3 - <<'PY' -import json -import os -import signal -import subprocess -import sys - -try: - build = subprocess.run( - [ - "cargo", - "test", - "-p", - "daemon", - "--test", - "fixture_contracts", - "--no-run", - "--message-format=json", - ], - check=True, - capture_output=True, - text=True, - ) -except subprocess.CalledProcessError as error: - print(f"unexpected error: fixture-contract build failed: {error}") - if error.stderr: - print(error.stderr, end="") - sys.exit(2) -except OSError as error: - print(f"unexpected error: fixture-contract build launch failed: {error}") - sys.exit(2) -test_binary = None -for line in build.stdout.splitlines(): - try: - message = json.loads(line) - except json.JSONDecodeError: - continue - if ( - message.get("reason") == "compiler-artifact" - and message.get("target", {}).get("name") == "fixture_contracts" - and message.get("executable") - ): - test_binary = message["executable"] - break -if test_binary is None: - print("unexpected error: Cargo JSON did not identify the fixture_contracts test binary") - sys.exit(2) - -try: - process = subprocess.Popen( - [ - test_binary, - "--exact", - "fixture_command_timeout_kills_and_reaps_child", - "--nocapture", - ], - start_new_session=True, - ) -except OSError as error: - print(f"unexpected error: fixture-contract test binary launch failed: {error}") - sys.exit(2) -try: - return_code = process.wait(timeout=3) -except subprocess.TimeoutExpired: - try: - os.killpg(process.pid, signal.SIGKILL) - except OSError as error: - print(f"unexpected error: failed to kill direct test process group: {error}") - sys.exit(2) - try: - process.wait(timeout=1) - except subprocess.TimeoutExpired: - print("unexpected error: direct test process did not exit after process-group cleanup") - sys.exit(2) - except OSError as error: - print(f"unexpected error: failed to reap direct test process: {error}") - sys.exit(2) - try: - os.killpg(process.pid, 0) - except ProcessLookupError: - print("RED: unbounded fixture command exceeded the direct test deadline; process group was cleaned up") - sys.exit(1) - except OSError as error: - print(f"unexpected error: failed to probe direct test process group: {error}") - sys.exit(2) - print("unexpected error: direct test process group survived cleanup") - sys.exit(2) -except OSError as error: - print(f"unexpected error: initial direct test wait failed: {error}") - sys.exit(2) - -print(f"unexpected early exit from RED test: {return_code}") -sys.exit(2) -PY -~~~ - -Expected: Cargo JSON identifies the `fixture_contracts` executable, the direct test binary hangs in the old scaffold, and after three seconds the harness kills and reaps its process group. The harness then verifies `os.killpg(process.pid, 0)` raises `ProcessLookupError`, prints: - -~~~text -RED: unbounded fixture command exceeded the direct test deadline; process group was cleaned up -~~~ - -and exits 1 only after timeout, successful kill/wait cleanup, and cleanup verification. An early test exit, build launch or nonzero-build failure, build/JSON failure, test-binary launch failure, initial wait failure, kill/wait failure, or non-`ProcessLookupError` process-group probe failure is an unexpected error and exits 2. - -- [ ] **Step 4: Implement the bounded runner** +- [ ] **Step 2: Implement the b07-era runner** -Replace the scaffolded run_fixture_command with: +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 { +fn run_fixture_command(command: &mut FixtureCommand, timeout: Duration) -> Result { command .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -301,69 +132,38 @@ fn run_fixture_command( } ~~~ -Keep fixture_output and process_pid exactly as introduced in Step 2. - -Change run_fixture to build the command first and use the normal 3-second deadline: +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 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) +fn fixture_output(output: Output) -> Result { + Ok(FixtureOutput { + code: output.status.code(), + stdout: String::from_utf8(output.stdout)?, + stderr: String::from_utf8(output.stderr)?, + }) } -~~~ - -Replace the direct MySQL empty-data-directory output call and manual conversion with: -~~~rust -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)?; -~~~ - -- [ ] **Step 5: Run the timeout regression and verify GREEN** - -Run: - -~~~shell -cargo nextest run -p daemon --test fixture_contracts -E 'test(fixture_command_timeout_kills_and_reaps_child)' +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}")) +} ~~~ -Expected: one test passes in approximately one second, and the recorded child PID no longer exists. +Route run_fixture and the direct MySQL empty-data-directory command through that runner. -- [ ] **Step 6: Run focused regression and lint checks** +- [ ] **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 ~~~ -Expected: all fixture-contract tests pass with the existing snapshots unchanged; formatting, Clippy, and diff checks pass. - -- [ ] **Step 7: Self-review and commit** - -Inspect the diff for these exact properties: - -- both commands formerly using output() now call run_fixture_command, -- normal output conversion occurs only in fixture_output, -- timeout cleanup attempts wait_with_output even after a kill error, -- ErrorKind::InvalidInput is tolerated only as the exit race, -- no dependency or lockfile change exists. +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: @@ -374,6 +174,8 @@ 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 @@ -382,115 +184,42 @@ git commit -m "fix(daemon): bound fixture contract subprocesses" - Modify: crates/daemon/test-fixtures/managed-resources/mailpit.py **Interfaces:** -- Consumes: Task 1's process_pid(pid: u32) -> Result, FixtureCommand alias, fixture materialization helper, and timeout constants. -- Produces: - - connect_to_loopback(port: u16, timeout: Duration) -> Result - - wait_for_child_exit(child: &mut std::process::Child, timeout: Duration) -> Result - - kill_and_reap_child(child: &mut std::process::Child) -> Result<()> - - MySQL and PostgreSQL active-idle-client shutdown regressions - - daemon_threads = True on every custom ThreadingMixIn server +- 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 top-level lifecycle imports and constants** +- [ ] **Step 1: Add the historical lifecycle constants, RED tests, and helpers** -Extend the Task 1 imports: +Add: ~~~rust -use std::process::{Child, Output, Stdio}; - -use rustix::process::{Pid, Signal, kill_process, test_kill_process}; - const FIXTURE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(1); const FIXTURE_HANDLER_SETTLE_TIME: Duration = Duration::from_millis(100); ~~~ -- [ ] **Step 2: Add the MySQL and PostgreSQL shutdown regressions before changing fixtures** - -Add these tests after the timeout regression: +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 -#[test] -fn mysql_fixture_exits_after_sigterm_with_idle_client() -> Result<()> { - let tempdir = tempdir()?; - let fixture = tempdir.path().join("mysqld"); - 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()) - .spawn()?; - 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!("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); +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"); } - 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 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()) - .spawn()?; - 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!("PostgreSQL fixture did not exit after SIGTERM with an idle client"); - } - - Ok::<(), anyhow::Error>(()) - })(); - let cleanup = kill_and_reap_child(&mut child); + Ok::<(), anyhow::Error>(()) +})(); +let cleanup = kill_and_reap_child(&mut child); - if let Err(error) = lifecycle { - cleanup?; - return Err(error); - } - cleanup +if let Err(error) = lifecycle { + cleanup?; + return Err(error); } +cleanup ~~~ -Add these helpers near wait_for_loopback_ports: +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 { @@ -539,19 +268,18 @@ fn kill_and_reap_child(child: &mut Child) -> Result<()> { } ~~~ -- [ ] **Step 3: Run both lifecycle regressions and verify RED** +Extend the Task 1 imports with: -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)' +~~~rust +use std::process::{Child, Output, Stdio}; +use rustix::process::{Pid, Signal, kill_process, test_kill_process}; ~~~ -Expected: both tests fail with their explicit did-not-exit messages after bounded waits. Their cleanup paths kill and reap both children, leaving no fixture listener behind. +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 4: Apply the minimal threaded-server configuration** +- [ ] **Step 2: Apply the minimal threaded-server GREEN change** -In mysql.py: +Add daemon_threads = True to each custom ThreadingMixIn class: ~~~python class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): @@ -559,81 +287,284 @@ class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): daemon_threads = True ~~~ -In postgres.py: +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. -~~~python -class Server(socketserver.ThreadingMixIn, socketserver.TCPServer): - allow_reuse_address = True - daemon_threads = True +- [ ] **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 ~~~ -In fake-mailpit.py: +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. -~~~python -class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): - allow_reuse_address = True - daemon_threads = True +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" ~~~ -In mailpit.py: +### Task 3: Harden Fixture Child Cleanup -~~~python -class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): - allow_reuse_address = True - daemon_threads = True +**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" +); ~~~ -Do not modify redis-server.py because it already has the required setting. Do not modify ThreadingHTTPServer subclasses or instances. +- [ ] **Step 2: Funnel every post-spawn failure through bounded cleanup** -- [ ] **Step 5: Run both lifecycle regressions and verify GREEN** +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. -Run: +~~~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; -~~~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)' + 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?; ~~~ -Expected: both tests pass; each fixture exits during the one-second shutdown deadline while the idle client remains in scope. +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 6: Verify Python syntax and focused daemon behavior** +- [ ] **Step 5: Verify focused cleanup behavior and commit** Run: ~~~shell -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 -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 ~~~ -Expected: four Python fixtures parse, all fixture-contract tests pass with no snapshot changes, and formatting, Clippy, and diff checks pass. +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 immediately before the initial read_startup(self.request). + +~~~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 final 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 final PostgreSQL shape places the same completed marker write before its first blocking read: -- [ ] **Step 7: Self-review and commit** +~~~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) +~~~ -Inspect the diff for these exact properties: +- [ ] **Step 3: Verify GREEN and final synchronization** -- all four custom ThreadingMixIn classes set daemon_threads = True, -- Redis and all ThreadingHTTPServer code remain unchanged, -- shutdown tests keep the idle client alive until after the exit assertion, -- every failure path kills and reaps its child, -- no sleeps are used as the exit assertion; sleeps only allow the accepted handler to begin, -- adoption, docstrings, gateway configuration reading, dependencies, and snapshots remain unchanged. +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 +for iteration in {1..20}; do + cargo nextest run -p daemon --test fixture_contracts -E 'test(mysql_fixture_exits_after_sigterm_with_idle_client)' +done +for iteration in {1..20}; do + cargo nextest run -p daemon --test fixture_contracts -E 'test(postgres_fixture_exits_after_sigterm_with_idle_client)' +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 \ - 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 -git commit -m "fix(daemon): daemonize fixture request handlers" +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" ~~~ ## Final Verification -After both implementation tasks and their task-scoped reviews are clean, run: +Run final verification only after all four tasks. The accepted state has bounded cleanup and marker-based handler synchronization; it does not accept Task 1's blocking reap or Task 2's fixed settle sleep. ~~~shell cargo fmt --all --check @@ -643,4 +574,4 @@ git diff --check 04ddff4802be29d14ae1bf78a6f03d5256543a9d..HEAD git status --short ~~~ -Expected: formatting and Clippy pass, the complete locked workspace suite passes, the branch diff has no whitespace errors, and the worktree is clean. +Expected: formatting, Clippy, and the locked workspace suite pass; normal fixture exits capture output only after confirmed exit; timeout and try_wait failures use bounded cleanup; MySQL and PostgreSQL use acceptance markers instead of a sleep; the branch diff has no whitespace errors; and the worktree is clean. From c36e9763ad3120c98b9c9df4d141b3052c348806 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 16 Jul 2026 15:39:36 -0400 Subject: [PATCH 19/33] docs(test): plan final fixture review fixes --- ...07-16-daemon-fixture-review-corrections.md | 377 +++++++++++++++++- 1 file changed, 362 insertions(+), 15 deletions(-) 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 index c3de6b7d..91e2fd78 100644 --- a/docs/superpowers/plans/2026-07-16-daemon-fixture-review-corrections.md +++ b/docs/superpowers/plans/2026-07-16-daemon-fixture-review-corrections.md @@ -2,31 +2,37 @@ > **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:** Document the accepted fixture-contract subprocess cleanup hardening and deterministic threaded-handler synchronization in an executable historical sequence. +**Goal:** Preserve the accepted historical fixture-contract corrections and specify the final timeout-budget, complete-marker, and backpressure-safe output-capture follow-ups. -**Architecture:** A private synchronous fixture-command runner owns deadline polling, confirmed-exit output capture, and bounded cleanup. MySQL and PostgreSQL lifecycle tests wait for an opt-in UTF-8 handler-acceptance marker before signalling the fixture. Task 1's blocking cleanup and Task 2's fixed settle delay are historical interim states only; Tasks 3 and 4 supersede them before final verification. +**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. **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, snapshots, or Cargo.lock. +- Do not change production daemon or supervisor behavior, process ownership matching, nextest configuration, CI scheduling, fixture process-group topology, Cargo.toml, Cargo.lock, dependencies, manifests, or Python fixture producers. - Python fixtures remain standard-library-only. Preserve normal exit status, stdout, stderr, custom MySQL environment variables, CLI behavior, and protocol behavior. -- Final behavior uses a 3-second command deadline, 10-millisecond poll interval, and 1-second bounded cleanup deadline. Every post-spawn timeout or try_wait error routes through bounded cleanup; the confirmed-exit wait_with_output path is unchanged. -- Final lifecycle tests use a per-test PV_FIXTURE_HANDLER_STARTED marker rather than a fixed delay. MySQL and PostgreSQL write and close that UTF-8 marker at the start of Handler.handle, before their blocking reads. +- 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. +- Final lifecycle tests use a per-test `PV_FIXTURE_HANDLER_STARTED` marker rather than a fixed delay. MySQL and PostgreSQL Python producers remain unchanged; 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. Tasks 1 and 2 are historical interim states and must be followed by Tasks 3 and 4; neither their blocking reap nor their 100-millisecond settle sleep is acceptable final behavior. +- 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–7 have three additional, separate commit boundaries and define the final accepted details. ## File Map - Modify: crates/daemon/tests/fixture_contracts.rs - - Final state: bounded command cleanup, handler-marker synchronization, and MySQL/PostgreSQL idle-client shutdown coverage. + - Final state: bounded command cleanup with a scheduling-margin test budget, complete-marker synchronization, backpressure-safe temporary-file output capture, and MySQL/PostgreSQL idle-client shutdown coverage. +- 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 and optional handler marker before recv. - Modify: crates/daemon/test-fixtures/managed-resources/postgres.py - - Final state: daemon request threads and optional handler marker before read_startup. + - Historical state: daemon request threads and an optional marker at handler entry, before `statements`/`portals` initialization and the first blocking `read_startup`. - Modify: crates/daemon/test-fixtures/managed-resources/fake-mailpit.py - Final state: daemon SMTP request threads. - Modify: crates/daemon/test-fixtures/managed-resources/mailpit.py @@ -496,7 +502,7 @@ Do not change either Python fixture in this RED step. Both lifecycle tests must - [ ] **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 immediately before the initial read_startup(self.request). +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") @@ -505,7 +511,7 @@ if handler_marker: marker.write("started\n") ~~~ -The final MySQL shape is: +The Task 4 MySQL shape is: ~~~python class Handler(socketserver.BaseRequestHandler): @@ -517,7 +523,7 @@ class Handler(socketserver.BaseRequestHandler): self.request.recv(1024) ~~~ -The final PostgreSQL shape places the same completed marker write before its first blocking read: +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): @@ -532,7 +538,7 @@ class Handler(socketserver.BaseRequestHandler): read_startup(self.request) ~~~ -- [ ] **Step 3: Verify GREEN and final synchronization** +- [ ] **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: @@ -562,16 +568,357 @@ 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 -- 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 -- 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" +~~~ + ## Final Verification -Run final verification only after all four tasks. The accepted state has bounded cleanup and marker-based handler synchronization; it does not accept Task 1's blocking reap or Task 2's fixed settle sleep. +Run final verification only after Task 7 and its commit. Tasks 1–4 are historical; Tasks 5–7 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 +for iteration in {1..20}; do + cargo nextest run -p daemon --test fixture_contracts -E 'test(mysql_fixture_exits_after_sigterm_with_idle_client)' +done +for iteration in {1..20}; do + cargo nextest run -p daemon --test fixture_contracts -E 'test(postgres_fixture_exits_after_sigterm_with_idle_client)' +done cargo fmt --all --check cargo clippy --workspace --all-targets --all-features --locked -- -D warnings cargo nextest run --workspace --all-features --locked -git diff --check 04ddff4802be29d14ae1bf78a6f03d5256543a9d..HEAD +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")' +git diff --check HEAD~3..HEAD +git diff --name-only HEAD~3..HEAD +! rg --files crates/daemon/tests/snapshots -g '*.snap.new' -g '*.snap.tmp' +git ls-files --others --exclude-standard git status --short ~~~ -Expected: formatting, Clippy, and the locked workspace suite pass; normal fixture exits capture output only after confirmed exit; timeout and try_wait failures use bounded cleanup; MySQL and PostgreSQL use acceptance markers instead of a sleep; the branch diff has no whitespace errors; and the worktree is clean. +Expected: the focused new regressions and full fixture-contract suite pass; each lifecycle test passes 20 times; formatting, locked workspace Clippy, and the locked workspace suite pass; all four Python fixtures parse; the three-task artifact diff contains only `fixture_contracts.rs` and the two new snapshots with no whitespace errors or temporary snapshot artifacts; and both untracked-file and status checks are empty. The final behavior retains bounded cleanup, includes the test-only scheduling margin, accepts only complete handler markers, and captures 2 MiB of UTF-8 output from each stream without pipe backpressure. From 98ef5fe86dad3cc04b88790437564da6ddffcc48 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 16 Jul 2026 15:53:35 -0400 Subject: [PATCH 20/33] test(daemon): allow fixture timeout scheduling margin --- crates/daemon/tests/fixture_contracts.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/daemon/tests/fixture_contracts.rs b/crates/daemon/tests/fixture_contracts.rs index 53bae1ee..123f3195 100644 --- a/crates/daemon/tests/fixture_contracts.rs +++ b/crates/daemon/tests/fixture_contracts.rs @@ -14,6 +14,7 @@ use rustix::process::{Pid, Signal, kill_process, test_kill_process}; 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 MYSQL_FIXTURE: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -84,7 +85,12 @@ fn fixture_command_timeout_kills_and_reaps_child() -> Result<()> { .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, + 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" ); From 4af83bcd164a815c0522fdff54a5d0561a62616f Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 16 Jul 2026 15:58:02 -0400 Subject: [PATCH 21/33] test(daemon): require complete fixture handler markers --- crates/daemon/tests/fixture_contracts.rs | 34 ++++++++++++++++--- ...ler_marker_requires_complete_contents.snap | 5 +++ 2 files changed, 34 insertions(+), 5 deletions(-) create mode 100644 crates/daemon/tests/snapshots/fixture_contracts__fixture_handler_marker_requires_complete_contents.snap diff --git a/crates/daemon/tests/fixture_contracts.rs b/crates/daemon/tests/fixture_contracts.rs index 123f3195..a17bd488 100644 --- a/crates/daemon/tests/fixture_contracts.rs +++ b/crates/daemon/tests/fixture_contracts.rs @@ -10,11 +10,13 @@ use camino::Utf8Path; use camino_tempfile::tempdir; use insta::{Settings, assert_debug_snapshot}; use rustix::process::{Pid, Signal, kill_process, test_kill_process}; +use state::StateError; 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 MYSQL_FIXTURE: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -122,7 +124,7 @@ fn mysql_fixture_exits_after_sigterm_with_idle_client() -> Result<()> { .spawn()?; let lifecycle = (|| { let _idle_client = connect_to_loopback(port, FIXTURE_COMMAND_TIMEOUT)?; - wait_for_path(&handler_marker, 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"); @@ -171,7 +173,7 @@ fn postgres_fixture_exits_after_sigterm_with_idle_client() -> Result<()> { .spawn()?; let lifecycle = (|| { let _idle_client = connect_to_loopback(port, FIXTURE_COMMAND_TIMEOUT)?; - wait_for_path(&handler_marker, 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"); @@ -188,6 +190,25 @@ fn postgres_fixture_exits_after_sigterm_with_idle_client() -> Result<()> { cleanup } +#[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()?; @@ -576,12 +597,15 @@ fn connect_to_loopback(port: u16, timeout: Duration) -> Result { } } -fn wait_for_path(path: &Utf8Path, timeout: Duration) -> Result<()> { +fn wait_for_handler_marker(path: &Utf8Path, timeout: Duration) -> Result<()> { let deadline = Instant::now() + timeout; loop { - if path_exists(path)? { - return Ok(()); + 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}"); 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" From 15e13b8d498016f002b6ded62b8cf733ca3b7a11 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 16 Jul 2026 16:02:49 -0400 Subject: [PATCH 22/33] docs(test): target fixture snapshot acceptance --- .../2026-07-16-daemon-fixture-review-corrections.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 index 91e2fd78..01a00a6c 100644 --- a/docs/superpowers/plans/2026-07-16-daemon-fixture-review-corrections.md +++ b/docs/superpowers/plans/2026-07-16-daemon-fixture-review-corrections.md @@ -711,7 +711,7 @@ Do not edit `mysql.py` or `postgres.py`: their existing write-and-close `"starte Run: ~~~shell -cargo insta test --accept --test-runner nextest -- fixture_handler_marker_requires_complete_contents +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 @@ -866,7 +866,7 @@ fn read_fixture_output(file: &mut (impl Read + Seek)) -> Result> { Run: ~~~shell -cargo insta test --accept --test-runner nextest -- fixture_command_captures_verbose_output_without_pipe_backpressure +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 @@ -914,11 +914,11 @@ 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; 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")' -git diff --check HEAD~3..HEAD -git diff --name-only HEAD~3..HEAD +git diff --check c36e9763ad3120c98b9c9df4d141b3052c348806..HEAD +git diff --name-only c36e9763ad3120c98b9c9df4d141b3052c348806..HEAD ! rg --files crates/daemon/tests/snapshots -g '*.snap.new' -g '*.snap.tmp' git ls-files --others --exclude-standard git status --short ~~~ -Expected: the focused new regressions and full fixture-contract suite pass; each lifecycle test passes 20 times; formatting, locked workspace Clippy, and the locked workspace suite pass; all four Python fixtures parse; the three-task artifact diff contains only `fixture_contracts.rs` and the two new snapshots with no whitespace errors or temporary snapshot artifacts; and both untracked-file and status checks are empty. The final behavior retains bounded cleanup, includes the test-only scheduling margin, accepts only complete handler markers, and captures 2 MiB of UTF-8 output from each stream without pipe backpressure. +Expected: the focused new regressions and full fixture-contract suite pass; each lifecycle test passes 20 times; formatting, locked workspace Clippy, and the locked workspace suite pass; all four Python fixtures parse; the follow-up artifact diff contains only this correction plan, `fixture_contracts.rs`, and the two new snapshots with no whitespace errors or temporary snapshot artifacts; and both untracked-file and status checks are empty. The final behavior retains bounded cleanup, includes the test-only scheduling margin, accepts only complete handler markers, and captures 2 MiB of UTF-8 output from each stream without pipe backpressure. From 71861525112146f4c7980bb1d3d1225f569571e3 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 16 Jul 2026 16:06:20 -0400 Subject: [PATCH 23/33] fix(daemon): capture fixture output without pipe backpressure --- crates/daemon/tests/fixture_contracts.rs | 63 ++++++++++++++++--- ...bose_output_without_pipe_backpressure.snap | 11 ++++ 2 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 crates/daemon/tests/snapshots/fixture_contracts__fixture_command_captures_verbose_output_without_pipe_backpressure.snap diff --git a/crates/daemon/tests/fixture_contracts.rs b/crates/daemon/tests/fixture_contracts.rs index a17bd488..42f130eb 100644 --- a/crates/daemon/tests/fixture_contracts.rs +++ b/crates/daemon/tests/fixture_contracts.rs @@ -1,13 +1,13 @@ -use std::io::{Error, ErrorKind}; +use std::io::{Error, ErrorKind, Read, Seek}; use std::net::{Ipv4Addr, TcpListener, TcpStream}; use std::os::unix::fs::PermissionsExt; -use std::process::{Child, Output, Stdio}; +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; +use camino_tempfile::{tempdir, tempfile}; use insta::{Settings, assert_debug_snapshot}; use rustix::process::{Pid, Signal, kill_process, test_kill_process}; use state::StateError; @@ -50,6 +50,16 @@ with open(sys.argv[1], "w", encoding="utf-8") as pid_file: 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() +"#; #[expect( clippy::disallowed_types, @@ -105,6 +115,25 @@ fn fixture_command_timeout_kills_and_reaps_child() -> Result<()> { 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)?; + 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()?; @@ -519,16 +548,18 @@ fn run_fixture( } fn run_fixture_command(command: &mut FixtureCommand, timeout: Duration) -> Result { + let mut stdout = tempfile()?; + let mut stderr = tempfile()?; command .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); + .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(_)) => return fixture_output(child.wait_with_output()?), + Ok(Some(status)) => return fixture_output(status, &mut stdout, &mut stderr), Ok(None) => {} Err(error) => break error.into(), } @@ -547,14 +578,26 @@ fn run_fixture_command(command: &mut FixtureCommand, timeout: Duration) -> Resul Err(error) } -fn fixture_output(output: Output) -> Result { +fn fixture_output( + status: ExitStatus, + stdout: &mut (impl Read + Seek), + stderr: &mut (impl Read + Seek), +) -> Result { Ok(FixtureOutput { - code: output.status.code(), - stdout: String::from_utf8(output.stdout)?, - stderr: String::from_utf8(output.stderr)?, + 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}")) 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, +) From 5ec23acb45c57f99902257bb37d39572930136a4 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 16 Jul 2026 16:34:02 -0400 Subject: [PATCH 24/33] docs(test): plan fixture signal preservation --- ...07-16-daemon-fixture-review-corrections.md | 157 ++++++++++++++++-- 1 file changed, 143 insertions(+), 14 deletions(-) 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 index 01a00a6c..3f1fe8d6 100644 --- a/docs/superpowers/plans/2026-07-16-daemon-fixture-review-corrections.md +++ b/docs/superpowers/plans/2026-07-16-daemon-fixture-review-corrections.md @@ -2,41 +2,46 @@ > **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, and backpressure-safe output-capture follow-ups. +**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. +**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, manifests, or Python fixture producers. -- Python fixtures remain standard-library-only. Preserve normal exit status, stdout, stderr, custom MySQL environment variables, CLI behavior, and protocol behavior. +- 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. -- Final lifecycle tests use a per-test `PV_FIXTURE_HANDLER_STARTED` marker rather than a fixed delay. MySQL and PostgreSQL Python producers remain unchanged; Rust accepts only the exact UTF-8 contents `"started\n"` before signalling the fixture. +- 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–7 have three additional, separate commit boundaries and define the final accepted details. +- 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, and MySQL/PostgreSQL idle-client shutdown coverage. + - 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 and optional handler marker before recv. + - 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 - - Historical state: daemon request threads and an optional marker at handler entry, before `statements`/`portals` initialization and the first blocking `read_startup`. + - 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. + - 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. + - 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 @@ -897,9 +902,127 @@ git add crates/daemon/tests/snapshots/fixture_contracts__fixture_command_capture 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 Task 7 and its commit. Tasks 1–4 are historical; Tasks 5–7 define the accepted final details. +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)' @@ -907,13 +1030,19 @@ cargo nextest run -p daemon --test fixture_contracts for iteration in {1..20}; do cargo nextest run -p daemon --test fixture_contracts -E 'test(mysql_fixture_exits_after_sigterm_with_idle_client)' done +for iteration in {1..20}; do + cargo nextest run -p daemon --test fixture_contracts -E 'test(single_server_fixture_exits_after_signal_status)' +done +for iteration in {1..20}; do + cargo nextest run -p daemon --test fixture_contracts -E 'test(multi_server_fixture_exits_after_signal_status)' +done for iteration in {1..20}; do cargo nextest run -p daemon --test fixture_contracts -E 'test(postgres_fixture_exits_after_sigterm_with_idle_client)' 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; 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")' +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")' git diff --check c36e9763ad3120c98b9c9df4d141b3052c348806..HEAD git diff --name-only c36e9763ad3120c98b9c9df4d141b3052c348806..HEAD ! rg --files crates/daemon/tests/snapshots -g '*.snap.new' -g '*.snap.tmp' @@ -921,4 +1050,4 @@ git ls-files --others --exclude-standard git status --short ~~~ -Expected: the focused new regressions and full fixture-contract suite pass; each lifecycle test passes 20 times; formatting, locked workspace Clippy, and the locked workspace suite pass; all four Python fixtures parse; the follow-up artifact diff contains only this correction plan, `fixture_contracts.rs`, and the two new snapshots with no whitespace errors or temporary snapshot artifacts; and both untracked-file and status checks are empty. The final behavior retains bounded cleanup, includes the test-only scheduling margin, accepts only complete handler markers, and captures 2 MiB of UTF-8 output from each stream without pipe backpressure. +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; the follow-up artifact diff contains only this correction plan, `fixture_contracts.rs`, the six listed fixtures, and the two new snapshots with no whitespace errors or temporary snapshot artifacts; and both untracked-file and status checks are empty. 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. From 5ce9e12fa8f21ec3c41915b1d1c16bd0f5b71a6b Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 16 Jul 2026 16:51:42 -0400 Subject: [PATCH 25/33] fix(daemon): make postgres fixture shutdown deterministic --- .../managed-resources/postgres.py | 14 +- crates/daemon/tests/fixture_contracts.rs | 122 ++++++++++++++++++ 2 files changed, 133 insertions(+), 3 deletions(-) diff --git a/crates/daemon/test-fixtures/managed-resources/postgres.py b/crates/daemon/test-fixtures/managed-resources/postgres.py index a4dfc5bf..c7f56fa7 100644 --- a/crates/daemon/test-fixtures/managed-resources/postgres.py +++ b/crates/daemon/test-fixtures/managed-resources/postgres.py @@ -285,14 +285,22 @@ class Server(socketserver.ThreadingMixIn, socketserver.TCPServer): server = Server((host, port), Handler) +shutdown_requested = threading.Event() +shutdown_thread = None def stop(_signum, _frame): - server.shutdown() + 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) -threading.Thread(target=server.serve_forever, daemon=True).start() -signal.pause() +server.serve_forever() +if shutdown_thread is not None: + shutdown_thread.join() +server.server_close() diff --git a/crates/daemon/tests/fixture_contracts.rs b/crates/daemon/tests/fixture_contracts.rs index 42f130eb..c33b7728 100644 --- a/crates/daemon/tests/fixture_contracts.rs +++ b/crates/daemon/tests/fixture_contracts.rs @@ -1,6 +1,7 @@ use std::io::{Error, ErrorKind, Read, Seek}; use std::net::{Ipv4Addr, TcpListener, TcpStream}; use std::os::unix::fs::PermissionsExt; +use std::os::unix::process::ExitStatusExt; use std::process::{Child, ExitStatus, Stdio}; use std::thread; use std::time::{Duration, Instant}; @@ -17,6 +18,7 @@ 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"), @@ -60,6 +62,46 @@ sys.stdout.flush() sys.stderr.write(contents) sys.stderr.flush() "#; +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, @@ -219,6 +261,86 @@ fn postgres_fixture_exits_after_sigterm_with_idle_client() -> Result<()> { 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.success() && 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 fixture_handler_marker_requires_complete_contents() -> Result<()> { let tempdir = tempdir()?; From 5945a088bc60f85f8f2b0ec7c3bbbe5bbe009bef Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 16 Jul 2026 22:50:03 -0400 Subject: [PATCH 26/33] fix(daemon): preserve single-server fixture signal status --- .../test-fixtures/managed-resources/mysql.py | 23 +- .../managed-resources/postgres.py | 9 +- .../managed-resources/redis-server.py | 15 +- crates/daemon/tests/fixture_contracts.rs | 205 +++++++++++++++++- 4 files changed, 237 insertions(+), 15 deletions(-) diff --git a/crates/daemon/test-fixtures/managed-resources/mysql.py b/crates/daemon/test-fixtures/managed-resources/mysql.py index c630d925..edbefe99 100644 --- a/crates/daemon/test-fixtures/managed-resources/mysql.py +++ b/crates/daemon/test-fixtures/managed-resources/mysql.py @@ -3,6 +3,7 @@ import signal import socketserver import sys +import threading arguments = list(sys.argv[1:]) @@ -48,12 +49,28 @@ class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): daemon_threads = True -def stop(_signum, _frame): - sys.exit(0) +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 = TcpServer(("127.0.0.1", int(port)), Handler) 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.py b/crates/daemon/test-fixtures/managed-resources/postgres.py index c7f56fa7..12778281 100644 --- a/crates/daemon/test-fixtures/managed-resources/postgres.py +++ b/crates/daemon/test-fixtures/managed-resources/postgres.py @@ -287,11 +287,13 @@ class Server(socketserver.ThreadingMixIn, socketserver.TCPServer): server = Server((host, port), Handler) shutdown_requested = threading.Event() shutdown_thread = None +received_signal = None -def stop(_signum, _frame): - global shutdown_thread +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() @@ -304,3 +306,6 @@ def stop(_signum, _frame): 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 index 416151da..0c2f327f 100644 --- a/crates/daemon/test-fixtures/managed-resources/redis-server.py +++ b/crates/daemon/test-fixtures/managed-resources/redis-server.py @@ -53,13 +53,18 @@ class RedisServer(socketserver.ThreadingMixIn, socketserver.TCPServer): shutdown_requested = threading.Event() +shutdown_thread = None +received_signal = None -def stop(_signum, _frame): +def stop(signum, _frame): + global received_signal, shutdown_thread if shutdown_requested.is_set(): return + received_signal = signum shutdown_requested.set() - threading.Thread(target=server.shutdown, daemon=True).start() + shutdown_thread = threading.Thread(target=server.shutdown, daemon=True) + shutdown_thread.start() port, data_dir = redis_config(sys.argv[1:]) @@ -70,3 +75,9 @@ def stop(_signum, _frame): 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/tests/fixture_contracts.rs b/crates/daemon/tests/fixture_contracts.rs index c33b7728..70ce8e4d 100644 --- a/crates/daemon/tests/fixture_contracts.rs +++ b/crates/daemon/tests/fixture_contracts.rs @@ -1,7 +1,7 @@ use std::io::{Error, ErrorKind, Read, Seek}; use std::net::{Ipv4Addr, TcpListener, TcpStream}; use std::os::unix::fs::PermissionsExt; -use std::os::unix::process::ExitStatusExt; +use std::os::unix::process::{CommandExt, ExitStatusExt}; use std::process::{Child, ExitStatus, Stdio}; use std::thread; use std::time::{Duration, Instant}; @@ -10,7 +10,8 @@ use anyhow::{Result, anyhow, bail}; use camino::Utf8Path; use camino_tempfile::{tempdir, tempfile}; use insta::{Settings, assert_debug_snapshot}; -use rustix::process::{Pid, Signal, kill_process, test_kill_process}; +use rustix::io::Errno; +use rustix::process::{Pid, Signal, kill_process, kill_process_group, test_kill_process}; use state::StateError; const FIXTURE_COMMAND_TIMEOUT: Duration = Duration::from_secs(3); @@ -32,6 +33,10 @@ 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" @@ -116,6 +121,39 @@ struct FixtureOutput { 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, + } + } +} + #[test] fn fixture_command_timeout_kills_and_reaps_child() -> Result<()> { let tempdir = tempdir()?; @@ -341,6 +379,21 @@ fn postgres_fixture_shutdown_is_deterministic_after_sigterm() -> Result<()> { 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 fixture_handler_marker_requires_complete_contents() -> Result<()> { let tempdir = tempdir()?; @@ -653,6 +706,111 @@ fn render_rustfs_fixture(reject_s3: bool) -> Result { 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 materialize_fixture(path: &Utf8Path, source: &str) -> Result<()> { state::fs::write_sensitive_file(path, source)?; set_executable(path) @@ -725,9 +883,12 @@ fn process_pid(pid: u32) -> Result { Pid::from_raw(raw_pid).ok_or_else(|| anyhow!("invalid process id {raw_pid}")) } -fn wait_for_loopback_ports(ports: [u16; 2], timeout: Duration) -> Result<[bool; 2]> { +fn wait_for_loopback_ports( + ports: [u16; PORT_COUNT], + timeout: Duration, +) -> Result<[bool; PORT_COUNT]> { let deadline = Instant::now() + timeout; - let mut readiness = [false; 2]; + let mut readiness = [false; PORT_COUNT]; loop { for (index, port) in ports.into_iter().enumerate() { @@ -740,7 +901,7 @@ fn wait_for_loopback_ports(ports: [u16; 2], timeout: Duration) -> Result<[bool; return Ok(readiness); } if Instant::now() >= deadline { - bail!("timed out waiting for fake Mailpit ports {ports:?}; readiness: {readiness:?}"); + bail!("timed out waiting for fixture ports {ports:?}; readiness: {readiness:?}"); } thread::sleep(Duration::from_millis(10)); @@ -781,20 +942,48 @@ fn wait_for_handler_marker(path: &Utf8Path, timeout: Duration) -> Result<()> { } 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 child.try_wait()?.is_some() { - return Ok(true); + if let Some(status) = child.try_wait()? { + return Ok(Some(status)); } if Instant::now() >= deadline { - return Ok(false); + return Ok(None); } thread::sleep(FIXTURE_COMMAND_POLL_INTERVAL); } } +fn kill_process_group_and_reap_child(child: &mut Child, process_group: Pid) -> Result<()> { + 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, From 3023efce187bab2c887d1ed523b7d48afdb77b44 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 16 Jul 2026 23:08:14 -0400 Subject: [PATCH 27/33] fix(daemon): avoid stale fixture process-group cleanup --- crates/daemon/tests/fixture_contracts.rs | 73 ++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/crates/daemon/tests/fixture_contracts.rs b/crates/daemon/tests/fixture_contracts.rs index 70ce8e4d..23574879 100644 --- a/crates/daemon/tests/fixture_contracts.rs +++ b/crates/daemon/tests/fixture_contracts.rs @@ -67,6 +67,22 @@ 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 @@ -394,6 +410,59 @@ fn single_server_fixture_exits_after_signal_status() -> Result<()> { 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()?; @@ -961,6 +1030,10 @@ fn wait_for_child_status(child: &mut Child, timeout: Duration) -> Result 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), From 5da0ebcef38bb04933e55d9cf1443b195d8f399c Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 16 Jul 2026 23:39:23 -0400 Subject: [PATCH 28/33] fix(daemon): preserve multi-server fixture signal status --- .../managed-resources/fake-mailpit.py | 39 ++++- .../managed-resources/mailpit.py | 33 ++++- .../managed-resources/rustfs.py.in | 30 +++- crates/daemon/tests/fixture_contracts.rs | 137 ++++++++++++++++++ 4 files changed, 224 insertions(+), 15 deletions(-) diff --git a/crates/daemon/test-fixtures/managed-resources/fake-mailpit.py b/crates/daemon/test-fixtures/managed-resources/fake-mailpit.py index 2d8ea5ce..03e6ee0e 100644 --- a/crates/daemon/test-fixtures/managed-resources/fake-mailpit.py +++ b/crates/daemon/test-fixtures/managed-resources/fake-mailpit.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import http.server +import os import signal import socketserver import sys @@ -20,18 +21,44 @@ class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): daemon_threads = 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, ) +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, daemon=True).start() -dashboard.serve_forever() +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.py b/crates/daemon/test-fixtures/managed-resources/mailpit.py index 7b813884..aba95bbe 100644 --- a/crates/daemon/test-fixtures/managed-resources/mailpit.py +++ b/crates/daemon/test-fixtures/managed-resources/mailpit.py @@ -65,14 +65,39 @@ class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): host_port(listen), http.server.SimpleHTTPRequestHandler, ) +shutdown_requested = threading.Event() +shutdown_thread = None +received_signal = None -def stop(_signum, _frame): - sys.exit(0) +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, daemon=True).start() -dashboard.serve_forever() +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/rustfs.py.in b/crates/daemon/test-fixtures/managed-resources/rustfs.py.in index 3f4dafc2..104b7f42 100644 --- a/crates/daemon/test-fixtures/managed-resources/rustfs.py.in +++ b/crates/daemon/test-fixtures/managed-resources/rustfs.py.in @@ -137,18 +137,38 @@ 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 stop(_signum, _frame): +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() - threading.Thread(target=api.shutdown, daemon=True).start() + 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, daemon=True).start() -api.serve_forever() -console.shutdown() +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/tests/fixture_contracts.rs b/crates/daemon/tests/fixture_contracts.rs index 23574879..6634e03d 100644 --- a/crates/daemon/tests/fixture_contracts.rs +++ b/crates/daemon/tests/fixture_contracts.rs @@ -13,6 +13,7 @@ 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); @@ -170,6 +171,31 @@ impl SingleServerFixture { } } +#[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()?; @@ -410,6 +436,21 @@ fn single_server_fixture_exits_after_signal_status() -> Result<()> { 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()?; @@ -880,6 +921,102 @@ fn assert_single_server_fixture_exits_after_signal( 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) From 1c79637b1d084989672da2d43f134d5ce770653e Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 16 Jul 2026 23:55:33 -0400 Subject: [PATCH 29/33] test(daemon): clean up RustFS allocation failure runtime --- crates/daemon/src/managed_resources/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/daemon/src/managed_resources/tests.rs b/crates/daemon/src/managed_resources/tests.rs index 22e6dbac..68901619 100644 --- a/crates/daemon/src/managed_resources/tests.rs +++ b/crates/daemon/src/managed_resources/tests.rs @@ -1726,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(), From f932b9fbd3f1b919589d9e3876f01c39e3598f52 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 16 Jul 2026 23:55:52 -0400 Subject: [PATCH 30/33] refactor(gateway): close fixture config file explicitly --- crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py b/crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py index 72ac28a7..c282595d 100644 --- a/crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py +++ b/crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py @@ -9,7 +9,8 @@ signal.signal(signal.SIGUSR1, signal.SIG_IGN) -config = open(sys.argv[1], encoding="utf-8").read() +with open(sys.argv[1], encoding="utf-8") as config_file: + config = config_file.read() def required(pattern): From 386b7f9fc276a728bd77a9c08d2a9d7f7824f35a Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 17 Jul 2026 00:14:55 -0400 Subject: [PATCH 31/33] test(daemon): avoid timeout fixture startup race --- crates/daemon/tests/fixture_contracts.rs | 38 +++++++++++------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/crates/daemon/tests/fixture_contracts.rs b/crates/daemon/tests/fixture_contracts.rs index 6634e03d..19baca17 100644 --- a/crates/daemon/tests/fixture_contracts.rs +++ b/crates/daemon/tests/fixture_contracts.rs @@ -48,13 +48,7 @@ const RUSTFS_FIXTURE_TEMPLATE: &str = include_str!(concat!( )); const RUSTFS_REJECT_S3_SENTINEL: &str = "__PV_REJECT_S3__"; 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() "#; @@ -200,17 +194,15 @@ impl MultiServerFixture { 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"); let timeout = Duration::from_secs(1); 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()); + command.current_dir(tempdir.path()); let started_at = Instant::now(); - let error = match run_fixture_command(&mut command, timeout) { + 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, }; @@ -228,11 +220,10 @@ fn fixture_command_timeout_kills_and_reaps_child() -> Result<()> { "fixture command timeout exceeded its cleanup deadline" ); - 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()); + 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(()) } @@ -246,7 +237,7 @@ fn fixture_command_captures_verbose_output_without_pipe_backpressure() -> Result let mut command = FixtureCommand::new(fixture.as_std_path()); command.current_dir(tempdir.path()); - let output = run_fixture_command(&mut command, FIXTURE_COMMAND_TIMEOUT)?; + 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( @@ -582,7 +573,7 @@ os.makedirs = record_makedirs .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)?; + run_fixture_command(&mut empty_data_dir_command, FIXTURE_COMMAND_TIMEOUT, None)?; assert_fixture_snapshot( tempdir.path(), @@ -1030,10 +1021,14 @@ fn run_fixture( let mut command = FixtureCommand::new(path.as_std_path()); command.args(arguments).current_dir(current_dir); - run_fixture_command(&mut command, FIXTURE_COMMAND_TIMEOUT) + run_fixture_command(&mut command, FIXTURE_COMMAND_TIMEOUT, None) } -fn run_fixture_command(command: &mut FixtureCommand, timeout: Duration) -> Result { +fn run_fixture_command( + command: &mut FixtureCommand, + timeout: Duration, + child_pid: Option<&mut Option>, +) -> Result { let mut stdout = tempfile()?; let mut stderr = tempfile()?; command @@ -1041,6 +1036,9 @@ fn run_fixture_command(command: &mut FixtureCommand, timeout: Duration) -> Resul .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 { From 3058dc140d0ddc6a098f20f5c7d72f17a4781b7f Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 17 Jul 2026 00:44:29 -0400 Subject: [PATCH 32/33] test(daemon): require injected postgres signal status --- crates/daemon/tests/fixture_contracts.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/daemon/tests/fixture_contracts.rs b/crates/daemon/tests/fixture_contracts.rs index 19baca17..d2693844 100644 --- a/crates/daemon/tests/fixture_contracts.rs +++ b/crates/daemon/tests/fixture_contracts.rs @@ -397,7 +397,7 @@ fn postgres_fixture_shutdown_is_deterministic_after_sigterm() -> Result<()> { thread::sleep(FIXTURE_COMMAND_POLL_INTERVAL); }; - if !status.success() && status.signal() != Some(Signal::TERM.as_raw()) { + if status.signal() != Some(Signal::TERM.as_raw()) { bail!("PostgreSQL fixture exited unexpectedly after injected SIGTERM: {status}"); } From 244b06f2cc02a13b2a97f7977c0bff3b24a5ffe3 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 17 Jul 2026 00:49:38 -0400 Subject: [PATCH 33/33] docs(test): harden daemon fixture final verification --- ...07-16-daemon-fixture-review-corrections.md | 65 ++++++++++++++++--- 1 file changed, 55 insertions(+), 10 deletions(-) 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 index 3f1fe8d6..06cb684a 100644 --- a/docs/superpowers/plans/2026-07-16-daemon-fixture-review-corrections.md +++ b/docs/superpowers/plans/2026-07-16-daemon-fixture-review-corrections.md @@ -554,11 +554,15 @@ python3 -c 'import ast, pathlib; paths = [pathlib.Path(path) for path in ["crate cargo fmt --all --check cargo clippy -p daemon --test fixture_contracts --locked -- -D warnings git diff --check -for iteration in {1..20}; do +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 -for iteration in {1..20}; do +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 ~~~ @@ -1027,27 +1031,68 @@ Run final verification only after Tasks 8–10 and their commits. Tasks 1–4 ar ~~~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 -for iteration in {1..20}; do +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 -for iteration in {1..20}; do +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 -for iteration in {1..20}; do +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 -for iteration in {1..20}; do +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 -git diff --name-only c36e9763ad3120c98b9c9df4d141b3052c348806..HEAD ! rg --files crates/daemon/tests/snapshots -g '*.snap.new' -g '*.snap.tmp' -git ls-files --others --exclude-standard -git status --short +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; the follow-up artifact diff contains only this correction plan, `fixture_contracts.rs`, the six listed fixtures, and the two new snapshots with no whitespace errors or temporary snapshot artifacts; and both untracked-file and status checks are empty. 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. +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.