Skip to content

Harness: no-write guard and stale-container preflight; retire live held-write scenario - #21

Merged
abrainwood merged 17 commits into
mainfrom
feat/held-write-harness
Sep 20, 2026
Merged

abrainwood merged 17 commits into
mainfrom
feat/held-write-harness

Conversation

@abrainwood

Copy link
Copy Markdown
Owner

Closes #18 (forced held-write harness scenario proving #12 early wake).

What

The forced held-write scenario was built, used to prove #12 (wake held writes on MQTT reconnect) live twice, then retired: its restore step left the real chlorinator at swc 0 for ~6h on 2026-09-19 (see the incident comment on #18). #12's early wake now rests on unit coverage only, per Andrew's call.

What this PR lands:

  • No-write guard for the harness. _ha_request allows GET plus POST to the exact config-entry reload path; anything else raises before any network call. Static tests: the forbidden HTTP names (urlopen, build_opener, Request, ...) appear only inside _ha_request anywhere in the module, and no alternate HTTP stack is imported (a denylist, so unrelated imports don't trip it). These catch accidental reintroduction, not deliberate evasion. Container.exec is unguarded, and the README says so.
  • Stale dev-container preflight. assert_mounted_code_is_loaded() fails if the container mounts a different checkout, or started before the newest mounted file changed. A config-entry reload doesn't re-import modules, so this once ran pre-Wake writes held behind a REST cooldown on MQTT reconnect #12 code and looked like a prod bug.
  • -p exo_pool on every compose call (Makefile dev/stop, scripts/dev-setup.py), so running from a worktree reuses the existing dev project and config volume instead of creating an empty one.
  • Early-wake unit test bounded. test_reconnect_callback_wakes_a_held_write_before_the_cooldown_ends gets @pytest.mark.timeout(5). Without the wake, the mutant used to die only at pytest's 30s global timeout; now it dies in ~5s. asyncio.wait_for can't be used here, because the fake_clock fixture patches time.monotonic, which also drives the loop clock. This is the one change outside the harness files.

custom_components/ is untouched.

Verification

  • 290 tests pass.
  • Both reviewers mutation-tested the guards: an aliased urlopen import, a lambda, build_opener, from http import client, an async def, and a widened reload regex all fail.
  • The early-wake mutant ({sleep_task, event_task} -> {sleep_task}) is killed.

🤖 Generated with Claude Code

https://claude.ai/code/session_019FJHinb3pMJEwTKrnMXMcG

abrainwood and others added 17 commits September 19, 2026 16:48
Issue #18: a 7-day soak never forced a write into its post-write
cooldown at the same moment MQTT was down, so #12's hold-then-wake
branch had no real-world exercise. scenario_forced_held_write blocks
MQTT via an allow-list inversion (resolve the REST host inside the
container, drop all outbound :443, re-allow just that host) instead
of a peer-address or blanket-port block - REST and MQTT share :443,
so a blanket block would also stop the forced-REST write from
landing, and a peer-address block doesn't hold against AWS IoT's
rotating reconnect pool. The second write runs on a background
thread (its own service call blocks inside HA until the write
resolves, so it can't run on the main thread) while the scenario
polls for "held behind cooldown" before unblocking, then confirms
"woken early by MQTT reconnect" landed before the cooldown deadline.

Chasing the first real run down turned up a second problem: the dev
container had been running since before #12/#9 landed, and config-
entry reloads don't re-import Python modules, so it was silently
executing pre-fix code - a false negative that looked exactly like
a production bug. assert_mounted_code_is_loaded() now runs before
any scenario and fails loudly if the container's StartedAt predates
the newest mtime under its mounted custom_components/exo_pool/*.py.

Signed-off-by: Andrew Brainwood <andrew.brainwood@gmail.com>
…nd item 10)

Lets the outage-reconnect harness derive its matching regexes from these
constants instead of hand-copied literals, so they can never silently
drift from what api.py actually logs. No change to arguments or emitted
text - verified against the full custom_components suite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FJHinb3pMJEwTKrnMXMcG
Signed-off-by: Andrew Brainwood <andrew.brainwood@gmail.com>
Addresses both reviewers' combined fix round on 909a0ab, item by item:

- Ctrl+C ordering: the network unblock is now deferred onto the shared
  teardown right after the restore, so a signal mid-scenario unblocks
  before restoring (item 1).
- The scenario's own finally now re-reads and numerically verifies the
  restore, failing and naming the value on mismatch (item 2).
- Write #1 now points away from the 100 upper bound; write #2 targets the
  original value, not original-1 (item 3).
- The stale-container guard also checks the mount source resolves to this
  repo's checkout, names both paths on mismatch, drops the unused runner
  param, and prints ISO timestamps (item 4).
- SERVICE_CALL_TIMEOUT and the held-wait timeout now derive from
  POST_WRITE_COOLDOWN_SECONDS plus named margins (item 5).
- URLError, TimeoutError and the guard's RuntimeError now end as FAIL
  lines in main()'s summary; a non-numeric entity state raises a clear
  ScenarioFailure (item 6).
- Early-wake failures now name whether/when "MQTT connection resumed"
  appeared and how much cooldown remained at unblock (item 7).
- The held-line wait is now keyed by write via matches_write_held, so a
  same-connection write for a different entity can't satisfy it early
  (item 8).
- REST_HOST is now derived from api.py's DATA_URL_TEMPLATE; resolve_host_ips
  uses `getent ahosts`, deduplicated and IPv4-only (item 9).
- The three log-matching regexes are now derived from api.py's new log
  constants (item 10, api.py change in ce24e5a).
- New tests kill mutants M1 (write #1 removed), M2 (write #2 off by one)
  and M4 (unblock before the held wait), assert the POST value sequence
  and unblock-before-wake ordering, and cover the previously-untested
  branches (non-REST first write, NET_ADMIN skip, restore-verify failure,
  ACCEPT-add failure still tearing down DROP, timestamp parsing without
  fractional seconds, mount_source failure, no .py files found) (item 11).
- Fake clocks now flow into wait_for_early_wake_or_fail from every
  scenario test path that reaches it, so failures resolve immediately
  instead of via the global timeout (item 12).
- Trimmed docstrings/comments to the one-line-contract standard across
  both files; rationale that only restated the test name or the git
  history is gone (item 13).

Full suite green (313 tests). comment-intent-guard clean against 909a0ab.
One real run against ha-exo-pool-dev correctly refused: its mount points
at the main checkout (/Users/Andrew_1/src/projects/homeassistant/exo_pool),
not this worktree, so item 4's new check names both paths and stops
before touching anything - reported rather than worked around, per
instruction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FJHinb3pMJEwTKrnMXMcG
Signed-off-by: Andrew Brainwood <andrew.brainwood@gmail.com>
…one (#18)

set_pool_value's HA state updates before the cloud write lands
(api.py's _apply_desired_update), so reading state straight back after a
restore write could report success on a write the device never applied.
_verify_restored now forces a homeassistant.update_entity refresh before
reading back, and the scenario calls it directly on the normal path (a
failed restore now raises instead of the PASS line printing regardless),
keeping the deferred teardown only as the Ctrl+C backstop.

Also: audited every scenario_forced_held_write() test call site for the
early-wake real-clock gap flagged in review - none exist; the flagged
test's own held-wait failure short-circuits the early-wake branch, and
every other site either fails before that branch, matches the "woken
early" log line before its first clock check, or already fakes both
clocks. Added coverage for the numeric-mismatch restore branch, the
non-NET_ADMIN skip, and a first write that never goes via REST fallback;
made WRITE_HELD_BEHIND_COOLDOWN_LOG's round trip through its own regex
derivation a test rather than an assumption; pulled SWC_OUTPUT_MAX from
number.py instead of mirroring it by hand; stopped _verify_restored's and
_resumed_diagnostic's own exceptions from masking a scenario's real error;
collapsed main()'s repeated exception tuple into SCENARIO_EXCEPTIONS;
renamed StaleContainerError to MountedCodeError now that it also covers a
mount-source mismatch; and a couple of test nits (an assertion against a
hardcoded 60.0 now reads SERVICE_CALL_TIMEOUT, a stray blank line gone).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FJHinb3pMJEwTKrnMXMcG
Signed-off-by: Andrew Brainwood <andrew.brainwood@gmail.com>
…ation (#18)

README's outage-reconnect section still described the old finally-block
restore and only the staleness half of assert_mounted_code_is_loaded().
Cover the mount-source mismatch check and the need to run the harness
from the checkout ha-exo-pool-dev actually mounts, the write direction
flipping at the max value, and restore now being a deferred-teardown
backstop plus an explicit, device-refreshed verification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FJHinb3pMJEwTKrnMXMcG
Signed-off-by: Andrew Brainwood <andrew.brainwood@gmail.com>
)

scenario_forced_held_write issued real number.swc_output writes against
the dev container's actual physical chlorinator to exercise #12's
early-wake-on-reconnect behaviour. A run left the real device stuck at
0% output for ~6 hours because the restore check read HA's optimistic
state (set synchronously by _apply_desired_update) rather than a
device-confirmed value, so it could never detect a real mismatch.

Andrew's call: retire the live write entirely rather than harden it -
#12's early-wake behaviour is already covered by
tests/test_api_write_manager_mqtt_throttle.py's
test_reconnect_callback_wakes_a_held_write_before_the_cooldown_ends and
doesn't need a real device to prove. Removed scenario_forced_held_write
and everything only it used: the write/service-call helpers, the
allow-list MQTT-blocking mechanism, the restore/verify helpers, and the
log-format matchers. resolve_swc_output_entity_id and its error type
also went, since the removed scenario was their only caller.

Added a source-scan regression guard so no future scenario can
reintroduce a device write: test_harness_source_never_references_a_write_service
fails if any write-service string literal (set_value, turn_on, turn_off,
select_option, set_temperature) appears anywhere in the script.

custom_components/exo_pool/api.py is untouched - its ce24e5a log-format
constants and the #12 wake logic stay, since production _execute_write
still uses them independently of this harness.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FJHinb3pMJEwTKrnMXMcG
Signed-off-by: Andrew Brainwood <andrew.brainwood@gmail.com>
The harness that used to derive regexes from these constants is gone
(this branch's earlier commit). Nothing outside api.py used them even
before that removal, so custom_components/ now matches origin/main
exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FJHinb3pMJEwTKrnMXMcG
Signed-off-by: Andrew Brainwood <andrew.brainwood@gmail.com>
test_harness_source_never_references_a_write_service was bypassable -
a call built from a runtime string (f"set_{kind}") or a literal path
(POST /api/services/number/set_value with no set_value token) would
sail past a source-text regex.

_ha_request is now the one place the script ever touches the network:
it raises DisallowedHaRequestError before any request goes out unless
the call is a GET, or a POST to exactly a config-entry reload path.
Covered by tests that a write-service POST and PUT/DELETE never reach
urlopen (monkeypatched), and that a reload POST is let through.

Two structural tests back the chokepoint itself: urlopen is called
from nowhere but _ha_request (AST walk over every function), and the
script imports no alternate HTTP client (http.client/requests/aiohttp)
that could route around it. Mutation-tested both: a urlopen call added
to another function, and a `import requests` added, each correctly
fail their respective test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FJHinb3pMJEwTKrnMXMcG
Signed-off-by: Andrew Brainwood <andrew.brainwood@gmail.com>
…#18)

test_reconnect_callback_wakes_a_held_write_before_the_cooldown_ends only
killed the {sleep_task, event_task} -> {sleep_task} mutant (the wake
event dropped from the race) via pytest's default 30s ceiling - a slow
but not immediately obvious signal, and any test elsewhere that also
hits that ceiling would mask it.

Tried asyncio.wait_for(..., timeout=1.0) first per the brief, but this
suite's fake_clock fixture monkeypatches time.monotonic globally (the
same module object api.py's `import time` sees), which backs asyncio's
own loop.time(). fake_clock's `+= 3` jump instantly blew past a 1s
deadline computed against that same frozen/jumping clock, failing the
good code path too. pytest-timeout's thread-based marker times out
against the OS-level clock instead, which the monkeypatch never
touches - @pytest.mark.timeout(1) on just this test gets a real 1s
bound unaffected by fake_clock.

Re-ran the mutant with the bound in place: fails in ~1s instead of
~30s. Reverted from the scratchpad snapshot; custom_components/ is
byte-identical to origin/main again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FJHinb3pMJEwTKrnMXMcG
Signed-off-by: Andrew Brainwood <andrew.brainwood@gmail.com>
- Removed resolve_host_ips and its 4 tests: only caller was the
  now-deleted scenario_forced_held_write's REST-host resolution.
- Restored wait_for_log_pattern to origin/main's signature - the
  now/sleep injectable params and the Callable-predicate branch on
  `pattern` had no caller or test exercising them; every call site
  passes a compiled regex and none pass now=/sleep=.
- Dropped the sys.path.insert(_REPO_ROOT) - nothing in the script
  imports from custom_components any more (that ended with the
  device-write removal). Kept _REPO_ROOT itself; EXPECTED_EXO_POOL_SOURCE
  still needs it.
- Inlined the entity_ids temporary in main() - used exactly once.
- Removed a stray extra blank line before the teardown section header.
- Deleted the SCENARIO_EXCEPTIONS comment and trimmed
  assert_mounted_code_is_loaded's docstring to one line - the name and
  the two checks' own error messages already carry it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FJHinb3pMJEwTKrnMXMcG
Signed-off-by: Andrew Brainwood <andrew.brainwood@gmail.com>
…ree dev (#18)

- Replaced the "never writes to any entity" claim with the actual
  guarantee: no HA service calls (the _ha_request GET/reload-only
  allow-list), and named that container exec calls aren't guarded the
  same way - scoped to iptables/ss/getent/docker-image only.
- Dropped the incident-history sentence about an earlier harness
  version's real-device write; that belongs to git history, not to
  what a reader needs to use this script safely.
- Updated the write-safety paragraph to describe the current guard
  (the _ha_request chokepoint and its urlopen/import structural tests)
  instead of the now-removed source-text regex.
- make dev didn't pass -p exo_pool, so running it from a worktree (a
  differently-named directory) stood up a second compose project with
  its own empty config volume instead of reusing ha-exo-pool-dev - the
  same class of mismatch assert_mounted_code_is_loaded exists to catch,
  but one step earlier. Fixed the Makefile target and documented it
  next to the mount-guard section, which already used -p exo_pool for
  its own repoint command.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FJHinb3pMJEwTKrnMXMcG
Signed-off-by: Andrew Brainwood <andrew.brainwood@gmail.com>
The two structural tests had gaps: the import check was a blocklist
(missed http.client via `from http import client`, which adds "http"
and "http.client" - neither of which was ever checked against), and the
urlopen-caller check only walked FunctionDef bodies, missing module-level
code, lambdas, and async def.

test_harness_source_imports_exactly_the_expected_modules replaces the
blocklist with an exact allow-list of every name the script's own
imports produce - `from X import Y` recorded as both X and X.Y - so any
addition at all, not just a known-bad one, fails loudly.

test_forbidden_http_names_are_used_only_inside_ha_request walks the
whole module (not just FunctionDef bodies) for any Name or Attribute
named urlopen/build_opener/OpenerDirector/Request/HTTPConnection/
HTTPSConnection, and fails on any of them found outside _ha_request's
own subtree (found once via id() membership, not scoped by node type -
covers module level, lambdas, and async def alike), plus a direct check
for any `from urllib.request import ...`.

Mutation-tested all 5 known bypasses against these two tests, each
correctly killed:
- from urllib.request import urlopen as _u: both tests (extra import
  name, and the explicit ImportFrom check)
- module-level lambda calling urlopen: forbidden-names test
- build_opener().open(...): forbidden-names test
- from http import client: import-allow-list test
- async def calling urlopen: forbidden-names test (proves the
  FunctionDef-only walk was the actual gap)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FJHinb3pMJEwTKrnMXMcG
Signed-off-by: Andrew Brainwood <andrew.brainwood@gmail.com>
"scoped to iptables/ss/getent/docker-image commands only" overstated
it - Container.exec and the netns sidecar's docker run carry no such
scoping themselves; they run whatever they're given. Replaced with
what's actually true: unguarded, but the only things this script ever
asks them to do are read the HA log, manage iptables, and read/rewrite
/etc/hosts.

Also updated the write-safety paragraph to describe what the hardened
guards (prior commit) actually prove: an exact import allow-list (not
a blocklist), and the forbidden-name check covering the whole module,
not just other functions' bodies - so a module-level call, a lambda,
or an async def can't route around it either.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FJHinb3pMJEwTKrnMXMcG
Signed-off-by: Andrew Brainwood <andrew.brainwood@gmail.com>
…18)

_ALLOWED_RELOAD_PATH_RE used .match() with ^...$ anchors and a
[^/]+ entry-id class. Three gaps: Python's $ matches just before a
trailing newline (not only end-of-string), so a path with a trailing
"\n" still matched; [^/]+ allows anything but a slash, so
".../entry/x?a=/reload" and ".../entry/../reload" both matched too.

Switched to .fullmatch() (no anchor semantics to get wrong - the whole
string must match) with an explicit [0-9A-Za-z]+ entry-id class.
Parametrized RED test covering all three cases, confirmed failing
against urlopen (monkeypatched) before the fix, passing after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FJHinb3pMJEwTKrnMXMcG
Signed-off-by: Andrew Brainwood <andrew.brainwood@gmail.com>
make dev already passed -p exo_pool (prior commit); make stop and
dev-setup.py's start_docker() didn't, so stopping or first-time-starting
from a worktree could still target/create a differently-named compose
project instead of ha-exo-pool-dev's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FJHinb3pMJEwTKrnMXMcG
Signed-off-by: Andrew Brainwood <andrew.brainwood@gmail.com>
pytest-timeout's per-test clock includes fixture setup, and a loaded
CI runner could plausibly eat more than 1s of that before the test
body even starts, flaking a passing test. 5s keeps the mutant-catching
margin (real early-wake code still passes in ~0.17s) while giving
slow CI room. Re-ran the {sleep_task, event_task} -> {sleep_task}
mutant with the wider bound: still correctly times out, in ~5.1s wall
time versus the previous ~1.1s. Reverted; custom_components/ confirmed
byte-identical to origin/main again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FJHinb3pMJEwTKrnMXMcG
Signed-off-by: Andrew Brainwood <andrew.brainwood@gmail.com>
…#18)

test_harness_source_imports_exactly_the_expected_modules (an exact set
equality) failed on any unrelated new import, like itertools, training
whoever hit that failure to just add the name to the allow-list without
thinking about why the test exists. Replaced with
test_harness_source_imports_no_alternate_http_stack: a denylist of
http/http.client/socket/ssl/requests/aiohttp/httpx/urllib3, plus the
existing from-urllib.request-import check. Mutation-checked: `from http
import client` and `import httpx` both fail it, `import itertools`
passes. test_forbidden_http_names_are_used_only_inside_ha_request is
unchanged.

README: corrected "can't route around it" - the guards catch accidental
reintroduction of a device write, not deliberate evasion (a
string-built getattr, a host-side subprocess shelling out to curl, a
container exec are all out of scope for a source-level test).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FJHinb3pMJEwTKrnMXMcG
Signed-off-by: Andrew Brainwood <andrew.brainwood@gmail.com>
@sonarqubecloud

Copy link
Copy Markdown

@abrainwood
abrainwood merged commit 9492160 into main Sep 20, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Harness: forced held-write scenario to prove #12 early wake live

1 participant