Move the log-redaction cache into a self-contained mixin (#5169) - #5171
Merged
Merged
Conversation
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
commented
Sep 20, 2026
springfall2008
commented
Sep 20, 2026
springfall2008
commented
Sep 20, 2026
springfall2008
commented
Sep 20, 2026
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
marked this pull request as ready for review
September 24, 2026 07:58
Contributor
There was a problem hiding this comment.
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
Hassinheritance 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is an automated draft PR generated from issue #5169 — a maintainer should review it before merging.
Fixes #5169
Summary
Hassheld 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'sset_arg()/auto_config(),web.py's batch apps.yaml editor andchat_tools.pyall callself._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 raisesAttributeErroron first use.The sentinel and the three methods move verbatim into
LogRedactionin a newapps/predbat/log_secrets.py;Hassbecomesclass 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 coverssecrets,web_apps_edit,chat_toolsandweb_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 newtest_log_redaction_mixin_needs_no_initreports exactly the failure from the issue:With the change restored, the whole module passes (
**** Test secrets PASSED ****).The new test asserts both halves of what the mixin is for: a
Hasssubclass that never callsHass.__init__still redacts (rather than raising on the first invalidation), andLogRedactionis usable with noHassanywhere, each instance's pattern covering its own args only.test_log_secret_pattern_build_is_not_racyis updated to monkeypatchcollect_log_secret_valuesinlog_secrets's namespace rather thanhass'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
userinterface.py,web.py,chat_tools.py,utils.py): each is a comment saying the cache lives inhass.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.impact({target: "_log_secret_pattern", direction: "upstream"})reports CRITICAL — its only direct caller isHass.log(), which every execution flow reaches. That reflects log()'s reach, not this change's: the methods keep their names and semantics andHassstill exposes all three by inheritance. The only namespace change is thathass.collect_log_secret_values/hass.compile_log_secret_patternno 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.pyneeds no install-list entry:download.pyinstallsapps/predbat/*andcoverage/standalone_hasymlinks*.py, both globs.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:
_log_secret_fingerprint()recordedid(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()usingis. Carried over from hass.py rather than introduced here, but the move re-anchored it.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.AttributeErrorcaught 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 amonggc.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 revertedid()-based fingerprint and passes with the fix - verified both ways.Gates after the fixes:
./run_pre_commitclean apart from pre-existing markdownlint findings in working-tree files this PR does not touch,tools/triage_test.sh secretspasses, and./run_all --quickpasses (4 slow tests skipped).