Files
alktls/tasks/acme-event-loop-test.md
T
glm-5.3-flash f21b62c4b4 task: ACME event-loop coverage — fake-directory integration test (U-1)
- tests/acme_event_loop.rs: five integration tests drive the crate's
  real ACME path and assert the spawned loop's logged events through a
  hand-rolled tracing test subscriber: AccountCacheStore (eager, pre-
  network), the Order warn via a plain-HTTP fake directory stub on an
  ephemeral 127.0.0.1 port, DeployedCachedCert from a pre-seeded
  PKCS#8-key-first PEM chain, CertCacheLoad/AccountCacheLoad errors +
  AccountCacheStore warn from an ENOTDIR-poisoned cache path, and
  CachedCertParse from a corrupt cached PEM. Event collection is
  timeout-bounded, never JoinHandle-bounded (rustls-acme 0.12.1's
  stream never terminates — verified against the vendored sources).
- src/server.rs: delete the unreachable debug!("ACME: state machine
  ended") line (work item 2: it cannot execute under the
  never-terminating stream).
- DirCache's deterministic cache file names (SHA256 over element+NUL*
  + directory URL, base64url-nopad) are recomputed in-test via the
  sha2 dependency + a new base64 dev-dependency.
- Explicitly not-covered (work item 4): DeployedNewCert, CertCacheStore
  ok+warn, NewCertParse — reachable only through a successful ACME
  order (a full fake CA), out of scope at this effort level.

Verification: cargo llvm-cov --all-features line coverage 99.21%
(from 98.26%), server.rs 96.08% -> 98.85% lines / 100% functions;
cargo test (93 passed), --all-features (123 passed), --features acme;
clippy (default + all-features, -D warnings); fmt; doc. The event-loop
suite is stable across five consecutive runs.
2026-09-12 05:35:46 +00:00

257 lines
13 KiB
Markdown

---
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/<name>` 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.