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

13 KiB

id, name, status, depends_on, scope, risk, impact, level, tags
id name status depends_on scope risk impact level tags
acme-event-loop-test ACME event-loop coverage — fake-directory integration test (U-1) completed
coverage-cheap-closes
moderate medium component implementation
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

  • 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)
  • The test performs no real network I/O (binds localhost only)
  • cargo test --features acme, --all-features green; default build unaffected
  • clippy/fmt/doc green

Acceptance Criteria

  • 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)
  • No test awaits the event loop's JoinHandle (it never resolves)
  • 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_directoryEventOk::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_fileCertCacheLoad + 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.