From 488f70c4937a0fec1b35764e9c53431fd9a18dc1 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Wed, 9 Sep 2026 01:01:37 +0300 Subject: [PATCH] fix: make paired plan writes recover before the next edit (#491) Issue: #491 User-Visible: yes --- custom_components/houseplan/__init__.py | 86 ++--- custom_components/houseplan/store.py | 151 ++++++++ custom_components/houseplan/websocket_api.py | 251 +++++++++---- docs/ARCHITECTURE.md | 26 +- docs/CHANGELOG.md | 5 + docs/CHANGELOG.ru.md | 7 + docs/CONFIG-COMPATIBILITY.md | 20 ++ docs/TESTING.md | 15 + docs/USER-GUIDE.md | 7 + docs/USER-GUIDE.ru.md | 8 + scripts/mutation-gate.mjs | 87 +++++ tests_backend/test_ha_import_export.py | 31 ++ tests_backend/test_ha_websocket.py | 357 +++++++++++++++++++ 13 files changed, 910 insertions(+), 141 deletions(-) diff --git a/custom_components/houseplan/__init__.py b/custom_components/houseplan/__init__.py index f58e511e..08d4be6f 100755 --- a/custom_components/houseplan/__init__.py +++ b/custom_components/houseplan/__init__.py @@ -30,6 +30,7 @@ from .plans import collect_attachments, collect_plans, sweep_upload_temps from .repairs import async_check_plan_files from .store import ( HouseplanConfigEntry, + async_resolve_pending_pair, async_save_config_state, async_save_layout_state, create_data, @@ -111,6 +112,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> # repairs or housekeeping below. await async_setup_frontend_registration(hass, entry, card_path) + # Resolve an interrupted whole-plan pair before any other setup-time + # writer reads or mutates either store. This is the same fence used by + # runtime writers, so startup migration cannot build on a half-commit. + optimize_revs: tuple[int, int] | None = None + recovered_import = False + async with data.write_lock: + resolved = await async_resolve_pending_pair(data) + if resolved.recovered_kind is not None: + optimize_revs = ( + int(resolved.config_data.get("rev", 0)), + int(resolved.layout_data.get("rev", 0)), + ) + recovered_import = resolved.recovered_kind.startswith("import") + _LOGGER.warning( + "House Plan: completed an interrupted %s", + resolved.recovered_kind.replace("_", " "), + ) + # One-time move to the square canvas (v1.48.0). Coordinates used to be # normalised against a per-space aspect ratio; the canvas is now always # square and a plan is centred inside it. Nothing about the drawing changes @@ -154,66 +173,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> # event must never see one migrated half and one old one hass.bus.async_fire("houseplan_config_updated", {"rev": rev}) - # Finish an explicit whole-plan optimization/undo interrupted between the - # config and layout store writes. The target was persisted before either - # visible half changed, so setup can always converge on the requested pair. - optimize_revs: tuple[int, int] | None = None - recovered_import = False - async with data.write_lock: - stored = await data.config_store.async_load() or {} - lay_stored = await data.store.async_load() or {} - pending = lay_stored.get("optimize_pending") - if isinstance(pending, dict) and isinstance(pending.get("config"), dict) \ - and isinstance(pending.get("layout"), dict): - target_config = pending["config"] - target_layout = pending["layout"] - config_rev = int(stored.get("rev", 0)) - layout_rev = int(lay_stored.get("rev", 0)) - target_config_rev = int(pending.get( - "config_rev", config_rev + (stored.get("config") != target_config) - )) - target_layout_rev = int(pending.get( - "layout_rev", layout_rev + (lay_stored.get("layout", {}) != target_layout) - )) - if stored.get("config") != target_config or config_rev < target_config_rev: - previous_config_rev = config_rev - config_rev = max(config_rev, target_config_rev) - await async_save_config_state( - data, - target_config, - config_rev, - previous_rev=previous_config_rev, - ) - if lay_stored.get("layout", {}) != target_layout or layout_rev < target_layout_rev: - layout_rev = max(layout_rev, target_layout_rev) - exact_metadata = pending.get("final_metadata") - replace_metadata = isinstance(exact_metadata, dict) - metadata = dict(exact_metadata) if replace_metadata else None - if not replace_metadata and not pending.get("clear_backup") \ - and "optimize_backup" in lay_stored: - metadata = {"optimize_backup": lay_stored["optimize_backup"]} - remove_metadata = ["optimize_pending", "optimize_backup"] - if pending.get("clear_backup"): - # A recovered whole-plan undo replaces the complete layout; - # a point-wise repair snapshot from the replaced layout must - # not survive and later restore coordinates into the new pair. - remove_metadata.append("repair_backup") - await async_save_layout_state( - data, - lay_stored, - target_layout, - layout_rev, - metadata=metadata, - remove=tuple(remove_metadata), - replace_metadata=replace_metadata, - ) - optimize_revs = (config_rev, layout_rev) - recovered_import = str(pending.get("kind") or "").startswith("import") - _LOGGER.warning( - "House Plan: completed an interrupted %s", - str(pending.get("kind") or "plan optimization").replace("_", " "), - ) if optimize_revs is not None: + # Setup-time geometry migration may have advanced either revision + # after pair recovery. Events always describe the final durable stores. + recovered_config = await data.config_store.async_load() or {} + recovered_layout = await data.store.async_load() or {} + optimize_revs = ( + int(recovered_config.get("rev", 0)), + int(recovered_layout.get("rev", 0)), + ) hass.bus.async_fire("houseplan_config_updated", {"rev": optimize_revs[0]}) hass.bus.async_fire("houseplan_layout_updated", {"rev": optimize_revs[1]}) if recovered_import: diff --git a/custom_components/houseplan/store.py b/custom_components/houseplan/store.py index b9c0da8f..3807498c 100644 --- a/custom_components/houseplan/store.py +++ b/custom_components/houseplan/store.py @@ -236,3 +236,154 @@ async def async_save_config_state( except Exception: # noqa: BLE001 - config commit already stands _LOGGER.exception("House Plan: virtual-light state reconciliation failed") return payload + + +@dataclass(frozen=True) +class ResolvedStorePair: + """One coherent config/layout read, optionally after pending recovery.""" + + config_data: dict[str, Any] + layout_data: dict[str, Any] + recovered_kind: str | None = None + + +def _pending_target( + layout_data: dict[str, Any], +) -> dict[str, Any] | None: + """Return a structurally usable paired-write intent, if one is present.""" + pending = layout_data.get(OPTIMIZE_PENDING) + if not isinstance(pending, dict): + return None + if not isinstance(pending.get("config"), dict): + return None + if not isinstance(pending.get("layout"), dict): + return None + return pending + + +async def async_converge_store_pair( + runtime: HouseplanData, + pending: dict[str, Any], + *, + config_data: dict[str, Any] | None = None, + layout_data: dict[str, Any] | None = None, +) -> ResolvedStorePair: + """Converge both stores on one durable paired-write intent. + + Callers hold ``runtime.write_lock``. Store may raise after bytes reached + disk, so each failed half is reloaded and compared with the exact payload + before the exception is allowed to escape. + """ + if config_data is None: + config_data = await runtime.config_store.async_load() or {} + if layout_data is None: + layout_data = await runtime.store.async_load() or {} + + target_config = canonicalize_config_geometry(pending["config"]) + target_layout = canonicalize_layout_geometry(pending["layout"]) + config_rev = int(config_data.get("rev", 0)) + layout_rev = int(layout_data.get("rev", 0)) + target_config_rev = int(pending.get( + "config_rev", config_rev + (config_data.get("config") != target_config) + )) + target_layout_rev = int(pending.get( + "layout_rev", layout_rev + (layout_data.get("layout", {}) != target_layout) + )) + + exact_metadata = pending.get("final_metadata") + replace_metadata = isinstance(exact_metadata, dict) + config_needs_write = ( + config_data.get("config") != target_config + or ( + config_rev != target_config_rev + if replace_metadata + else config_rev < target_config_rev + ) + ) + if config_needs_write: + previous_config_rev = config_rev + config_rev = ( + target_config_rev + if replace_metadata + else max(config_rev, target_config_rev) + ) + expected_config = {"config": target_config, "rev": config_rev} + try: + config_data = await async_save_config_state( + runtime, + target_config, + config_rev, + previous_rev=previous_config_rev, + ) + except Exception: + # A Store write is allowed to fail after its atomic replacement. + # Exact reload distinguishes that case from a half that never + # reached disk; no inference is made from the exception itself. + config_data = await runtime.config_store.async_load() or {} + if config_data != expected_config: + raise + + layout_rev = ( + target_layout_rev + if replace_metadata + else max(layout_rev, target_layout_rev) + ) + metadata = dict(exact_metadata) if replace_metadata else None + if ( + not replace_metadata + and not pending.get("clear_backup") + and OPTIMIZE_BACKUP in layout_data + ): + metadata = {OPTIMIZE_BACKUP: layout_data[OPTIMIZE_BACKUP]} + remove_metadata = [OPTIMIZE_PENDING, OPTIMIZE_BACKUP] + if pending.get("clear_backup"): + # A recovered whole-plan undo replaces the complete layout; a nested + # repair snapshot from that replaced layout must not survive it. + remove_metadata.append("repair_backup") + expected_layout = layout_store_payload( + layout_data, + target_layout, + layout_rev, + metadata=metadata, + remove=tuple(remove_metadata), + replace_metadata=replace_metadata, + ) + try: + layout_data = await async_save_layout_state( + runtime, + layout_data, + target_layout, + layout_rev, + metadata=metadata, + remove=tuple(remove_metadata), + replace_metadata=replace_metadata, + ) + except Exception: + layout_data = await runtime.store.async_load() or {} + if layout_data != expected_layout: + raise + + # Return fresh durable documents. A following writer must never continue + # with pre-recovery revisions or a pre-recovery layout snapshot. + config_data = await runtime.config_store.async_load() or {} + layout_data = await runtime.store.async_load() or {} + return ResolvedStorePair( + config_data=config_data, + layout_data=layout_data, + recovered_kind=str(pending.get("kind") or "plan optimization"), + ) + + +async def async_resolve_pending_pair(runtime: HouseplanData) -> ResolvedStorePair: + """Load a coherent pair, finishing a valid durable intent first.""" + config_data = await runtime.config_store.async_load() or {} + layout_data = await runtime.store.async_load() or {} + pending = _pending_target(layout_data) + if pending is None: + return ResolvedStorePair(config_data=config_data, layout_data=layout_data) + return await async_converge_store_pair( + runtime, + pending, + config_data=config_data, + layout_data=layout_data, + ) diff --git a/custom_components/houseplan/websocket_api.py b/custom_components/houseplan/websocket_api.py index d5ec558b..ba7a0f8d 100755 --- a/custom_components/houseplan/websocket_api.py +++ b/custom_components/houseplan/websocket_api.py @@ -85,6 +85,9 @@ from .registry_snapshot import import_registry_snapshot from .store import ( LAYOUT_STORE_CORE_KEYS, HouseplanData, + ResolvedStorePair, + async_converge_store_pair, + async_resolve_pending_pair, async_save_config_state, async_save_layout_state, get_data, @@ -191,6 +194,46 @@ async def _discard_optimizer_snapshot(rt: HouseplanData) -> None: ) +class PairCommitFailure(Exception): + """A paired write failed after restoring, or while recovery stayed durable.""" + + def __init__(self, *, recovery_pending: bool) -> None: + super().__init__("paired store commit failed") + self.recovery_pending = recovery_pending + + +async def _resolved_write_pair( + hass: HomeAssistant, + connection, + msg_id: int, + rt: HouseplanData, +) -> ResolvedStorePair | None: + """Resolve an older pair before a writer reads revisions or state.""" + try: + resolved = await async_resolve_pending_pair(rt) + except Exception: # noqa: BLE001 - the pending intent must remain authoritative + _LOGGER.exception("House Plan: an interrupted paired write is still pending recovery") + connection.send_error( + msg_id, + "commit_failed", + "A previous House Plan save is pending recovery; retry or restart Home Assistant", + ) + return None + if resolved.recovered_kind is not None: + # Both durable halves are complete before either event is observable. + hass.bus.async_fire( + "houseplan_config_updated", {"rev": int(resolved.config_data.get("rev", 0))} + ) + hass.bus.async_fire( + "houseplan_layout_updated", {"rev": int(resolved.layout_data.get("rev", 0))} + ) + _LOGGER.warning( + "House Plan: completed an interrupted %s before the next write", + resolved.recovered_kind.replace("_", " "), + ) + return resolved + + @callback def async_register(hass: HomeAssistant) -> None: """Register the WS commands.""" @@ -328,23 +371,10 @@ async def _persist_pair_intent( async def _converge_pair(rt: HouseplanData, pending: dict[str, Any]) -> None: """Write both target halves and remove the durable intent last.""" - await async_save_config_state( - rt, - pending["config"], - int(pending["config_rev"]), - ) - stored = await rt.store.async_load() or {} - await async_save_layout_state( - rt, - stored, - pending["layout"], - int(pending["layout_rev"]), - metadata=dict(pending.get("final_metadata") or {}), - replace_metadata=True, - ) + await async_converge_store_pair(rt, pending) -async def _commit_import_pair( +async def _commit_pair( rt: HouseplanData, pending: dict[str, Any], rollback: dict[str, Any], @@ -362,24 +392,22 @@ async def _commit_import_pair( await _converge_pair(rt, pending) return except Exception: # noqa: BLE001 - one retry handles fail-after-write too - _LOGGER.warning("House Plan import pair write failed; retrying target", exc_info=True) + _LOGGER.warning("House Plan pair write failed; retrying target", exc_info=True) try: await _persist_pair_intent(rt, pending) await _converge_pair(rt, pending) return except Exception: # noqa: BLE001 - target is no longer the recovery policy - _LOGGER.exception("House Plan import target retry failed; restoring previous pair") + _LOGGER.exception("House Plan target retry failed; restoring previous pair") try: await _persist_pair_intent(rt, rollback) await _converge_pair(rt, rollback) except Exception as rollback_error: # noqa: BLE001 - a failed rollback must not mask the original commit error _LOGGER.exception( - "House Plan import rollback could not finish; rollback intent remains for setup" + "House Plan rollback could not finish; rollback intent remains for recovery" ) - raise ImportFailure( - "commit_failed", "Import failed; the previous plan is pending recovery" - ) from rollback_error - raise ImportFailure("commit_failed", "Import failed and the previous plan was restored") + raise PairCommitFailure(recovery_pending=True) from rollback_error + raise PairCommitFailure(recovery_pending=False) # ---------------- portable backup / transfer ---------------- @@ -501,8 +529,11 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any]) try: async with rt.write_lock: candidate = get_candidate(rt, msg["token"], _connection_user_id(connection)) - config_data = await rt.config_store.async_load() or {} - layout_data = await rt.store.async_load() or {} + resolved = await _resolved_write_pair(hass, connection, msg["id"], rt) + if resolved is None: + return + config_data = resolved.config_data + layout_data = resolved.layout_data config_rev = int(config_data.get("rev", 0)) layout_rev = int(layout_data.get("rev", 0)) if ( @@ -591,12 +622,20 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any]) "layout_rev": layout_rev, "final_metadata": original_metadata, } - await _commit_import_pair(rt, pending, rollback) + await _commit_pair(rt, pending, rollback) # A token becomes single-use only after both durable halves land. get_candidate(rt, msg["token"], _connection_user_id(connection), consume=True) except ImportFailure as err: _send_import_error(connection, msg["id"], err) return + except PairCommitFailure as err: + message = ( + "Import failed; the previous plan is pending recovery" + if err.recovery_pending + else "Import failed and the previous plan was restored" + ) + connection.send_error(msg["id"], "commit_failed", message) + return except Exception: # noqa: BLE001 - defensive: a listener must never break the write path _LOGGER.exception("House Plan import commit failed") connection.send_error(msg["id"], "commit_failed", "Import commit failed") @@ -690,8 +729,11 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> if rt is None: return async with rt.write_lock: - config_data = await rt.config_store.async_load() or {} - data = await rt.store.async_load() or {} + resolved = await _resolved_write_pair(hass, connection, msg["id"], rt) + if resolved is None: + return + config_data = resolved.config_data + data = resolved.layout_data current_rev = int(data.get("rev", 0)) if "expected_rev" not in msg and current_rev: # #356: without the revision the client read, a wholesale write is @@ -749,7 +791,10 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any]) # the marker. Its tombstone is the server-side authority: acknowledge # but ignore the late point so re-adding starts without a zombie # position. - config_data = await rt.config_store.async_load() or {} + resolved = await _resolved_write_pair(hass, connection, msg["id"], rt) + if resolved is None: + return + config_data = resolved.config_data config = config_data.get("config") or {} markers = config.get("markers") or [] deleted = any( @@ -771,14 +816,14 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any]) and not live_explicit ) if deleted or orphan_virtual: - data = await rt.store.async_load() or {} + data = resolved.layout_data connection.send_result(msg["id"], { "ok": True, "ignored": "removed" if deleted else "missing_virtual", "rev": int(data.get("rev", 0)), }) return - data = await rt.store.async_load() or {} + data = resolved.layout_data layout = data.get("layout", {}) if layout.get(msg["device_id"]) == msg["pos"]: connection.send_result(msg["id"], {"ok": True, "rev": int(data.get("rev", 0))}) @@ -833,7 +878,10 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any connection.send_error(msg["id"], "invalid_space_id", "space_id: only [a-z0-9_-], up to 64 characters") return async with rt.write_lock: - data = await rt.store.async_load() or {} + resolved = await _resolved_write_pair(hass, connection, msg["id"], rt) + if resolved is None: + return + data = resolved.layout_data layout = data.get("layout") or {} current_rev = int(data.get("rev", 0)) if "expected_rev" in msg and msg["expected_rev"] != current_rev: @@ -1314,7 +1362,10 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) return new_rev: int | None = None async with rt.write_lock: - data = await rt.store.async_load() or {} + resolved = await _resolved_write_pair(hass, connection, msg["id"], rt) + if resolved is None: + return + data = resolved.layout_data layout = data.get("layout", {}) if msg["device_id"] in layout: del layout[msg["device_id"]] @@ -1537,7 +1588,10 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> ) return async with rt.write_lock: - data = await rt.config_store.async_load() or {} + resolved = await _resolved_write_pair(hass, connection, msg["id"], rt) + if resolved is None: + return + data = resolved.config_data current_rev = data.get("rev", 0) if "expected_rev" not in msg and current_rev: # #340: a request without the revision it read is indistinguishable @@ -1801,8 +1855,11 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) try: async with rt.write_lock: - config_data = await rt.config_store.async_load() or {} - layout_data = await rt.store.async_load() or {} + resolved = await _resolved_write_pair(hass, connection, msg["id"], rt) + if resolved is None: + return + config_data = resolved.config_data + layout_data = resolved.layout_data config_rev = int(config_data.get("rev", 0)) layout_rev = int(layout_data.get("rev", 0)) if (msg["expected_config_rev"] != config_rev @@ -1854,7 +1911,15 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) "layout_rev": layout_rev, "final_metadata": original_metadata, } - await _commit_import_pair(rt, pending, rollback) + await _commit_pair(rt, pending, rollback) + except PairCommitFailure as err: + message = ( + "Space delete failed; the previous plan is pending recovery" + if err.recovery_pending + else "Space delete failed and the previous plan was restored" + ) + connection.send_error(msg["id"], "commit_failed", message) + return except ImportFailure as err: _send_import_error(connection, msg["id"], err) return @@ -1910,8 +1975,11 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any]) return async with rt.write_lock: - config_data = await rt.config_store.async_load() or {} - layout_data = await rt.store.async_load() or {} + resolved = await _resolved_write_pair(hass, connection, msg["id"], rt) + if resolved is None: + return + config_data = resolved.config_data + layout_data = resolved.layout_data config_rev = int(config_data.get("rev", 0)) layout_rev = int(layout_data.get("rev", 0)) if msg["expected_config_rev"] != config_rev or msg["expected_layout_rev"] != layout_rev: @@ -2021,36 +2089,50 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any]) "after_config_rev": new_config_rev, "after_layout_rev": new_layout_rev, } + original_metadata = _layout_metadata(layout_data) + final_metadata = { + key: value for key, value in original_metadata.items() + if key not in { + _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, + "repair_backup", "geom_pending", + } + } + final_metadata[_OPTIMIZE_BACKUP] = backup pending = { + "kind": "optimize", "config": canonicalize_config_geometry(msg["config"]), "layout": canonicalize_layout_geometry(msg["layout"]), "config_rev": new_config_rev, "layout_rev": new_layout_rev, - "clear_backup": False, + "final_metadata": final_metadata, } - # Intent first. A setup-time finisher completes whichever half a crash - # interrupted; until then the visible layout/revision remain unchanged. - await async_save_layout_state( - rt, layout_data, layout_data.get("layout", {}), layout_rev, - metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending}, - remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING), - ) - await async_save_config_state( - rt, - msg["config"], - new_config_rev, - previous_rev=config_rev, - ) + rollback = { + "kind": "optimize_rollback", + "config": canonicalize_config_geometry( + config_data.get("config") or DEFAULT_CONFIG + ), + "layout": canonicalize_layout_geometry( + layout_data.get("layout") or {} + ), + "config_rev": config_rev, + "layout_rev": layout_rev, + "final_metadata": original_metadata, + } + try: + await _commit_pair(rt, pending, rollback) + except PairCommitFailure as err: + message = ( + "Plan optimization failed; the previous plan is pending recovery" + if err.recovery_pending + else "Plan optimization failed and the previous plan was restored" + ) + connection.send_error(msg["id"], "commit_failed", message) + return # The optimized candidate is now the stored document: its junction # counts are the next write's baseline (#333 AC3, symmetric with # config/set — otherwise the next save re-judges `previous` for # nothing and the #330 cache loses its point). rt.junction_baseline = (int(new_config_rev), optimize_counts or {}) - await async_save_layout_state( - rt, layout_data, msg["layout"], new_layout_rev, - metadata={_OPTIMIZE_BACKUP: backup}, - remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup", "geom_pending"), - ) hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev}) hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev}) @@ -2082,8 +2164,11 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str, restored_kind = "optimize" async with rt.write_lock: - config_data = await rt.config_store.async_load() or {} - layout_data = await rt.store.async_load() or {} + resolved = await _resolved_write_pair(hass, connection, msg["id"], rt) + if resolved is None: + return + config_data = resolved.config_data + layout_data = resolved.layout_data config_rev = int(config_data.get("rev", 0)) layout_rev = int(layout_data.get("rev", 0)) if msg["expected_config_rev"] != config_rev or msg["expected_layout_rev"] != layout_rev: @@ -2106,29 +2191,45 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str, ) new_config_rev = config_rev + 1 new_layout_rev = layout_rev + 1 + original_metadata = _layout_metadata(layout_data) + final_metadata = { + key: value for key, value in original_metadata.items() + if key not in {_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup"} + } pending = { "kind": "import_undo" if restored_kind == "import" else "optimize_undo", "config": restored_config, "layout": restored_layout, "config_rev": new_config_rev, "layout_rev": new_layout_rev, - "clear_backup": True, + "final_metadata": final_metadata, } - await async_save_layout_state( - rt, layout_data, layout_data.get("layout", {}), layout_rev, - metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending}, - remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING), - ) - await async_save_config_state( - rt, - restored_config, - new_config_rev, - previous_rev=config_rev, - ) - await async_save_layout_state( - rt, layout_data, restored_layout, new_layout_rev, - remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup"), - ) + rollback = { + "kind": ( + "import_undo_rollback" + if restored_kind == "import" + else "optimize_undo_rollback" + ), + "config": canonicalize_config_geometry( + config_data.get("config") or DEFAULT_CONFIG + ), + "layout": canonicalize_layout_geometry( + layout_data.get("layout") or {} + ), + "config_rev": config_rev, + "layout_rev": layout_rev, + "final_metadata": original_metadata, + } + try: + await _commit_pair(rt, pending, rollback) + except PairCommitFailure as err: + message = ( + "Plan undo failed; the previous plan is pending recovery" + if err.recovery_pending + else "Plan undo failed and the previous plan was restored" + ) + connection.send_error(msg["id"], "commit_failed", message) + return hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev}) hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev}) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 962ecf2e..61f3b2da 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1201,13 +1201,25 @@ geometry/presentation allowlists. The parser recomputes that projection and its placement manifest before showing a plan-only preview, so manually adding a private field while keeping `transfer.plan_only: true` is rejected. -The browser never parses imported configuration. Full import and maintenance -share the `optimize_pending` crash-recovery intent and the one-deep backup slot; -the backup carries `kind: optimize|import`, while every layout-store writer -goes through `async_save_layout_state` so unrelated store metadata survives. -Apply rechecks local plan files under the write lock. A failed pair is retried -toward the target once, then gets an explicit rollback intent so a later -restart never finishes an import already reported as failed. +The browser never parses imported configuration. Optimize, Optimize Undo, full +import, space deletion and maintenance share the `optimize_pending` +crash-recovery intent and the one-deep backup slot; the backup carries +`kind: optimize|import`, while every layout-store writer goes through +`async_save_layout_state` so unrelated store metadata survives. Each paired +writer persists an exact target intent before either half, retries convergence +once, then durably replaces it with an exact before-pair rollback intent before +reporting failure. HA Store exceptions are resolved by reloading and comparing +the exact payload because an exception may follow a durable atomic replace. + +Every runtime config/layout writer holds the common `write_lock` and calls the +same pending-pair resolver before reading revisions, validating or checking for +a no-op. A stale CAS writer therefore sees the recovered revisions and gets a +normal conflict; point layout writers apply only their delta to the recovered +layout. If convergence still fails, the new writer performs no own write and +leaves the intent available for retry or restart. Setup runs this resolver +before any setup-time storage migration. Config/layout update events are fired +only after both halves and final metadata are durable. Apply still rechecks +local plan files under the write lock. **If the v1.48 migration crashed halfway** (HP-1500-01): the config write landed, the layout write did not, and both triggers are gone — markers of that diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b1ffcfee..87f50bf7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Optimize, its server-side Undo, imports and space deletion now use one + crash-resumable config/layout commit protocol. If a temporary storage error + leaves such a save unfinished, the next edit completes recovery before it + checks revisions or writes anything, so it cannot silently replace half of + the plan or lose device positions ([#491](https://github.com/Matysh/houseplan-card/issues/491)). - Summary-panel entity values now update immediately even when the entity is not represented anywhere else on the plan. A recovered save whose response was lost also keeps unrelated changes made concurrently in another House diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 70bd5a2b..bb4350f3 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,13 @@ ## Не выпущено +- Оптимизация, её серверная отмена, импорт и удаление пространства теперь + используют единый восстанавливаемый протокол записи конфигурации и + расположения. Если временная ошибка хранилища прервала такую запись, + следующая правка сначала завершит восстановление и только потом проверит + ревизии или запишет свои данные — половина плана и позиции устройств больше + не могут быть незаметно потеряны + ([#491](https://github.com/Matysh/houseplan-card/issues/491)). - Значения сущностей в сводной панели теперь обновляются сразу, даже если эта сущность больше нигде не размещена на плане. Если ответ на сохранение был потерян, восстановление также сохраняет несвязанные параллельные изменения diff --git a/docs/CONFIG-COMPATIBILITY.md b/docs/CONFIG-COMPATIBILITY.md index 3a8fc535..19e2b410 100644 --- a/docs/CONFIG-COMPATIBILITY.md +++ b/docs/CONFIG-COMPATIBILITY.md @@ -63,6 +63,26 @@ writer produce the same request; accepting either would reopen last-writer-wins data loss. This changes only the WebSocket write contract. Stored config, model/store versions, exports and read compatibility are unchanged. +## Crash-resumable config/layout pairs (#491) + +`optimize_pending` is the durable authority for every paired config/layout +write: Optimize, Optimize Undo, full import and space deletion. Current intents +carry canonical target documents, their exact target revisions and exact final +layout-store metadata. The intent is written before either visible half and is +removed only by the final layout write after config and layout both match it. +On a persistent target failure, an explicit rollback intent restores the exact +before-pair, including its revisions and unknown metadata. + +Before every runtime config/layout writer reads revisions or validates its own +candidate, the common write fence resolves a valid pending intent and reloads +both stores. Old CAS revisions conflict normally; point layout writes then +change only their named entry on the recovered layout. If recovery cannot yet +finish, the new write is rejected without deleting the intent or backup. +Startup invokes the same resolver before storage migrations. Legacy valid +intents without `final_metadata` retain their previous compatibility fallback; +there are no new persisted fields, store/model version bumps, export fields or +read-side repair semantics. + ## Room hover information preference (#426) `settings.show_room_tooltip` is an optional global boolean. Absence and any diff --git a/docs/TESTING.md b/docs/TESTING.md index d649214e..652ee146 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -3052,6 +3052,21 @@ require hands on real hardware — they remain for the human pass. test_ha_import_export; auto: smoke_optimize_coordinate_canonicalization; mutations: `optimize-storage-boundary-removed`, `optimize-config-storage-half-raw`, `optimize-layout-storage-half-raw`]. +- [ ] **An unfinished config/layout pair survives the next writer (#491)**: + Optimize and Optimize Undo use the same intent → exact reload/retry → + rollback protocol as import and space deletion. A runtime config writer + resolves pending before CAS; a point layout writer applies only its + delta to the recovered target; a continuing Store failure rejects that + writer without changing either half or deleting intent/backup. A Store + exception after the final durable layout is recognized as success, while + persistent Optimize/Undo target failures restore the exact before-pair + including unknown metadata and revisions. Setup shares the resolver and + legacy pending remains compatible [backend/HA harness: + `test_issue_491_*`, existing `test_setup_recovers_*` and import pair + fault tests; mutations: `pair-recovery-config-writer-skips-fence`, + `pair-recovery-point-writer-skips-fence`, + `optimize-skips-pair-retry-rollback`, + `optimize-undo-skips-pair-retry-rollback`]. - [ ] Optimizer migration safety: legacy decor width/text size is clamped to the backend schema, `fill: true` receives explicit fill style, invalid legacy `plan_scale` is preserved for repair, an already canonical plan is diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 1137ee74..f3b2a1b0 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -1118,6 +1118,13 @@ and explicitly removed positions with the rest of the previous layout. Any later edit makes that undo stale, so create a Home Assistant backup before a large maintenance operation. +If a temporary storage error interrupts Optimize or its server-side Undo, +House Plan does not let the next edit overwrite the unfinished half. The next +save first completes the recorded operation (or its safe rollback) and then +checks the edit against the fresh revisions. A stale browser may therefore ask +you to reload and retry. If storage is still unavailable, the save fails and +the recovery record remains for another attempt or a Home Assistant restart. + ## 20. Storage, multiple cards and backups diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index ba7c6333..f88bad1a 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -1905,6 +1905,14 @@ light_pools: true Старые и импортированные объекты между узлами будут привязаны к сетке. Новые редакторы такие координаты не создают. После оптимизации доступна одна серверная отмена, но только до следующего изменения плана. Отмена возвращает прежнюю геометрию, автоматически и явно удалённые позиции и неизвестные поля в чистом числовом представлении, не восстанавливая невидимый floating-point шум. Новый edit делает резервную копию оптимизации устаревшей. +Если временная ошибка хранилища прервала оптимизацию или её серверную отмену, +House Plan не позволит следующей правке перезаписать незавершённую половину. +Следующее сохранение сначала завершит записанную операцию либо её безопасный +откат, а затем проверит правку по свежим ревизиям. Поэтому устаревшая карточка +может попросить перечитать план и повторить действие. Если хранилище всё ещё +недоступно, сохранение завершится ошибкой, а запись восстановления останется +для следующей попытки или перезапуска Home Assistant. + Обычное открытие и редактирование плана не удаляет даже очень короткие точные границы толщины. Описанное схлопывание выполняется только после явного подтверждения Optimize, отражается в количестве объединённых фрагментов в diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 19b59927..0f5d9585 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -7749,6 +7749,93 @@ const MUTANT_DEFINITIONS = [ replace: ' partitionIds: [], // mutant: finish skips coincident seed scope', }], }, + { + id: 'pair-recovery-config-writer-skips-fence', + guard: 'node scripts/backend-test-guard.mjs ' + + 'issue_491_config_writer_resolves_pending_pair_before_cas ' + + 'tests_backend/test_ha_websocket.py', + because: 'a config save after a half-finished Optimize must resolve the durable pair before ' + + 'CAS, or it can consume the recovery intent and commit over mixed state (#491 AC3)', + patches: [{ + file: 'custom_components/houseplan/websocket_api.py', + find: ' async with rt.write_lock:\n' + + ' resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)\n' + + ' if resolved is None:\n' + + ' return\n' + + ' data = resolved.config_data\n' + + ' current_rev = data.get("rev", 0)\n', + replace: ' async with rt.write_lock:\n' + + ' data = await rt.config_store.async_load() or {}\n' + + ' current_rev = data.get("rev", 0)\n', + }], + }, + { + id: 'pair-recovery-point-writer-skips-fence', + guard: 'node scripts/backend-test-guard.mjs ' + + 'issue_491_point_layout_writer_applies_delta_to_recovered_pair ' + + 'tests_backend/test_ha_websocket.py', + because: 'a point drag must apply its one-device delta to the recovered target layout, not ' + + 'the stale visible half that existed when the drag started (#491 AC4)', + patches: [{ + file: 'custom_components/houseplan/websocket_api.py', + find: ' resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)\n' + + ' if resolved is None:\n' + + ' return\n' + + ' config_data = resolved.config_data\n' + + ' config = config_data.get("config") or {}\n' + + ' markers = config.get("markers") or []\n', + replace: ' config_data = await rt.config_store.async_load() or {}\n' + + ' resolved = ResolvedStorePair(\n' + + ' config_data=config_data, layout_data=await rt.store.async_load() or {},\n' + + ' )\n' + + ' config = config_data.get("config") or {}\n' + + ' markers = config.get("markers") or []\n', + }], + }, + { + id: 'optimize-skips-pair-retry-rollback', + guard: 'node scripts/backend-test-guard.mjs ' + + 'issue_491_optimize_failure_restores_before_pair ' + + 'tests_backend/test_ha_websocket.py', + because: 'Optimize must report a failed target only after the common retry/rollback protocol ' + + 'has restored the exact before-pair and metadata (#491 AC1)', + patches: [{ + file: 'custom_components/houseplan/websocket_api.py', + find: ' try:\n' + + ' await _commit_pair(rt, pending, rollback)\n' + + ' except PairCommitFailure as err:\n' + + ' message = (\n' + + ' "Plan optimization failed; the previous plan is pending recovery"\n', + replace: ' try:\n' + + ' await _persist_pair_intent(rt, pending)\n' + + ' await _converge_pair(rt, pending)\n' + + ' except PairCommitFailure as err:\n' + + ' message = (\n' + + ' "Plan optimization failed; the previous plan is pending recovery"\n', + }], + }, + { + id: 'optimize-undo-skips-pair-retry-rollback', + guard: 'node scripts/backend-test-guard.mjs ' + + 'issue_491_optimize_undo_failure_restores_pre_undo_pair ' + + 'tests_backend/test_ha_websocket.py', + because: 'Optimize Undo needs the same retry/rollback primitive; otherwise a failed restore ' + + 'can leave a target intent over the still-live optimized pair (#491 AC2)', + patches: [{ + file: 'custom_components/houseplan/websocket_api.py', + find: ' try:\n' + + ' await _commit_pair(rt, pending, rollback)\n' + + ' except PairCommitFailure as err:\n' + + ' message = (\n' + + ' "Plan undo failed; the previous plan is pending recovery"\n', + replace: ' try:\n' + + ' await _persist_pair_intent(rt, pending)\n' + + ' await _converge_pair(rt, pending)\n' + + ' except PairCommitFailure as err:\n' + + ' message = (\n' + + ' "Plan undo failed; the previous plan is pending recovery"\n', + }], + }, ]; const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8'); diff --git a/tests_backend/test_ha_import_export.py b/tests_backend/test_ha_import_export.py index 6c73803d..655b10f2 100644 --- a/tests_backend/test_ha_import_export.py +++ b/tests_backend/test_ha_import_export.py @@ -2402,6 +2402,37 @@ async def test_pair_retries_a_fail_after_layout_write_before_success( assert "optimize_pending" not in layout_data +async def test_issue_491_import_apply_fences_an_older_pending_pair( + hass: HomeAssistant, tmp_path: Path, +) -> None: + """A preview made before recovery conflicts after the exact pair is resolved.""" + await _setup(hass) + rt, response, _ = await _candidate(hass, tmp_path) + recovered_config = _config() + recovered_config["spaces"][0]["title"] = "Recovered before import" + recovered_layout = {"lamp": {"x": 0.4, "y": 0.5, "s": "ground"}} + current_layout = await rt.store.async_load() + await rt.store.async_save({ + **current_layout, + "optimize_pending": { + "kind": "optimize", "config": recovered_config, + "layout": recovered_layout, "config_rev": 2, "layout_rev": 2, + "final_metadata": {"future": {"kept": True}}, + }, + }) + + connection = await _apply(hass, response) + assert connection.result is None + assert connection.error and connection.error[0] == "conflict" + assert await rt.config_store.async_load() == { + "config": recovered_config, "rev": 2, + } + stored = await rt.store.async_load() + assert stored == { + "future": {"kept": True}, "layout": recovered_layout, "rev": 2, + } + + async def test_success_events_are_emitted_only_after_both_target_writes( hass: HomeAssistant, tmp_path: Path, monkeypatch, ) -> None: diff --git a/tests_backend/test_ha_websocket.py b/tests_backend/test_ha_websocket.py index 2f6c17d6..cf812c53 100644 --- a/tests_backend/test_ha_websocket.py +++ b/tests_backend/test_ha_websocket.py @@ -1161,6 +1161,363 @@ async def test_plan_optimize_pair_and_one_deep_undo_survives_geometry_repair( ) +async def test_issue_491_config_writer_resolves_pending_pair_before_cas( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, +) -> None: + """A stale config edit cannot consume a half-finished Optimize intent.""" + from custom_components.houseplan.store import get_data + + await _setup(hass) + client = await hass_ws_client(hass) + runtime = get_data(hass) + assert runtime is not None + before = {"spaces": [], "markers": [], "settings": {}} + target = { + "spaces": [], + "markers": [{"id": "target", "binding": "virtual"}], + "settings": {}, + } + target_layout = { + "dev": {"s": "f1", "x": 0.4, "y": 0.5}, + "other": {"s": "f1", "x": 0.7, "y": 0.8}, + } + backup = { + "kind": "optimize", "config": before, "layout": {}, + "after_config_rev": 2, "after_layout_rev": 2, + } + await runtime.config_store.async_save({"config": before, "rev": 1}) + await runtime.store.async_save({ + "layout": {}, "rev": 1, + "optimize_pending": { + "kind": "optimize", "config": target, "layout": target_layout, + "config_rev": 2, "layout_rev": 2, + "final_metadata": {"optimize_backup": backup, "future": {"kept": True}}, + }, + }) + + candidate = { + **target, + "markers": [ + *target["markers"], + {"id": "next", "binding": "virtual"}, + ], + } + await client.send_json_auto_id({ + "type": "houseplan/config/set", "config": candidate, "expected_rev": 1, + }) + stale = await client.receive_json() + assert not stale["success"] and stale["error"]["code"] == "conflict" + assert await runtime.config_store.async_load() == {"config": target, "rev": 2} + resolved_layout = await runtime.store.async_load() + assert resolved_layout["layout"] == target_layout + assert resolved_layout["rev"] == 2 + assert resolved_layout["future"] == {"kept": True} + assert "optimize_pending" not in resolved_layout + + await client.send_json_auto_id({ + "type": "houseplan/config/set", "config": candidate, "expected_rev": 2, + }) + saved = await client.receive_json() + assert saved["success"] and saved["result"]["rev"] == 3 + assert (await runtime.store.async_load())["layout"] == target_layout + + +async def test_issue_491_point_layout_writer_applies_delta_to_recovered_pair( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, +) -> None: + """A drag after a half-commit preserves every recovered foreign point.""" + from custom_components.houseplan.store import get_data + + await _setup(hass) + client = await hass_ws_client(hass) + runtime = get_data(hass) + assert runtime is not None + config = {"spaces": [], "markers": [], "settings": {}} + old_layout = {"dev": {"s": "f1", "x": 0.1, "y": 0.2}} + target_layout = { + "dev": {"s": "f1", "x": 0.4, "y": 0.5}, + "other": {"s": "f1", "x": 0.7, "y": 0.8}, + } + await runtime.config_store.async_save({"config": config, "rev": 2}) + await runtime.store.async_save({ + "layout": old_layout, "rev": 1, + "optimize_pending": { + "kind": "optimize", "config": config, "layout": target_layout, + "config_rev": 2, "layout_rev": 2, + "final_metadata": {"optimize_backup": {"after_config_rev": 2, + "after_layout_rev": 2}}, + }, + }) + + moved = {"s": "f1", "x": 0.9, "y": 0.6} + await client.send_json_auto_id({ + "type": "houseplan/layout/update", "device_id": "dev", "pos": moved, + }) + response = await client.receive_json() + assert response["success"] and response["result"]["rev"] == 3 + stored = await runtime.store.async_load() + assert stored["layout"] == {"dev": moved, "other": target_layout["other"]} + assert "optimize_pending" not in stored + assert "optimize_backup" not in stored + + +@pytest.mark.parametrize("writer", ["set", "delete", "repair"]) +async def test_issue_491_every_layout_writer_fences_a_pending_pair( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, writer: str, +) -> None: + """CAS, point-delete and maintenance all start from the recovered layout.""" + from custom_components.houseplan.store import get_data + + await _setup(hass) + client = await hass_ws_client(hass) + runtime = get_data(hass) + assert runtime is not None + config = {"spaces": [], "markers": [], "settings": {}} + target_layout = { + "dev": {"s": "f1", "x": 0.2, "y": 0.25}, + "other": {"s": "f2", "x": 0.7, "y": 0.8}, + } + backup = {"after_config_rev": 2, "after_layout_rev": 2} + await runtime.config_store.async_save({"config": config, "rev": 1}) + await runtime.store.async_save({ + "layout": {"dev": {"s": "f1", "x": 0.1, "y": 0.1}}, "rev": 1, + "optimize_pending": { + "kind": "optimize", "config": config, "layout": target_layout, + "config_rev": 2, "layout_rev": 2, + "final_metadata": {"optimize_backup": backup}, + }, + }) + + if writer == "set": + await client.send_json_auto_id({ + "type": "houseplan/layout/set", + "layout": {"replacement": {"s": "f3", "x": 0.5, "y": 0.5}}, + "expected_rev": 1, + }) + elif writer == "delete": + await client.send_json_auto_id({ + "type": "houseplan/layout/delete", "device_id": "dev", + }) + else: + await client.send_json_auto_id({ + "type": "houseplan/geometry/repair", "space_id": "f1", "aspect": 2.0, + }) + response = await client.receive_json() + stored = await runtime.store.async_load() + assert "optimize_pending" not in stored + + if writer == "set": + assert not response["success"] and response["error"]["code"] == "conflict" + assert stored["layout"] == target_layout and stored["rev"] == 2 + assert stored["optimize_backup"] == backup + elif writer == "delete": + assert response["success"] and response["result"]["rev"] == 3 + assert stored["layout"] == {"other": target_layout["other"]} + assert "optimize_backup" not in stored + else: + assert response["success"] and response["result"]["rev"] == 3 + assert stored["layout"]["other"] == target_layout["other"] + assert stored["layout"]["dev"] != target_layout["dev"] + assert stored["optimize_backup"]["after_layout_rev"] == 3 + + +async def test_issue_491_space_delete_fences_before_pair_revisions( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, +) -> None: + """A paired writer cannot replace an older unresolved paired operation.""" + from custom_components.houseplan.store import get_data + + await _setup(hass) + client = await hass_ws_client(hass) + runtime = get_data(hass) + assert runtime is not None + before = { + "spaces": [_space("f1", "r1")], "markers": [], "settings": {}, + } + target = copy.deepcopy(before) + target["spaces"][0]["title"] = "Recovered" + target_layout = {"dev": {"s": "f1", "x": 0.4, "y": 0.5}} + await runtime.config_store.async_save({"config": before, "rev": 1}) + await runtime.store.async_save({ + "layout": {}, "rev": 1, + "optimize_pending": { + "kind": "optimize", "config": target, "layout": target_layout, + "config_rev": 2, "layout_rev": 2, "final_metadata": {}, + }, + }) + + await client.send_json_auto_id({ + "type": "houseplan/space/delete", "space_id": "f1", + "expected_config_rev": 1, "expected_layout_rev": 1, + }) + response = await client.receive_json() + assert not response["success"] and response["error"]["code"] == "conflict" + assert await runtime.config_store.async_load() == {"config": target, "rev": 2} + assert await runtime.store.async_load() == {"layout": target_layout, "rev": 2} + + +async def test_issue_491_failed_fence_blocks_point_write_and_keeps_intent( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch, +) -> None: + """A continuing Store failure cannot turn recovery into an ordinary edit.""" + from custom_components.houseplan.store import get_data + + await _setup(hass) + client = await hass_ws_client(hass) + runtime = get_data(hass) + assert runtime is not None + before = {"spaces": [], "markers": [], "settings": {}} + target = { + "spaces": [], "markers": [{"id": "target", "binding": "virtual"}], + "settings": {}, + } + old_layout = {"dev": {"s": "f1", "x": 0.1, "y": 0.2}} + pending = { + "kind": "optimize", "config": target, + "layout": {"dev": {"s": "f1", "x": 0.4, "y": 0.5}}, + "config_rev": 2, "layout_rev": 2, + "final_metadata": {"optimize_backup": {"sentinel": True}}, + } + await runtime.config_store.async_save({"config": before, "rev": 1}) + await runtime.store.async_save({ + "layout": old_layout, "rev": 1, "optimize_pending": pending, + }) + + real_config_save = runtime.config_store.async_save + + async def refuse_recovery(value: dict) -> None: + if value.get("rev") == 2: + raise OSError("target config remains unavailable") + await real_config_save(value) + + monkeypatch.setattr(runtime.config_store, "async_save", refuse_recovery) + await client.send_json_auto_id({ + "type": "houseplan/layout/update", "device_id": "dev", + "pos": {"s": "f1", "x": 0.9, "y": 0.9}, + }) + response = await client.receive_json() + assert not response["success"] and response["error"]["code"] == "commit_failed" + assert await runtime.config_store.async_load() == {"config": before, "rev": 1} + stored = await runtime.store.async_load() + assert stored["layout"] == old_layout + assert stored["optimize_pending"] == pending + + +async def test_issue_491_optimize_fail_after_final_write_is_success( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch, +) -> None: + """A post-durable Store exception is decided by exact reload, not guessed.""" + from custom_components.houseplan.store import get_data + + await _setup(hass) + client = await hass_ws_client(hass) + runtime = get_data(hass) + assert runtime is not None + before = {"spaces": [], "markers": [], "settings": {}} + target = {**before, "model_version": 1} + await runtime.config_store.async_save({"config": before, "rev": 1}) + await runtime.store.async_save({"layout": {}, "rev": 1, "future": "kept"}) + real_layout_save = runtime.store.async_save + failed = False + + async def fail_after_final(value: dict) -> None: + nonlocal failed + await real_layout_save(value) + if value.get("rev") == 2 and "optimize_pending" not in value and not failed: + failed = True + raise OSError("reported failure after durable final layout") + + monkeypatch.setattr(runtime.store, "async_save", fail_after_final) + await client.send_json_auto_id({ + "type": "houseplan/plan/optimize", "config": target, "layout": {}, + "expected_config_rev": 1, "expected_layout_rev": 1, + }) + response = await client.receive_json() + assert response["success"] and response["result"]["can_undo"] is True + assert failed + assert (await runtime.config_store.async_load())["rev"] == 2 + stored = await runtime.store.async_load() + assert stored["rev"] == 2 and "optimize_pending" not in stored + assert stored["future"] == "kept" + + +async def test_issue_491_optimize_failure_restores_before_pair( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch, +) -> None: + """Persistent target failure reports an error only after exact rollback.""" + from custom_components.houseplan.store import get_data + + await _setup(hass) + client = await hass_ws_client(hass) + runtime = get_data(hass) + assert runtime is not None + before = {"spaces": [], "markers": [], "settings": {}} + target = {**before, "model_version": 1} + before_layout = {"dev": {"s": "f1", "x": 0.1, "y": 0.2}} + before_store = { + "layout": before_layout, "rev": 1, + "future": {"must": "survive"}, + } + await runtime.config_store.async_save({"config": before, "rev": 1}) + await runtime.store.async_save(before_store) + real_config_save = runtime.config_store.async_save + + async def refuse_target(value: dict) -> None: + if value.get("rev") == 2: + raise OSError("optimize target unavailable") + await real_config_save(value) + + monkeypatch.setattr(runtime.config_store, "async_save", refuse_target) + await client.send_json_auto_id({ + "type": "houseplan/plan/optimize", "config": target, + "layout": {"dev": {"s": "f1", "x": 0.8, "y": 0.9}}, + "expected_config_rev": 1, "expected_layout_rev": 1, + }) + response = await client.receive_json() + assert not response["success"] and response["error"]["code"] == "commit_failed" + assert await runtime.config_store.async_load() == {"config": before, "rev": 1} + assert await runtime.store.async_load() == before_store + + +async def test_issue_491_optimize_undo_failure_restores_pre_undo_pair( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch, +) -> None: + """Undo uses the same retry/rollback protocol and keeps its live backup.""" + from custom_components.houseplan.store import get_data + + await _setup(hass) + client = await hass_ws_client(hass) + runtime = get_data(hass) + assert runtime is not None + before = {"spaces": [], "markers": [], "settings": {}} + optimized = {**before, "model_version": 1} + await runtime.config_store.async_save({"config": before, "rev": 1}) + await runtime.store.async_save({"layout": {}, "rev": 1}) + await client.send_json_auto_id({ + "type": "houseplan/plan/optimize", "config": optimized, "layout": {}, + "expected_config_rev": 1, "expected_layout_rev": 1, + }) + assert (await client.receive_json())["success"] + exact_config = await runtime.config_store.async_load() + exact_layout = await runtime.store.async_load() + real_config_save = runtime.config_store.async_save + + async def refuse_undo_target(value: dict) -> None: + if value.get("rev") == 3: + raise OSError("undo target unavailable") + await real_config_save(value) + + monkeypatch.setattr(runtime.config_store, "async_save", refuse_undo_target) + await client.send_json_auto_id({ + "type": "houseplan/plan/optimize_undo", + "expected_config_rev": 2, "expected_layout_rev": 2, + }) + response = await client.receive_json() + assert not response["success"] and response["error"]["code"] == "commit_failed" + assert await runtime.config_store.async_load() == exact_config + assert await runtime.store.async_load() == exact_layout + + async def test_not_ready_without_entry(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None: """WS commands answer not_ready when the integration has no loaded entry.""" # register only the WS commands, without an entry