--- id: acme-event-loop-test name: ACME event-loop coverage — fake-directory integration test (U-1) status: completed depends_on: [coverage-cheap-closes] scope: moderate risk: medium impact: component level: implementation tags: [tests, acme, review-001, u1] --- ## Description The ACME event-loop body (src/server.rs:94-136) is unreachable by tests: 23 uncovered lines — every `EventOk`/`EventError` arm, the `debug`/`warn`/`error` mapping. The `acme_lifecycle` tests construct the config and assert spawn + ALPN + resolver wiring; the spawned task runs against a blackhole URL and its events are never observed. A refactor that drops or mislevels an arm (e.g. `EventError::Order` warn → error) lands green. **Correction to this task (2026-09-12, verified against the vendored rustls-acme 0.12.1 sources) — the review's §U-1 termination premise is wrong, and so was this task's original work item 3.** `rustls-acme 0.12.1`'s `Stream for AcmeState` never terminates: `poll_next` is `Poll::Ready(Some(ready!(self.poll_next_infinite(cx))))` (state.rs:407-412) — it can never yield `None`. Order errors enter an exponential backoff (`Timer::after(1 << backoff_cnt)`, capped at 2^16 seconds — state.rs:371-375) and retry forever. Two consequences: 1. **There is no "loop ends when the stream ends"** — the `while let Some(event) = state.next().await` loop in `new_acme` never exits on its own. Do NOT assert that the `JoinHandle` resolves; such a test hangs until timeout. The original item 3 is removed. 2. **`src/server.rs:135` ("ACME: state machine ended") is unreachable dead code under this dependency.** It cannot be covered by any test through the real stream. Either delete the line in this task (and note the removal) or explicitly accept it as uncovered — do not leave an implementer to burn hours rediscovering that it is unreachable. (The review's §U-1 framing "if upstream changes the stream's termination semantics, nothing notices" described a termination semantics that does not exist.) The acme feature's only runtime surface is this loop; it deserves one real integration test. ## Work 1. Drive a `DirCache`-backed `AcmeState` against a local fake directory: a stub HTTP server (tokio, `std::net::TcpListener` on an ephemeral port — no new deps) that serves a directory JSON with no usable endpoints, forcing the error path through the *real* event stream. (Mechanically verified: rustls-acme's HTTP layer, `async_web_client` 0.6.3, is plain `async_net::TcpStream` for `http://` URLs and self-drives via async-io 2's reactor thread — it works under a tokio runtime, and a plain-HTTP localhost stub is reachable.) 2. Assert the log events fire. Two shapes, pick one (or both): - `tracing` test subscriber capturing the `warn!`/`error!` events (tracing-subscriber with a test layer — add as dev-dependency only), or - extract the event-mapping match into a helper fn taking the event, returning (level, message-class), and test the helper directly (no subscriber needed; cheaper, but the loop body itself stays uncovered — prefer the subscriber shape if the dev dep is acceptable). 3. **Bound event collection by time, never by stream end**: collect events under `tokio::time::timeout` (or a bounded channel + a drop-with-timeout teardown) and assert on what arrived. The spawned task never resolves its `JoinHandle` — see the correction above — so any "await the handle" shape hangs. Note: with `#[tokio::test]` (current-thread runtime) the spawned loop runs on the test thread, so a thread-local default tracing subscriber is visible to it; keep the test single-threaded or the subscriber captures nothing. 4. Which arms are reachable, per rustls-acme 0.12.1's state machine (state.rs `poll_next_infinite` / `order`): - `EventError::Order` (warn) — fires against the erroring stub. - `EventOk::AccountCacheStore` — the first event, generated before any network I/O (the account key is generated and stored eagerly, state.rs:377-395); fires even against a dead address. - `EventError::AccountCacheLoad` / `CertCacheLoad` / `CachedCertParse` (error) and `EventOk::DeployedCachedCert` / `CertCacheStore` (ok) — reached by pre-seeding the `DirCache` directory. The cache file names are deterministic (`caches/dir.rs:38-53`): `cached_account_{base64url(SHA256( contact-els…directory_url))}` and `cached_cert_{base64url( SHA256(domain-els…directory_url))}`. A corrupt pre-seeded file gives the Load/Parse error arms; a valid PEM (an ECDSA-P256 chain, PKCS#8 key first — `parse_cert` wants key-then-cert-chain, state.rs:192-214) gives the deployed/stored ok arms. The PEM can be generated with rcgen in-test (rcgen is already a dependency on the acme feature path via the server's self-signed helper — it is a [dependencies] member). - `EventOk::DeployedNewCert` / `EventError::NewCertParse` — reachable only through a *successful* order, i.e. a full fake ACME CA (newNonce/newAccount/newOrder/authorizations/finalize/ certificate endpoints). Out of scope at this effort level; mark explicitly not-covered rather than silent. 5. Keep `TlsError` out of it: per ADR-002/ADR-006, ACME runtime errors are stream events, not error variants — the test asserts events, never a `TlsError`. ## Verification - [x] server.rs 94-136 covered under `cargo llvm-cov --all-features` (line 135 either deleted or explicitly accepted as uncovered, per work item 2's second consequence) - [x] The test performs no real network I/O (binds localhost only) - [x] `cargo test --features acme`, `--all-features` green; default build unaffected - [x] clippy/fmt/doc green ## Acceptance Criteria - [x] Every reachable arm per work item 4 is executed by a test (`Order` warn, `AccountCacheStore`, the Load/Parse error arms, `DeployedCachedCert`/`CertCacheStore`); `DeployedNewCert`/ `NewCertParse` are explicitly marked not-covered (full fake CA needed — out of scope) - [x] No test awaits the event loop's `JoinHandle` (it never resolves) - [x] The ACME feature's runtime surface is no longer refactor-fragile ## References - docs/reviews/001-implementation-review.md §U-1, Part C (§U-1's termination premise is corrected — see this task's Description) - src/server.rs:64-145 (`new_acme` + the spawned loop) - tests/acme_lifecycle.rs (the existing construction-level tests) - ADR-006 (acme feature layout), ADR-002 (`TlsError` scope boundary) - rustls-acme 0.12.1 sources (vendored): `state.rs:407-412` (the never-ending `Stream` impl), `state.rs:371-375` (order-error backoff + retry), `state.rs:192-214` (`parse_cert` — key first, then ≥2 PEMs), `caches/dir.rs:38-53` (the deterministic cache file names) ## Notes > Agent fills this during implementation. Work notes (2026-09-12, verified against the vendored rustls-acme 0.12.1 sources before implementation): - **Scope correction to work item 4 (verified, mechanically)**: `EventOk::CertCacheStore` / `EventError::CertCacheStore` are *not* pre-seed-reachable. `DirCache::store_cert` is invoked only from `process_cert`'s **new-cert** branch (state.rs:219-227 `early_action = store_cert`); a pre-seeded cache hit takes the `cached = true` branch, which returns `DeployedCachedCert` before any store. So `DeployedNewCert` / `CertCacheStore` (ok+warn) / `NewCertParse` are all new-cert-path arms — the same full-fake-CA class the task already marks out of scope. The acceptance criterion's "…`DeployedCachedCert`/`CertCacheStore`" is satisfied as: `CertCacheStore` is covered for the *warn* arm (below), its ok arm belongs to the not-covered set. - **Arm coverage achieved** (each via the real event stream, asserted through a tracing test subscriber — work item 2's preferred shape, hand-rolled on `tracing` itself, no tracing-subscriber dev-dep): - `EventOk::AccountCacheStore` (debug) — dead-directory test (`http://127.0.0.1:9`); the account key is generated and stored eagerly before any network I/O (state.rs:377-395). The test also verifies the file exists under the recomputed deterministic name. - `EventError::Order` (warn) — plain-HTTP fake directory stub (`std::net::TcpListener`, ephemeral 127.0.0.1 port) serving `{"newNonce": "/"}` — discover fails to deserialize (the required camelCase endpoints are missing), the order future errors, the loop logs the warn. Verified the level is WARN, not ERROR. - `EventError::CertCacheLoad` + `AccountCacheLoad` (errors) + `EventError::AccountCacheStore` (warn) — `cache_dir` poisoned as a *regular file*: DirCache reads of `cache_dir/` fail with ENOTDIR (an error, not NotFound-miss) and `create_dir_all` in the store path fails — three arms in one scenario. - `EventError::CachedCertParse` (error) — a real cache file under the recomputed name holding a corrupt PEM (load succeeds, parse fails). - `EventOk::DeployedCachedCert` (debug) — a pre-seeded valid PEM (rcgen ECDSA-P256 self-signed, PKCS#8 key PEM first, then cert — `parse_cert`'s required order). Zero network I/O. - **Work item 2, second consequence: the dead line was deleted.** `debug!("ACME: state machine ended")` (former server.rs:135) is unreachable under rustls-acme 0.12.1's never-terminating stream; removing it also removed the dead-code trap. Note llvm-cov still reports the closure's closing `});` (now server.rs:156) uncovered — structurally uncoverable for the same reason (the spawned closure never returns); same accepted class. - **Remaining uncovered in the loop (all new-cert-path, explicitly not-covered per work item 4)**: `DeployedNewCert` (125), `CertCacheStore` ok (128), `CertCacheStore` warn (139-140), `NewCertParse` (151-152) — reachable only through a *successful* order (a full fake ACME CA: newNonce/newAccount/newOrder/ authorizations/finalize/certificate). Documented here, not silent. - **Mechanical notes for future maintainers**: the fake directory is served by a dedicated OS thread (one accept, then done); the client side is rustls-acme's own `async_web_client` (plain `async_net::TcpStream` for `http://`), which self-drives via async-io's reactor thread — it works under `#[tokio::test]`'s current-thread runtime without any tokio networking features. The subscriber is installed with `tracing::subscriber::set_default` (thread-local default), visible to the spawned loop because `#[tokio::test]` polls the task on the test thread. `Timer::after` on the huge renewal wait is safe: async-io 2.6 maps `Instant::checked_add` overflow to `Timer::never` (no panic). - Timing: each scenario asserts on captured events after a `tokio::time::timeout`-bounded poll loop (10s cap, events arrive in ~ms); the suite is stable across repeated runs. ## Summary > Agent fills this on completion. Closed the ACME event-loop coverage gap (review 001 §U-1) with `tests/acme_event_loop.rs` — five integration tests driving the crate's real ACME path (`TlsServerConfig::new` on an `Acme` identity) and asserting the loop's logged events through a hand-rolled tracing test subscriber: - `event_loop_logs_account_store_against_a_dead_directory` — `EventOk::AccountCacheStore` fires before any network I/O; also verifies the deterministic `DirCache` file name on disk. - `event_loop_logs_order_warn_against_a_directory_with_no_usable_endpoints` — a plain-HTTP stub on an ephemeral 127.0.0.1 port serves an unparseable directory document; the `EventError::Order` warn fires through the real event stream (real HTTP, real deserialize failure). - `event_loop_deploys_a_valid_pre_seeded_cached_cert` — a valid pre-seeded PEM chain (PKCS#8 key first) drives `EventOk::DeployedCachedCert` with zero network I/O. - `event_loop_logs_cert_cache_load_errors_when_the_cache_dir_is_a_file` — `CertCacheLoad` + `AccountCacheLoad` errors and the `AccountCacheStore` warn from one ENOTDIR-poisoned cache path. - `event_loop_logs_cached_cert_parse_error_on_a_corrupt_cert_cache` — a corrupt cached PEM surfaces `CachedCertParse` with the underlying error attached. Event collection is bounded by wall-clock timeout, never by the spawned task's `JoinHandle` (rustls-acme 0.12.1's stream never terminates). No test awaits the handle. The unreachable `debug!("ACME: state machine ended")` line was deleted per work item 2. Only non-test source change: that dead-line deletion. Dev-deps: added `base64` (for the in-test recomputation of `DirCache`'s deterministic file names). Verification: `cargo llvm-cov --all-features` line coverage **99.21%** crate-wide (from 98.26%); server.rs 96.08% → **98.85%** lines / 100.00% functions. The 7 remaining uncovered lines in server.rs are exactly the four new-cert-path arms (125, 128, 139-140, 151-152 — need a full fake ACME CA, out of scope per work item 4) plus the closure's closing `});` (156 — structurally uncoverable, the spawned task never returns). `cargo test` (93 passed), `cargo test --all-features` (123 passed), `cargo test --features acme`, clippy (default + all-features, `-D warnings`), `cargo fmt --check`, `cargo doc --no-deps` — all green. The event-loop suite is stable across five consecutive runs.