Skip to content

Move the log-redaction cache into a self-contained mixin (#5169) - #5171

Merged
springfall2008 merged 3 commits into
mainfrom
feat/log-redaction-mixin-5169
Sep 24, 2026
Merged

springfall2008 merged 3 commits into
mainfrom
feat/log-redaction-mixin-5169

Conversation

@springfall2008

@springfall2008 springfall2008 commented Sep 20, 2026 •

Copy link
Copy Markdown
Owner

This is an automated draft PR generated from issue #5169 — a maintainer should review it before merging.

Fixes #5169

Summary

Hass held log()'s credential-redaction cache (GH#4770/#5053), but its three members are called from files that do not define the class holding them — userinterface.py's set_arg()/auto_config(), web.py's batch apps.yaml editor and chat_tools.py all call self._invalidate_log_secret_pattern() on the engine object — while the state they need (_log_secret_pattern_lock, _log_secret_pattern_cache, _log_secret_pattern_fingerprint) was established by exactly one __init__. "Has the methods but not the state" was therefore a reachable state that raises AttributeError on first use.

The sentinel and the three methods move verbatim into LogRedaction in a new apps/predbat/log_secrets.py; Hass becomes class Hass(LogRedaction) and __init__ loses three lines. No behaviour changes: the same methods, the same caching, the same fingerprint safety net.

State lives on the class rather than in an __init__, so nothing has to be remembered by whatever inherits the mixin. Following the design note on the triage comment, the lock is not lazily initialised — _log_secret_pattern() takes it on entry precisely to serialise concurrent first uses, and two threads each creating one lazily would each proceed inside their own, which is the interleaving the lock exists to prevent. It is a class attribute, shared by every instance: all it guards is the atomicity of one instance's "read sentinel, build, store", so making that atomic process-wide is strictly stronger than per-instance, and it is only held for any length of time during a rebuild (a config change) - it is, as the #5171 review notes, acquired on every log line, across a fingerprint check of four identity/len() reads. Sharing it means a rebuild on one instance briefly blocks log() on the others; nothing reached under it logs, so there is no re-entrant path back into log() to deadlock a non-reentrant Lock. The cache and fingerprint are class attributes too, but only as defaults to read — the first build or invalidation shadows them with instance attributes, so no instance ever sees another's secrets.

This complements #5063 (which removes the call-site contract) rather than overlapping it.

Testing

  • cd coverage && ./run_pre_commit — passed, including the quick suite (All tests passed (4 slow tests skipped)), which covers secrets, web_apps_edit, chat_tools and web_chat — the four modules that poke at this cache.

  • tools/triage_test.sh secrets — fails without the fix, passes with it. With the source change stashed (test file kept), the new test_log_redaction_mixin_needs_no_init reports exactly the failure from the issue:

    ERROR: redaction needed state that only Hass.__init__ establishes:
      'EngineWithItsOwnInit' object has no attribute '_log_secret_pattern_lock'
    ERROR: the redaction mixin is not importable on its own: No module named 'log_secrets'
    

    With the change restored, the whole module passes (**** Test secrets PASSED ****).

The new test asserts both halves of what the mixin is for: a Hass subclass that never calls Hass.__init__ still redacts (rather than raising on the first invalidation), and LogRedaction is usable with no Hass anywhere, each instance's pattern covering its own args only.

test_log_secret_pattern_build_is_not_racy is updated to monkeypatch collect_log_secret_values in log_secrets's namespace rather than hass's — that is where _log_secret_pattern() now looks the name up, and patching the old target would have silently stopped pausing the build, leaving the test green without exercising the race at all. It still passes, which is the evidence that the shared class-level lock serialises a build against a concurrent invalidation exactly as the per-instance one did.

Notes

  • Four one-line pointer updates are included beyond the move itself (userinterface.py, web.py, chat_tools.py, utils.py): each is a comment saying the cache lives in hass.py, which the move makes untrue. One docstring reference to "successive reviews of this PR" is reworded to name fix(security): redact secrets at log-write time, not just at serve time (#4770) #5053, since it now sits in a file a different PR created.
  • Blast radius: impact({target: "_log_secret_pattern", direction: "upstream"}) reports CRITICAL — its only direct caller is Hass.log(), which every execution flow reaches. That reflects log()'s reach, not this change's: the methods keep their names and semantics and Hass still exposes all three by inheritance. The only namespace change is that hass.collect_log_secret_values/hass.compile_log_secret_pattern no longer exist; a repo-wide grep found one referrer, the monkeypatch above. detect_changes() reports only the redaction path plus the comment-only touches.
  • apps/predbat/log_secrets.py needs no install-list entry: download.py installs apps/predbat/* and coverage/standalone_ha symlinks *.py, both globs.
  • Not done deliberately: the module is not added to the core-module tables in CLAUDE.md/AGENTS.md, which list the orchestrator's major subsystems rather than small helpers.

🤖 Generated with Claude Code

Review round (7d17ea1)

Four inline comments on the first round, all replied to in their threads:

  • Fingerprint could be fooled by address reuse (fixed) - _log_secret_fingerprint() recorded id(args)/id(secrets), so a freed mapping's address handed to a same-length replacement compared equal and skipped the rebuild, leaving a just-configured credential unredacted until the next explicit invalidation. It now holds the mappings themselves, compared by _log_secret_fingerprint_matches() using is. Carried over from hass.py rather than introduced here, but the move re-anchored it.
  • Shared class-level lock (comment corrected, lock kept) - the comment claimed it was not on the per-log-line path, which was wrong; it now states the real cost and records that nothing under the lock logs, so sharing it cannot deadlock.
  • Cache-hit path uncovered (fixed) - test_log_secret_pattern_cache_is_reused() counts rebuilds, so a regression that recompiled on every log line can no longer pass the suite silently.
  • Only AttributeError caught in the new test (fixed) - any other failure is now a reported test failure rather than a traceback that aborts the whole run.

test_log_secret_fingerprint_is_not_fooled_by_address_reuse() covers the first of those. It asserts the stored fingerprint is among gc.get_referrers() of the superseded mapping rather than trying to provoke a real address collision: a probe that frees the mapping and allocates same-shaped dicts looking for the old address was tried first and passed with and without the fix (64 decoy allocations never landed on the freed address), so it proved nothing. The referrer assertion fails against the reverted id()-based fingerprint and passes with the fix - verified both ways.

Gates after the fixes: ./run_pre_commit clean apart from pre-existing markdownlint findings in working-tree files this PR does not touch, tools/triage_test.sh secrets passes, and ./run_all --quick passes (4 slow tests skipped).

The cache's three methods are called from files that do not define the class
holding them, while the state they need was established only by Hass.__init__,
so "has the methods but not the state" was a reachable, silently broken state
(GH#5169). LogRedaction (log_secrets.py) now carries its own state on the class,
so inheriting the mixin is enough.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@springfall2008 springfall2008 self-assigned this Sep 20, 2026
@springfall2008 springfall2008 added the BOT_REVIEW Trigger an autotriage label Sep 20, 2026
Comment thread apps/predbat/log_secrets.py Outdated
Comment thread apps/predbat/log_secrets.py
Comment thread apps/predbat/tests/test_secrets.py
Comment thread apps/predbat/tests/test_secrets.py
@springfall2008 springfall2008 added BOT_CLEANUP Trigger: bot should address PR review feedback and CI failures, then commit and push and removed BOT_REVIEW Trigger an autotriage labels Sep 20, 2026
CI and others added 2 commits September 20, 2026 17:40
Review of #5171:

- _log_secret_fingerprint() recorded id(args)/id(secrets). A stored id is only
  an integer, so once the mapping it came from is freed CPython may hand that
  address to the next allocation and a same-length replacement landing there
  compares equal - skipping the rebuild and leaving a just-configured
  credential unredacted until the next explicit invalidation. It now holds the
  mappings themselves, compared by _log_secret_fingerprint_matches() with `is`
  so the recorded address always belongs to a live object and an
  equal-but-distinct mapping still counts as a change.
- Corrected the lock comment: it is acquired on every _log_secret_pattern()
  call, so on every log line, and only held longer for a rebuild - not "only
  ever held for the duration of a rebuild". Records the cross-instance cost of
  sharing it, and that nothing reached under it logs, so there is no
  re-entrant path back into log() to deadlock a non-reentrant Lock.
- test_log_secret_pattern_cache_is_reused() counts rebuilds, closing the gap
  where a regression that recompiled on every log line passed the whole suite.
- test_log_secret_fingerprint_is_not_fooled_by_address_reuse() asserts the
  fingerprint refers to the mapping it describes (fails without the fix above).
- The mixin test caught only AttributeError, so any other failure aborted the
  run instead of reporting a failed test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@springfall2008 springfall2008 removed the BOT_CLEANUP Trigger: bot should address PR review feedback and CI failures, then commit and push label Sep 20, 2026
@springfall2008
springfall2008 marked this pull request as ready for review September 24, 2026 07:58
Copilot AI lite review requested due to automatic review settings September 24, 2026 07:58
@springfall2008
springfall2008 merged commit 30cf59e into main Sep 24, 2026
2 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

No unresolved blocking issues were identified.

Review effort: Lite
Findings: None

What changed in this PR

Moves log-redaction caching into a reusable LogRedaction mixin, independent of Hass.__init__.

Changes:

  • Added self-contained cache, locking, and fingerprint logic.
  • Updated Hass inheritance and documentation references.
  • Expanded tests for caching, concurrency, and fingerprint safety.
File Description
apps/​predbat/​web.py Updated cache ownership documentation
apps/​predbat/​utils.py Updated cache documentation
apps/​predbat/​userinterface.py Updated cache ownership documentation
apps/​predbat/​tests/​test_secrets.py Expanded redaction and concurrency tests
apps/​predbat/​log_secrets.py Added redaction mixin
apps/​predbat/​hass.py Inherits the mixin and removes duplicated setup
apps/​predbat/​chat_tools.py Updated cache ownership documentation

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

CossieRob pushed a commit to CossieRob/batpred that referenced this pull request Sep 26, 2026
…pringfall2008#5103/springfall2008#5186/springfall2008#5053-adjacent entries

Folded (13 entries from the 15-candidate slice, all re-verified against origin/main 130596b):
- Load ML row (GH#5075, PR springfall2008#5112 open draft): is_valid() can report active for a never-trained
  model; training_timestamp is None is not a sound never-trained test (legacy saved models);
  train() stamps at the end of every curriculum pass, so an abort leaves fresh stamps that
  suppress the ml_max_model_age_hours retrain. Normalisation-statistics skew stays suspected.
- Octopus row: GH#5144 overlapping DIRECT/NON_DIRECT_DEBIT rows, minute_data last-row-wins and
  the period-to-period flip - fixed in PR springfall2008#5145 (filter_payment_method, keeps nulls).
- Car charging row: GH#5146/GH#5151 the two different Hold-for-car gates (execute.py live block
  vs prediction.py plan gate), deliberate export-window exemption test-codified
  (discharge_car_full_bat2), read-only users never see the status sensor's annotation.
- Sunsynk/DEYE row: PR springfall2008#5150 control_active persistence + upgrade-cache inference; the in-code
  press every cycle comment is wrong (both press sites change-gated, post-restart re-commit is
  is_hm_format-only); GH#5156 TOU padding truncation fixed in tou_schedule.py _padding_segments.
- Solis row: GH#5152 the hold lever is the window-scoped timed current (standing
  battery_discharge_current never driven); PR springfall2008#5189 adds the rated-current cap (GH#5187).
- Predheat row: GH#5153 no Fahrenheit conversion in get_weather_data + the optional
  heating_energy age bug pinning minute_data_age to 0.
- Teslemetry row: GH#5157 forced re-assert implemented (FORCED_ASSERT_SECONDS = 2h, dedupe
  bypassed, tariff excluded); tbc_control default on since PR springfall2008#5188.
- Keep section: GH#5162 manual_soc floor penalty is import-rate-weighted, so inert inside 0p
  windows; the manual_soc_max ceiling is export-rate-weighted; manual_charge lookalike trap.
- New Kraken row: GH#5166/PR springfall2008#5167 EDF 400-on-day/night vs Octopus 200-empty;
  KRAKEN_REST_RATES_UNAVAILABLE_STATUSES; the (None, None) tuple contract and the
  failures_total double-count, both fixed in the merged PR and kept as mechanisms.
- New Manual rates row: GH#5168 day_of_week stamped across the whole rate horizon, fixed in
  PR springfall2008#5173; end ':59:59' final-minute trap and the metric_future_rate_offset test pin kept.

Dropped: 5169-5053-cache-now-on-main (already covered by the redaction entry and the GH#5063
premise bullet; its lock-lazy-init nugget is recorded as a fact of the merged PR springfall2008#5171 mixin).

Corrected by the merge scan: GH#5103 fixed in PR springfall2008#5109 (hourly settings refresh + the new
check_ems_inverter_slots warning); teslemetry_tbc_control default on (PR springfall2008#5188); the
log-redaction cache moved into the LogRedaction mixin (log_secrets.py, PR springfall2008#5171).

Co-Authored-By: Claude Code <noreply@anthropic.com>
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.

hass.py: move the log-redaction cache into its own mixin with lazily-initialised state

2 participants