diff --git a/BlocksScreen/lib/panels/mainWindow.py b/BlocksScreen/lib/panels/mainWindow.py index 8d5543a0..0eeb0607 100644 --- a/BlocksScreen/lib/panels/mainWindow.py +++ b/BlocksScreen/lib/panels/mainWindow.py @@ -300,6 +300,9 @@ def __init__(self): self.updater_worker.daemon_unavailable.connect( self.update_page.handle_daemon_unavailable ) + self.updater_worker.daemon_restarting.connect( + self.update_page.handle_daemon_restarting + ) self.controlPanel.lock_ui.connect(self.set_ui_lock) self.updater_worker.request_reconnect.connect(self._on_post_update_reconnect) self.updater_worker.proxy_connected.connect( diff --git a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py index ea8b8c4c..c57336c8 100644 --- a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py +++ b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py @@ -375,6 +375,10 @@ def handle_busy_changed(self, busy: bool) -> None: if busy: if self._provisioning: self._show_provisioning_overlay() + elif not self._overlay_shown: + # Busy this UI did not start: it restarted mid-update, block it too. + self._overlay_shown = True + self.call_load_panel.emit(True, "Update in progress ...", False) self._restart_pending = False self._restart_grace_timer.stop() self._stale_overlay_timer.stop() @@ -409,11 +413,24 @@ def handle_provisioning_changed(self, provisioning: bool) -> None: if provisioning and self._busy: self._cancel_btn.hide() self._show_provisioning_overlay() + elif self._busy: + # Still busy after the clone: the daemon holds it for install-updater. + self._show_finishing_overlay() def _show_provisioning_overlay(self) -> None: self._overlay_shown = True self.call_load_panel.emit(True, "Missing component, installing ...", False) + def _show_finishing_overlay(self) -> None: + self._overlay_shown = True + self.call_load_panel.emit(True, "Finishing installation ...", False) + + @QtCore.pyqtSlot(name="handle-daemon-restarting") + def handle_daemon_restarting(self) -> None: + """Daemon left mid-busy to restart: keep the overlay until the new one reports.""" + self._cancel_btn.hide() + self._show_finishing_overlay() + def _dismiss_stale_overlay(self) -> None: """Drop the overlay if the post-update status never arrived.""" if self._overlay_shown and not self._busy: diff --git a/BlocksScreen/lib/updater_worker.py b/BlocksScreen/lib/updater_worker.py index 9091963e..42a7e0e1 100644 --- a/BlocksScreen/lib/updater_worker.py +++ b/BlocksScreen/lib/updater_worker.py @@ -53,6 +53,7 @@ class UpdaterWorker(QtCore.QObject): busy_changed = QtCore.pyqtSignal(bool) provisioning_changed = QtCore.pyqtSignal(bool) daemon_unavailable = QtCore.pyqtSignal() + daemon_restarting = QtCore.pyqtSignal() update_rejected = QtCore.pyqtSignal() request_reconnect = QtCore.pyqtSignal() proxy_connected = QtCore.pyqtSignal() @@ -337,7 +338,11 @@ async def _watch_daemon_owner(self) -> None: self._daemon_owner = "" # Retry bus-activates a stopped unit; only mask keeps it off. _log.error("updater daemon left the bus - awaiting restart") - self.daemon_unavailable.emit() + # Busy exit = install-updater restarting it; a failed retry still reports. + if self._last_busy: + self.daemon_restarting.emit() + else: + self.daemon_unavailable.emit() self._schedule_reconnect() continue _log.warning( diff --git a/tests/lib/test_updater_worker_unit.py b/tests/lib/test_updater_worker_unit.py index e68b8e69..2d0fc35a 100644 --- a/tests/lib/test_updater_worker_unit.py +++ b/tests/lib/test_updater_worker_unit.py @@ -273,6 +273,24 @@ async def test_owner_lost_emits_unavailable_and_schedules_reconnect( worker._async_initialize.assert_not_awaited() worker._schedule_reconnect.assert_called_once_with() + @pytest.mark.asyncio + async def test_owner_lost_while_busy_emits_restarting(self, worker): + """install-updater restarts a busy daemon: the overlay must stay, not drop.""" + restarting, unavailable = [], [] + worker.daemon_restarting.connect(lambda: restarting.append(True)) + worker.daemon_unavailable.connect(lambda: unavailable.append(True)) + worker._last_busy = True + worker._async_initialize = AsyncMock() + worker._schedule_reconnect = MagicMock() + _, dbus = self._subscribe( + worker, [(_BUS, ":1.5", ""), self._stop(worker)], owner=":1.5" + ) + with dbus: + await worker._watch_daemon_owner() + assert restarting == [True] + assert unavailable == [] + worker._schedule_reconnect.assert_called_once_with() + @pytest.mark.asyncio async def test_other_names_and_repeat_owner_ignored(self, worker): worker._async_initialize = AsyncMock() diff --git a/tests/updater/conftest.py b/tests/updater/conftest.py index 2532ef84..b6bdab52 100644 --- a/tests/updater/conftest.py +++ b/tests/updater/conftest.py @@ -60,6 +60,10 @@ def svc(): mock_svc.provision_missing = AsyncMock(return_value=False) mock_svc.reconcile = AsyncMock(return_value=True) mock_svc.reconcile_if_pending = AsyncMock() + mock_svc.deploy_pending = MagicMock(return_value=False) + # Units stay live while the flag is set unless a test says otherwise. + mock_svc.deploy_running = AsyncMock(side_effect=lambda: mock_svc.deploy_pending()) + mock_svc.deploy_flag_stamp = MagicMock(return_value=1) mock_svc._components = [ ComponentConfig(name="moonraker", kind="git"), ComponentConfig(name="klipper", kind="git"), @@ -76,6 +80,9 @@ def svc(): s._busy = False s._provisioning = False s._provisioned = False + s._boot_pass_done = True + s._deploy_hold = None + s._capped_flag = None s._background_tasks = set() s._closing = False s._status_check_in_progress = False diff --git a/tests/updater/test_dbus_service_unit.py b/tests/updater/test_dbus_service_unit.py index 5db63514..11e190ef 100644 --- a/tests/updater/test_dbus_service_unit.py +++ b/tests/updater/test_dbus_service_unit.py @@ -352,21 +352,189 @@ async def test_periodic_check_never_lengthens_a_short_poll_interval(self, svc): class TestBootNotBusy: - def test_idle_at_construction(self): - """Offline boots must not open on the install overlay: busy waits for a real clone.""" + @staticmethod + def _build(deploy_pending: bool): from updater import dbus_service + mock_svc = MagicMock() + mock_svc.deploy_pending = MagicMock(return_value=deploy_pending) with ( - patch.object(dbus_service, "UpdateService", return_value=MagicMock()), + patch.object(dbus_service, "UpdateService", return_value=mock_svc), patch.object( dbus_service.UpdaterDbusService, "_spawn", MagicMock(side_effect=lambda coro, **_: coro.close()), ), ): - built = dbus_service.UpdaterDbusService() + return dbus_service.UpdaterDbusService() + + def test_idle_at_construction(self): + """Offline boots must not open on the install overlay: busy waits for a real clone.""" + built = self._build(deploy_pending=False) assert (built._busy, built._provisioning) == (False, False) + def test_busy_at_construction_while_deploy_runs(self): + """install-updater restarts the daemon mid-run: the new one resumes the hold.""" + built = self._build(deploy_pending=True) + assert built._busy is True + assert built._boot_pass_done is False + + +class TestDeployHold: + @pytest.mark.asyncio + async def test_busy_held_until_flag_clears(self, svc, monkeypatch): + from updater import dbus_service + + monkeypatch.setattr(dbus_service, "_DEPLOY_POLL_S", 0.01) + svc._busy = True + svc._svc.deploy_pending.return_value = True + svc._set_busy(False) + await asyncio.sleep(0.05) + assert svc._busy is True + svc.busy_changed.emit.assert_not_called() + + svc._svc.deploy_pending.return_value = False + await asyncio.sleep(0.05) + assert svc._busy is False + svc.busy_changed.emit.assert_called_once_with((False,)) + assert svc._deploy_hold is None + + @pytest.mark.asyncio + async def test_hold_released_at_cap(self, svc, monkeypatch): + """A flag nothing clears must not lock the screen forever.""" + from updater import dbus_service + + monkeypatch.setattr(dbus_service, "_DEPLOY_POLL_S", 0.01) + monkeypatch.setattr(dbus_service, "_DEPLOY_HOLD_CAP_S", 0.03) + svc._busy = True + svc._svc.deploy_pending.return_value = True + svc._set_busy(False) + await asyncio.sleep(0.1) + assert svc._busy is False + + @pytest.mark.asyncio + async def test_capped_flag_never_holds_again(self, svc, monkeypatch): + """A stuck flag costs the cap once, not again at the end of every update.""" + from updater import dbus_service + + monkeypatch.setattr(dbus_service, "_DEPLOY_POLL_S", 0.01) + monkeypatch.setattr(dbus_service, "_DEPLOY_HOLD_CAP_S", 0.03) + svc._svc.deploy_pending.return_value = True + svc._set_busy(False) + await asyncio.sleep(0.1) + svc._set_busy(True) + svc._set_busy(False) + assert (svc._busy, svc._deploy_hold) == (False, None) + + svc._svc.deploy_flag_stamp.return_value = 2 + svc._set_busy(False) + assert svc._busy is True + svc._svc.deploy_pending.return_value = False + await asyncio.sleep(0.05) + + @pytest.mark.asyncio + async def test_dead_deploy_units_release_at_once(self, svc, monkeypatch): + """A failed or stopped path unit never runs install-updater: its flag is stale.""" + from updater import dbus_service + + monkeypatch.setattr(dbus_service, "_DEPLOY_POLL_S", 0.01) + svc._busy = True + svc._svc.deploy_pending.return_value = True + svc._svc.deploy_running = AsyncMock(return_value=False) + svc._set_busy(False) + await asyncio.sleep(0.02) + assert (svc._busy, svc._deploy_hold) == (False, None) + + @pytest.mark.asyncio + async def test_hold_spawned_once(self, svc, monkeypatch): + from updater import dbus_service + + monkeypatch.setattr(dbus_service, "_DEPLOY_POLL_S", 0.01) + svc._svc.deploy_pending.return_value = True + svc._set_busy(False) + first = svc._deploy_hold + svc._set_busy(False) + assert svc._deploy_hold is first + svc._svc.deploy_pending.return_value = False + await asyncio.sleep(0.05) + + @pytest.mark.asyncio + async def test_hold_end_leaves_running_provision_busy(self, svc, monkeypatch): + """A clone that started under the hold drops busy itself when it finishes.""" + from updater import dbus_service + + monkeypatch.setattr(dbus_service, "_DEPLOY_POLL_S", 0.01) + svc._svc.deploy_pending.return_value = True + svc._set_busy(False) + svc._provision_busy(True) + svc._svc.deploy_pending.return_value = False + await asyncio.sleep(0.05) + assert svc._busy is True + svc._provision_busy(False) + assert svc._busy is False + + @pytest.mark.asyncio + async def test_update_rejected_during_hold(self, svc): + """install-updater restarts moonraker: no new batch may start under it.""" + svc._svc.deploy_pending.return_value = True + svc._set_busy(False) + assert await svc.update_all() is False + svc._svc.deploy_pending.return_value = False + await asyncio.sleep(0) + + @pytest.mark.asyncio + async def test_cancel_keeps_hold(self, svc): + svc._busy = True + svc._svc.deploy_pending.return_value = True + await svc.cancel() + assert svc._busy is True + svc._svc.deploy_pending.return_value = False + + +class TestStatusAfterBootPass: + @pytest.mark.asyncio + async def test_request_status_deferred_until_boot_pass(self, svc): + """A status before provisioning would let the UI drop its overlay mid-install.""" + svc._boot_pass_done = False + await svc.request_status() + await asyncio.sleep(0) + svc.status_ready.emit.assert_not_called() + + @pytest.mark.asyncio + @pytest.mark.usefixtures("reconciled") + async def test_boot_pass_enables_request_status(self, svc, monkeypatch): + from updater import dbus_service + + monkeypatch.setattr(dbus_service, "_BOOT_DELAY_S", 0.0) + svc._boot_pass_done = False + svc._svc.poll_interval = 3600.0 + task = asyncio.create_task(svc._periodic_status_check()) + await asyncio.sleep(0.05) + assert svc._boot_pass_done is True + svc.status_ready.emit.assert_called_once() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + @pytest.mark.asyncio + @pytest.mark.usefixtures("reconciled") + async def test_failed_boot_pass_still_emits_status(self, svc, monkeypatch): + """A raising provision pass must not leave the UI without status for a poll.""" + from updater import dbus_service + + monkeypatch.setattr(dbus_service, "_BOOT_DELAY_S", 0.0) + svc._boot_pass_done = False + svc._provisioned = False + svc._svc.provision_missing = AsyncMock(side_effect=RuntimeError("boom")) + svc._svc.poll_interval = 3600.0 + task = asyncio.create_task(svc._periodic_status_check()) + await asyncio.sleep(0.05) + assert svc._boot_pass_done is True + svc.status_ready.emit.assert_called_once() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + @pytest.mark.usefixtures("reconciled") class TestBootProvision: diff --git a/tests/updater/test_service_unit.py b/tests/updater/test_service_unit.py index 3622f745..8b5bc204 100644 --- a/tests/updater/test_service_unit.py +++ b/tests/updater/test_service_unit.py @@ -1,5 +1,6 @@ import asyncio import json +import os import time from contextlib import ExitStack, nullcontext from pathlib import Path @@ -20,6 +21,8 @@ def units_installed(): "updater.service.service_unit_missing", AsyncMock(return_value=False) ) as mock: yield mock + + def _isolate_inflight(tmp_path_factory, monkeypatch): """Keep the in-flight marker, state and history out of the real cache.""" marker = tmp_path_factory.mktemp("inflight") / "updater_inflight.json" @@ -2827,6 +2830,47 @@ def test_touch_deploy_flag_rejects_planted_symlink(self, tmp_path: Path): assert flag.exists() and not flag.is_symlink() assert target.read_text() == "keep" # symlink not followed + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("ok", "out", "running"), + [ + (True, "active\n\ninactive\n", True), + (True, "inactive\n\nactivating\n", True), + (True, "failed\n\ninactive\n", False), + (True, "inactive\n\ninactive\n", False), + (False, "", True), + ], + ) + async def test_deploy_running_follows_unit_state( + self, tmp_path: Path, ok: bool, out: str, running: bool + ): + """A failed/stopped path unit never fires; an unreadable state trusts the flag.""" + flag = tmp_path / ".run-install-updater" + flag.touch() + with ( + patch("updater.service._DEPLOY_FLAG", flag), + patch("updater.service._DEPLOY_PATH_UNIT", flag), + patch("updater.service._run", new=AsyncMock(return_value=(ok, out))), + ): + assert await UpdateService().deploy_running() is running + + @pytest.mark.asyncio + async def test_deploy_running_skips_systemctl_without_flag(self, tmp_path: Path): + with ( + patch("updater.service._DEPLOY_FLAG", tmp_path / "nope"), + patch("updater.service._run", new=AsyncMock()) as mock_run, + ): + assert await UpdateService().deploy_running() is False + mock_run.assert_not_called() + + def test_deploy_flag_stamp_tracks_each_touch(self, tmp_path: Path): + flag = tmp_path / ".run-install-updater" + with patch("updater.service._DEPLOY_FLAG", flag): + assert UpdateService.deploy_flag_stamp() is None + flag.touch() + os.utime(flag, ns=(1, 1)) + assert UpdateService.deploy_flag_stamp() == 1 + class TestInflightRollback: """Cross-reboot revert: an update cut off before commit is undone on boot.""" diff --git a/tests/widgets/test_update_page_unit.py b/tests/widgets/test_update_page_unit.py index 15292bda..c6a05cf1 100644 --- a/tests/widgets/test_update_page_unit.py +++ b/tests/widgets/test_update_page_unit.py @@ -553,12 +553,42 @@ def test_provisioning_after_busy_still_shows_message(self, page, qtbot): page.handle_provisioning_changed(True) assert blocker.args == [True, "Missing component, installing ...", False] - def test_undeclared_busy_is_not_an_install(self, page, qtbot): + def test_undeclared_busy_shows_update_overlay(self, page, qtbot): page.show_loading = MagicMock() - with qtbot.assertNotEmitted(page.call_load_panel, wait=200): + with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: page.handle_busy_changed(True) + assert blocker.args == [True, "Update in progress ...", False] assert page._provisioning is False + def test_busy_with_own_overlay_keeps_its_message(self, page, qtbot): + page.show_loading = MagicMock() + page._overlay_shown = True + with qtbot.assertNotEmitted(page.call_load_panel, wait=200): + page.handle_busy_changed(True) + + def test_provision_end_while_busy_shows_finishing(self, page, qtbot): + page.show_loading = MagicMock() + page.handle_provisioning_changed(True) + page.handle_busy_changed(True) + with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: + page.handle_provisioning_changed(False) + assert blocker.args == [True, "Finishing installation ...", False] + + def test_provision_end_while_idle_emits_nothing(self, page, qtbot): + page.show_loading = MagicMock() + with qtbot.assertNotEmitted(page.call_load_panel, wait=200): + page.handle_provisioning_changed(False) + + def test_daemon_restarting_keeps_overlay_and_hides_cancel(self, page, qtbot): + page.show_loading = MagicMock() + page.handle_busy_changed(True) + page._cancel_btn.reset_mock() + with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: + page.handle_daemon_restarting() + assert blocker.args == [True, "Finishing installation ...", False] + assert page._overlay_shown is True + page._cancel_btn.hide.assert_called_once() + def test_provision_steps_name_the_component(self, page, qtbot): page.show_loading = MagicMock() page.handle_provisioning_changed(True) diff --git a/updater/dbus_service.py b/updater/dbus_service.py index 7b8071e5..b7286319 100644 --- a/updater/dbus_service.py +++ b/updater/dbus_service.py @@ -22,6 +22,9 @@ _RECONCILE_RETRY_S = 5.0 _BOOT_DELAY_S = 3.0 _SHUTDOWN_DRAIN_S = 60.0 # < systemd's 90s stop timeout +_DEPLOY_POLL_S = 1.0 +# oneshot has no start timeout: bounds a hung install-updater (first DeviceDiscovery build takes minutes). +_DEPLOY_HOLD_CAP_S = 900.0 class DbusProgressCallback: @@ -112,6 +115,11 @@ def __init__(self) -> None: self._busy: bool = False self._provisioning: bool = False self._provisioned: bool = False + # request_status waits for the boot pass, so a client never sees pre-install status. + self._boot_pass_done: bool = False + self._deploy_hold: asyncio.Task | None = None + # Flag stamp that already used up the cap: never hold for it again. + self._capped_flag: int | None = None self._background_tasks: set[asyncio.Task] = set() self._closing: bool = False self._status_check_in_progress: bool = False @@ -124,6 +132,11 @@ def __init__(self) -> None: self._spawn(self._periodic_status_check(), name="periodic_status_check") self._spawn(self._svc.supervise_ui(), name="supervise_ui") self._spawn(self._svc.forward_heal_ui(), name="forward_heal_ui") + # install-updater restarts us before its run ends: resume the hold. + # Silent: not exported yet, clients read it via get_busy on connect. + if self._svc.deploy_pending(): + self._busy = True + self._deploy_hold = self._spawn(self._hold_for_deploy(), name="deploy_hold") def _spawn(self, coro, *, name: str | None = None) -> asyncio.Task: """Create a task and hold a strong reference so GC cannot cancel it.""" @@ -187,12 +200,50 @@ def _set_provisioning(self, provisioning: bool) -> None: self.provisioning_changed.emit((provisioning,)) def _set_busy(self, busy: bool) -> None: + """Busy stays up while install-updater.sh runs out-of-band, so the UI keeps its overlay.""" + if not busy and self._hold_wanted(): + if self._deploy_hold is None: + self._deploy_hold = self._spawn( + self._hold_for_deploy(), name="deploy_hold" + ) + busy = True + self._emit_busy(busy) + + def _emit_busy(self, busy: bool) -> None: """Emit busy_changed only on state transitions to avoid redundant signals.""" if busy != self._busy: self._busy = busy _log.info("busy_changed -> %s", busy) self.busy_changed.emit((busy,)) + def _hold_wanted(self) -> bool: + """A deploy flag is set and has not already used up the hold cap.""" + return ( + self._svc.deploy_pending() + and self._svc.deploy_flag_stamp() != self._capped_flag + ) + + async def _hold_for_deploy(self) -> None: + """Hold busy while install-updater runs; release on exit, dead units or the cap.""" + _log.info("deploy flag set - holding busy until install-updater finishes") + loop = asyncio.get_running_loop() + deadline = loop.time() + _DEPLOY_HOLD_CAP_S + try: + while await self._svc.deploy_running(): + if loop.time() >= deadline: + _log.warning( + "install-updater still running after %.0fs - releasing busy", + _DEPLOY_HOLD_CAP_S, + ) + self._capped_flag = self._svc.deploy_flag_stamp() + break + await asyncio.sleep(_DEPLOY_POLL_S) + finally: + self._deploy_hold = None + # A provisioning pass that started under the hold still owns busy. + if not self._provisioning: + self._emit_busy(busy=False) + def _validate_component_name(self, name: str) -> bool: """SEC: verify component exists to prevent abuse on unknown names.""" is_valid = self._svc.has_component(name) @@ -256,6 +307,11 @@ async def _periodic_status_check(self) -> None: deferred = await self._svc.provision_missing(self._provision_busy) self._provisioned = not deferred _log.info("provisioning pass done (deferred=%s)", deferred) + except Exception as exc: # noqa: BLE001 + _log.error("provisioning pass failed: %s", exc) + # Emitted even after a failed pass: request_status stays silent until then. + self._boot_pass_done = True + try: await self._emit_status() except Exception as exc: # noqa: BLE001 _log.error("periodic_check failed: %s", exc) @@ -380,6 +436,9 @@ async def _run_recover(self, name: str, hard: bool) -> None: async def request_status(self) -> None: """D-Bus method: reply immediately, status arrives via status_ready signal.""" _log.info("request_status called") + if not self._boot_pass_done: + _log.info("request_status: boot pass pending, its status follows") + return self._spawn(self._emit_status(force=True), name="request_status") @sdbus.dbus_method_async(result_signature="b") diff --git a/updater/service.py b/updater/service.py index e88ca72c..254c7a67 100644 --- a/updater/service.py +++ b/updater/service.py @@ -178,6 +178,10 @@ def reset(self) -> None: # Watched by BlocksScreen-deploy.path: install-updater.sh runs in its own cgroup. _DEPLOY_FLAG = Path.home() / ".config" / "blockscreen" / ".run-install-updater" +_DEPLOY_PATH_UNIT = Path("/etc/systemd/system/BlocksScreen-deploy.path") +_DEPLOY_UNITS = ("BlocksScreen-deploy.path", "BlocksScreen-deploy.service") +# A failed/stopped path unit never fires, so its stale flag is not a running deploy. +_DEPLOY_LIVE_STATES = frozenset({"active", "activating", "deactivating", "reloading"}) class ProgressCallback(Protocol): @@ -1095,6 +1099,31 @@ def _touch_deploy_flag(self) -> None: _DEPLOY_FLAG.touch() self._fsync_dir(_DEPLOY_FLAG.parent) + @staticmethod + def deploy_pending() -> bool: + """True while install-updater.sh is queued or running; its ExecStopPost clears the flag.""" + # Without the path unit nothing ever clears the flag (bs-deploy-check.sh test). + return _DEPLOY_FLAG.exists() and _DEPLOY_PATH_UNIT.exists() + + @staticmethod + def deploy_flag_stamp() -> int | None: + """The deploy flag's mtime in ns, identifying one queued run; None when unset.""" + try: + return _DEPLOY_FLAG.stat().st_mtime_ns + except OSError: + return None + + async def deploy_running(self) -> bool: + """True while the flag is set and the path unit is armed or install-updater runs.""" + if not self.deploy_pending(): + return False + ok, out = await _run( + [SYSTEMCTL, "show", *_DEPLOY_UNITS, "-p", "ActiveState", "--value"], + timeout=10.0, + ) + # Unknown state falls back to the flag; the hold cap still bounds it. + return not ok or not _DEPLOY_LIVE_STATES.isdisjoint(out.split()) + async def recover(self, name: str, hard: bool = False) -> bool: """Reset a component to its last known-good hash.""" component = next((c for c in self._components if c.name == name), None)