v1.45.1: follow-up review of v1.45.0 — R3-1, R3-2

R3-1 (high): v1.45.0 made the upload safe but left deletion to the client —
after a successful save the card asked the backend to remove everything but the
file it had just committed. Two open editors cannot be ordered: a delayed
request from one deleted the plan the other had just saved, leaving the
accepted configuration pointing at nothing, the exact damage copy-on-write was
introduced to prevent.

houseplan/plan/cleanup is removed. config/set collects inside its own write
lock from the two configurations that bracket the commit (plans.collect_plans):
a file the old revision referenced and the new one does not is superseded and
goes; any other unreferenced upload waits out PLAN_ORPHAN_TTL_S, because a
fresh one may belong to a transaction that has not committed yet. The collector
lives in a pure module so it can be reasoned about and unit-tested without the
HA harness.

R3-2: houseplan-space-card signed its plan url and threw the result away —
getCardSize() mutated a throwaway model while render() rebuilt its own from the
config, so the <image> requested the protected path and got 401 on every
render. Both cards now share ContentSigner (src/signing.ts), which also gives
the static card batching, expiry handling and periodic re-signing.  is
released in finally: one failed request no longer wedges a url for the life of
the page.

Tests: five backend interleaving cases from the report, six unit tests for the
pure collector, smoke_space_card_bg (verified to fail against a v1.45.0 build:
the raw url reaches the DOM and no retry happens). 57 smokes, 124 unit, 22
backend-pure.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
This commit is contained in:
Matysh
2026-07-27 22:01:41 +03:00
parent f1b501a956
commit c749b52a0d
24 changed files with 875 additions and 375 deletions
+119
View File
@@ -15,6 +15,44 @@ v = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(v)
def _load_pure(name):
"""Load one pure module of the integration without importing the package.
custom_components/houseplan/__init__.py pulls in Home Assistant, which the
local sandbox does not have; but plans.py is deliberately pure, so a tiny
stub package lets it keep its normal relative imports.
"""
import sys
import types
pkg_dir = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "custom_components", "houseplan"
)
pkg = sys.modules.get("hp_pure")
if pkg is None:
pkg = types.ModuleType("hp_pure")
pkg.__path__ = [pkg_dir]
sys.modules["hp_pure"] = pkg
for dep in ("const", "validation"):
sp = importlib.util.spec_from_file_location(
f"hp_pure.{dep}", os.path.join(pkg_dir, f"{dep}.py")
)
mod = importlib.util.module_from_spec(sp)
sys.modules[f"hp_pure.{dep}"] = mod
sp.loader.exec_module(mod)
sp = importlib.util.spec_from_file_location(
f"hp_pure.{name}", os.path.join(pkg_dir, f"{name}.py")
)
mod = importlib.util.module_from_spec(sp)
sys.modules[f"hp_pure.{name}"] = mod
sp.loader.exec_module(mod)
return mod
plans = _load_pure("plans")
const = importlib.import_module("hp_pure.const")
def test_sanitize_marker_id():
assert v.sanitize_marker_id("../etc/passwd") == "_etc_passwd"
assert v.sanitize_marker_id("..") == "misc" # pure traversal → misc
@@ -173,3 +211,84 @@ def test_openings_cap_enforced():
v.CONFIG_SCHEMA({"spaces": [{"id": "s1", "title": "S", "aspect": 1.0,
"view_box": [0, 0, 100, 100], "rooms": [],
"openings": many}]})
# ---------- plan-file collection (review R3-1) ----------
def _plans(tmp_path, names, age=0.0):
import os, time
d = tmp_path / "plans"
d.mkdir(exist_ok=True)
for n in names:
(d / n).write_bytes(b"x")
if age:
t = time.time() - age
os.utime(d / n, (t, t))
return d
def _cfg(*urls):
return {"spaces": [{"id": f"s{i}", "plan_url": u} for i, u in enumerate(urls)]}
def test_plan_basename_and_refs():
plan_basename, plan_refs, is_plan_file = plans.plan_basename, plans.plan_refs, plans.is_plan_file
assert plan_basename("/api/houseplan/content/plans/_/f1.abc.png?v=7") == "f1.abc.png"
assert plan_basename("/houseplan_files/plans/f1.svg") == "f1.svg"
assert plan_basename(None) == "" and plan_basename("") == "" and plan_basename(7) == ""
assert plan_refs(None) == set() and plan_refs({}) == set()
assert plan_refs(_cfg("/p/a.png", None, "/p/b.svg")) == {"a.png", "b.svg"}
assert is_plan_file("f1.svg") and is_plan_file("f1.tok.png")
assert not is_plan_file("notes.txt") and not is_plan_file("readme")
assert not is_plan_file("deep.name.with.dots.png") # 4 parts: not ours
def test_collect_plans_removes_only_the_superseded_file(tmp_path):
collect_plans = plans.collect_plans
d = _plans(tmp_path, ["f1.old.png", "f1.new.png", "f2.png"])
removed = collect_plans(d, _cfg("/p/f1.old.png", "/p/f2.png"), _cfg("/p/f1.new.png", "/p/f2.png"))
assert removed == 1
assert not (d / "f1.old.png").exists()
assert (d / "f1.new.png").is_file() and (d / "f2.png").is_file()
def test_collect_plans_keeps_a_fresh_unreferenced_upload(tmp_path):
"""Another client may be mid-transaction: its file is unreferenced but young."""
collect_plans = plans.collect_plans
d = _plans(tmp_path, ["f1.committed.png", "f1.inflight.png"])
removed = collect_plans(d, _cfg("/p/f1.committed.png"), _cfg("/p/f1.committed.png"))
assert removed == 0
assert (d / "f1.inflight.png").is_file()
def test_collect_plans_takes_an_aged_orphan(tmp_path):
PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S
collect_plans = plans.collect_plans
d = _plans(tmp_path, ["f1.keep.png"])
_plans(tmp_path, ["f1.abandoned.png"], age=PLAN_ORPHAN_TTL_S + 60)
removed = collect_plans(d, _cfg("/p/f1.keep.png"), _cfg("/p/f1.keep.png"))
assert removed == 1
assert (d / "f1.keep.png").is_file() and not (d / "f1.abandoned.png").exists()
def test_collect_plans_never_touches_a_referenced_or_foreign_file(tmp_path):
PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S
collect_plans = plans.collect_plans
old = PLAN_ORPHAN_TTL_S + 60
d = _plans(tmp_path, ["f1.png", "notes.txt", "readme", "deep.name.with.dots.png"], age=old)
removed = collect_plans(d, _cfg("/p/f1.png"), _cfg("/p/f1.png"))
assert removed == 0
for n in ("f1.png", "notes.txt", "readme", "deep.name.with.dots.png"):
assert (d / n).is_file()
def test_collect_plans_survives_a_missing_directory(tmp_path):
collect_plans = plans.collect_plans
assert collect_plans(tmp_path / "nope", _cfg(), _cfg()) == 0