From e027247aeed29fa2d16f07588b9e7d07ec1908e4 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Tue, 29 Sep 2026 15:30:23 +0100 Subject: [PATCH 01/21] fix(updater): fail health check fast, signal busy during boot provisioning, keep overlay through UI restart --- .../panels/widgets/MainWindow/updatePage.py | 18 +++++++++++++-- updater/dbus_service.py | 2 +- updater/executor.py | 13 +++++++++-- updater/service.py | 22 +++++++++++++------ 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py index 825bd013..b4e3c9db 100644 --- a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py +++ b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py @@ -72,6 +72,7 @@ def __init__(self) -> None: self._update_avail: bool = False self._post_update_status_pending: bool = False self._overlay_shown: bool = False + self._restart_pending: bool = False self._elapsed_time_seconds: int = 0 self._elapsed_timer: QtCore.QTimer = QtCore.QTimer(self) self._elapsed_timer.setSingleShot(False) @@ -335,7 +336,7 @@ def handle_status_ready(self, json_str: str) -> None: self._update_avail = _update_avail if not self._busy: self.show_loading(False) - if self._post_update_status_pending: + if self._post_update_status_pending and not self._restart_pending: _log.debug("status_ready: emitting call_load_panel(False)") self.call_load_panel.emit(False, "", False) self._post_update_status_pending = False @@ -350,6 +351,7 @@ def handle_busy_changed(self, busy: bool) -> None: self._busy = busy self.show_loading(busy) if busy: + self._restart_pending = False self._elapsed_time_seconds = 0 self._elapsed_timer.start() self._busy_timeout_timer.start() @@ -364,11 +366,21 @@ def handle_busy_changed(self, busy: bool) -> None: self._progress_label.hide() self._cancel_btn.hide() self.update_all_btn.setEnabled(True) - if self._overlay_shown: + if self._restart_pending: + # Keep the overlay up: SIGTERM is imminent, MainWindow would flash. + QtCore.QTimer.singleShot(15000, self._dismiss_after_restart_grace) + elif self._overlay_shown: self._overlay_shown = False self.call_load_panel.emit(False, "", False) self._request_status_debounced() + def _dismiss_after_restart_grace(self) -> None: + """Drop the overlay if the expected UI restart never came.""" + if self._restart_pending and not self._busy: + self._restart_pending = False + self._overlay_shown = False + self.call_load_panel.emit(False, "", False) + @QtCore.pyqtSlot(name="on-update-all-clicked") def on_update_all_clicked(self) -> None: """Guard against updates during a print or with hot heaters; otherwise show confirm dialog.""" @@ -425,6 +437,8 @@ def handle_step_complete(self, name: str, step: int, total: int) -> None: if self._busy_timeout_timer.isActive(): self._busy_timeout_timer.start() self._overlay_shown = True + # BlocksScreen's last step restarts this very process. + self._restart_pending = name == "BlocksScreen" and step == total overlay_msg = f"{name}: {label}" self._progress_label.setText(f"Step {step}/{total}") self.call_load_panel.emit(True, overlay_msg, False) diff --git a/updater/dbus_service.py b/updater/dbus_service.py index 3e1b6b14..123c5625 100644 --- a/updater/dbus_service.py +++ b/updater/dbus_service.py @@ -195,7 +195,7 @@ async def _periodic_status_check(self) -> None: while True: try: await self._emit_status() - if await self._svc.provision_missing(): + if await self._svc.provision_missing(self._set_busy): await self._emit_status() # reflect freshly-installed components except Exception as exc: # noqa: BLE001 _log.error("periodic_check failed: %s", exc) diff --git a/updater/executor.py b/updater/executor.py index 2c171b16..5c9b3176 100644 --- a/updater/executor.py +++ b/updater/executor.py @@ -1034,13 +1034,22 @@ def _http_probe(url: str) -> bool: conn.close() -async def wait_for_http_ready(url: str, timeout: float = 120.0) -> bool: - """Poll a component's loopback health URL until it returns 2xx or timeout.""" +async def wait_for_http_ready( + url: str, timeout: float = 120.0, *, service: str | None = None +) -> bool: + """Poll a health URL until 2xx or timeout; fail fast if `service` leaves active.""" deadline = asyncio.get_running_loop().time() + timeout while True: if await asyncio.to_thread(_http_probe, url): logger.info("health check ok: %s", url) return True + # A crash-looping unit is 'activating', never 'active': don't wait out the timeout. + if ( + service + and not (await _run([SYSTEMCTL, "is-active", service], timeout=10.0))[0] + ): + logger.warning("service %r left active during health check", service) + return False if asyncio.get_running_loop().time() >= deadline: logger.warning("health check timed out after %.0fs: %s", timeout, url) return False diff --git a/updater/service.py b/updater/service.py index 1eb09a88..ca7272e5 100644 --- a/updater/service.py +++ b/updater/service.py @@ -476,8 +476,10 @@ async def _filter_dead_branch_batch(self, batch: list[ComponentConfig]) -> bool: ) return ok - async def provision_missing(self) -> bool: - """Clone absent install_if_missing components at boot (no manual update).""" + async def provision_missing( + self, on_busy: Callable[[bool], None] | None = None + ) -> bool: + """Clone absent install_if_missing components at boot; on_busy brackets the work.""" missing = [ c for c in self._components @@ -492,10 +494,16 @@ async def provision_missing(self) -> bool: if not acquired: self._log.info("provision_missing: update in progress, deferring") return False - for c in missing: - if c.path is None or not c.path.exists(): # recheck under lock - await self._provision_component(c) - provisioned = True + if on_busy: + on_busy(True) # UI shows step_complete only while busy + try: + for c in missing: + if c.path is None or not c.path.exists(): # recheck under lock + await self._provision_component(c) + provisioned = True + finally: + if on_busy: + on_busy(False) return provisioned async def _preflight_fetch( @@ -1982,7 +1990,7 @@ async def _restart_one(self, service: str, health_url: str | None = None) -> boo if not await wait_for_service_active(service, timeout=90.0): self._log.error("%s did not become active after restart", service) return False - if health_url and not await wait_for_http_ready(health_url): + if health_url and not await wait_for_http_ready(health_url, service=service): self._log.error("%s active but health check failed", service) return False self._log.info("%s active after restart", service) From 94606fb634f4f11adf9692bd98f73a4c5c6ca6b1 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Tue, 29 Sep 2026 16:07:33 +0100 Subject: [PATCH 02/21] fix(updater): fail-fast Spoolman health check, busy overlay during provision, no double start --- tests/updater/test_service_unit.py | 38 +++++++++++++++++++++++++++++- updater/executor.py | 16 +++++++++++++ updater/service.py | 20 +++++++++++++++- 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/tests/updater/test_service_unit.py b/tests/updater/test_service_unit.py index b51ff640..ed7ec906 100644 --- a/tests/updater/test_service_unit.py +++ b/tests/updater/test_service_unit.py @@ -1753,10 +1753,12 @@ async def test_provision_waits_for_service_active(self, tmp_path): return_value=(True, ""), ), patch("updater.service.run_hook", return_value=(True, "")), + patch("updater.service.is_service_active", return_value=False), patch("updater.service.restart_service", return_value=(True, "")), patch( "updater.service.wait_for_service_active", return_value=False ) as mock_wait, + patch("updater.service.stop_service", return_value=(True, "")) as mock_stop, patch("updater.service.shutil.rmtree") as mock_rmtree, ): svc = UpdateService(callback=cb) @@ -1764,6 +1766,7 @@ async def test_provision_waits_for_service_active(self, tmp_path): ok = await svc.update_component("newcomp") assert ok is False mock_wait.assert_called_once() + mock_stop.assert_called_once_with("newcomp.service") mock_rmtree.assert_called_once() assert cb.on_error.call_args[0][1] == "restart" @@ -1783,11 +1786,13 @@ async def test_provision_fails_when_health_check_fails(self, tmp_path): return_value=(True, ""), ), patch("updater.service.run_hook", return_value=(True, "")), + patch("updater.service.is_service_active", return_value=False), patch("updater.service.restart_service", return_value=(True, "")), patch("updater.service.wait_for_service_active", return_value=True), patch( "updater.service.wait_for_http_ready", return_value=False ) as mock_health, + patch("updater.service.stop_service", return_value=(True, "")) as mock_stop, patch("updater.service.shutil.rmtree") as mock_rmtree, ): svc = UpdateService(callback=cb) @@ -1795,6 +1800,7 @@ async def test_provision_fails_when_health_check_fails(self, tmp_path): ok = await svc.update_component("newcomp") assert ok is False mock_health.assert_called_once() + mock_stop.assert_called_once_with("newcomp.service") mock_rmtree.assert_called_once() assert cb.on_error.call_args[0][1] == "restart" @@ -1814,6 +1820,7 @@ async def test_provision_succeeds_when_health_ready(self, tmp_path): return_value=(True, ""), ), patch("updater.service.run_hook", return_value=(True, "")), + patch("updater.service.is_service_active", return_value=False), patch("updater.service.restart_service", return_value=(True, "")), patch("updater.service.wait_for_service_active", return_value=True), patch( @@ -1826,10 +1833,39 @@ async def test_provision_succeeds_when_health_ready(self, tmp_path): svc._components = [comp] ok = await svc.update_component("newcomp") assert ok is True - mock_health.assert_called_once_with("http://127.0.0.1:7912/health") + mock_health.assert_called_once_with( + "http://127.0.0.1:7912/health", service="newcomp.service" + ) mock_rmtree.assert_not_called() cb.on_component_done.assert_called_with("newcomp", True) + @pytest.mark.asyncio + async def test_provision_skips_restart_when_hook_started_service(self, tmp_path): + comp = self._comp( + tmp_path, + service="newcomp.service", + health_url="http://127.0.0.1:7912/health", + ) + cb = MagicMock() + with ( + patch("updater.service.git_clone", return_value=(True, "")), + patch("updater.service.git_get_hash", return_value="newhash"), + patch( + "updater.service.UpdateService._install_dependencies", + return_value=(True, ""), + ), + patch("updater.service.run_hook", return_value=(True, "")), + patch("updater.service.is_service_active", return_value=True), + patch("updater.service.restart_service") as mock_restart, + patch("updater.service.wait_for_http_ready", return_value=True), + patch("updater.service.enable_service", return_value=(True, "")), + ): + svc = UpdateService(callback=cb) + svc._components = [comp] + ok = await svc.update_component("newcomp") + assert ok is True + mock_restart.assert_not_called() + @pytest.mark.asyncio async def test_check_status_reports_needs_install(self, tmp_path): comp = self._comp(tmp_path) diff --git a/updater/executor.py b/updater/executor.py index 5c9b3176..3e9e7c35 100644 --- a/updater/executor.py +++ b/updater/executor.py @@ -995,6 +995,13 @@ async def enable_service(name: str | None) -> tuple[bool, str]: return await _run([SUDO, SYSTEMCTL, "enable", name], timeout=15.0) +async def is_service_active(name: str) -> bool: + """One-shot systemctl is-active probe.""" + if not _SERVICE_RE.match(name): + return False + return (await _run([SYSTEMCTL, "is-active", name], timeout=10.0))[0] + + async def wait_for_service_active(name: str, timeout: float = 90.0) -> bool: """Poll systemctl is-active until active or timeout.""" if not _SERVICE_RE.match(name): @@ -1079,6 +1086,15 @@ async def verify_updater_importable(component_path: Path | None) -> bool: return ok +async def stop_service(name: str | None) -> tuple[bool, str]: + """Stop a systemd service.""" + if name is None: + return (False, "service name is None") + if not _SERVICE_RE.match(name): + return (False, f"service name {name!r} is invalid") + return await _run([SUDO, SYSTEMCTL, "stop", name], timeout=30.0) + + async def restart_service(name: str | None) -> tuple[bool, str]: """Restart a systemd service, recovering from a start-limit hit.""" if name is None: diff --git a/updater/service.py b/updater/service.py index ca7272e5..e7b46314 100644 --- a/updater/service.py +++ b/updater/service.py @@ -47,9 +47,11 @@ git_tree_has_path, git_untracked_paths, is_git_repo, + is_service_active, restart_service, restart_service_noblock, run_hook, + stop_service, verify_updater_importable, wait_for_http_ready, wait_for_service_active, @@ -1703,6 +1705,9 @@ async def _remove_clone(self, component: ComponentConfig) -> None: async def _fail_provision(self, component: ComponentConfig, reason: str) -> bool: """Remove the partial clone, log, and report failure.""" + if component.service and reason in ("hook", "restart"): + # Else systemd crash-loops the unit on the deleted dir until StartLimit. + await stop_service(component.service) await self._remove_clone(component) self._history("install_failed", component.name, reason=reason) self._log.warning( @@ -1716,7 +1721,12 @@ async def _provision_restart_service( """Restart+health-check+enable the provisioned service; fail reason or None.""" if not component.service: return None - if not await self._restart_one(component.service, component.health_url): + if await is_service_active(component.service): + # The hook already started it (enable --now): a restart would start it twice. + ok = await self._await_health(component.service, component.health_url) + else: + ok = await self._restart_one(component.service, component.health_url) + if not ok: return "restart" # Enable only after a clean start (no boot-looping failed unit). en_ok, en_err = await enable_service(component.service) @@ -1980,6 +1990,14 @@ async def _stage_component(self, component: ComponentConfig) -> tuple[bool, str] return guard return await self._stage_apply_ref(component, tip) + async def _await_health(self, service: str, health_url: str | None) -> bool: + """Verify an already-running service answers its health URL.""" + if health_url and not await wait_for_http_ready(health_url, service=service): + self._log.error("%s active but health check failed", service) + return False + self._log.info("%s already active, restart skipped", service) + return True + async def _restart_one(self, service: str, health_url: str | None = None) -> bool: """Restart a service and verify it came active (kill-fallback aware).""" self._log.info("restarting %s and waiting for active", service) From 1718e2f571b5b78bbb6574a596c06c2ff8765c61 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Tue, 29 Sep 2026 16:12:15 +0100 Subject: [PATCH 03/21] fix(updater): fail-fast Spoolman health check, no double start, hold overlay until fresh status --- .../panels/widgets/MainWindow/updatePage.py | 13 ++++++-- tests/widgets/test_update_page_unit.py | 32 ++++++++++++++----- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py index b4e3c9db..e195308e 100644 --- a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py +++ b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py @@ -340,6 +340,7 @@ def handle_status_ready(self, json_str: str) -> None: _log.debug("status_ready: emitting call_load_panel(False)") self.call_load_panel.emit(False, "", False) self._post_update_status_pending = False + self._overlay_shown = False else: _log.debug("status_ready: skipping loadscreen dismiss (busy=True)") self.build_cards() @@ -370,10 +371,18 @@ def handle_busy_changed(self, busy: bool) -> None: # Keep the overlay up: SIGTERM is imminent, MainWindow would flash. QtCore.QTimer.singleShot(15000, self._dismiss_after_restart_grace) elif self._overlay_shown: - self._overlay_shown = False - self.call_load_panel.emit(False, "", False) + # Hold the overlay until fresh status lands, else stale cards flash. + self._post_update_status_pending = True + QtCore.QTimer.singleShot(10000, self._dismiss_stale_overlay) self._request_status_debounced() + def _dismiss_stale_overlay(self) -> None: + """Drop the overlay if the post-update status never arrived.""" + if self._overlay_shown and not self._busy: + self._overlay_shown = False + self._post_update_status_pending = False + self.call_load_panel.emit(False, "", False) + def _dismiss_after_restart_grace(self) -> None: """Drop the overlay if the expected UI restart never came.""" if self._restart_pending and not self._busy: diff --git a/tests/widgets/test_update_page_unit.py b/tests/widgets/test_update_page_unit.py index 26980da0..8a6ed50e 100644 --- a/tests/widgets/test_update_page_unit.py +++ b/tests/widgets/test_update_page_unit.py @@ -11,8 +11,12 @@ def page(qapp): """UpdatePage instance with all heavy UI deps mocked.""" patches = [ - patch("BlocksScreen.lib.panels.widgets.MainWindow.updatePage.LoadingOverlayWidget"), - patch("BlocksScreen.lib.panels.widgets.MainWindow.updatePage.BlocksCustomButton"), + patch( + "BlocksScreen.lib.panels.widgets.MainWindow.updatePage.LoadingOverlayWidget" + ), + patch( + "BlocksScreen.lib.panels.widgets.MainWindow.updatePage.BlocksCustomButton" + ), patch("BlocksScreen.lib.panels.widgets.MainWindow.updatePage.IconButton"), ] for p in patches: @@ -307,12 +311,22 @@ def test_false_does_not_emit_call_load_panel_when_no_overlay(self, page, qtbot): with qtbot.assertNotEmitted(page.call_load_panel, wait=200): page.handle_busy_changed(False) - def test_false_emits_call_load_panel_when_overlay_shown(self, page, qtbot): + def test_false_holds_overlay_until_status_ready(self, page, qtbot): page.show_loading = MagicMock() page._overlay_shown = True - with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: + with qtbot.assertNotEmitted(page.call_load_panel, wait=200): page.handle_busy_changed(False) - assert blocker.args == [False, "",False] + assert page._overlay_shown is True + with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: + page.handle_status_ready(_make_payload()) + assert blocker.args == [False, "", False] + assert page._overlay_shown is False + + def test_stale_overlay_fallback_dismisses(self, page, qtbot): + page._overlay_shown = True + page._busy = False + with qtbot.waitSignal(page.call_load_panel, timeout=200): + page._dismiss_stale_overlay() assert page._overlay_shown is False def test_true_starts_elapsed_timer(self, page): @@ -413,13 +427,13 @@ class TestHandleStepComplete: def test_emits_call_load_panel_with_step_message(self, page, qtbot): with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: page.handle_step_complete("klipper", 1, 4) - assert blocker.args == [True, "klipper: fetching",False] + assert blocker.args == [True, "klipper: fetching", False] page._progress_label.setText.assert_called_with("Step 1/4") def test_unknown_steps_falls_back_to_working(self, page, qtbot): with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: page.handle_step_complete("moonraker", 99, 4) - assert blocker.args == [True, "moonraker: working",False] + assert blocker.args == [True, "moonraker: working", False] page._progress_label.setText.assert_called_with("Step 99/4") @@ -498,7 +512,9 @@ def test_bad_payload_keeps_statuses_and_toasts(self, page): class TestConfirmPopupCleanup: def test_second_confirm_deletes_previous_popup(self, page): - with patch("BlocksScreen.lib.panels.widgets.MainWindow.updatePage.BasePopup") as popup_cls: + with patch( + "BlocksScreen.lib.panels.widgets.MainWindow.updatePage.BasePopup" + ) as popup_cls: first = MagicMock() second = MagicMock() popup_cls.side_effect = [first, second] From 87ddc5b0204a7525f9dc4d0b8c71dd6b084eed3b Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Tue, 29 Sep 2026 16:45:05 +0100 Subject: [PATCH 04/21] fix(updater): skip background apt pass when a daemon restart is pending --- tests/updater/test_dbus_service_unit.py | 15 +++++++++++++++ tests/updater/test_executor_unit.py | 4 +++- tests/updater/test_service_unit.py | 3 +++ updater/dbus_service.py | 5 ++++- updater/service.py | 4 ++++ 5 files changed, 29 insertions(+), 2 deletions(-) diff --git a/tests/updater/test_dbus_service_unit.py b/tests/updater/test_dbus_service_unit.py index acc4381f..9940a16f 100644 --- a/tests/updater/test_dbus_service_unit.py +++ b/tests/updater/test_dbus_service_unit.py @@ -422,6 +422,21 @@ async def test_update_all_includes_errored_git_repo(self, svc): assert "RF50-Klipper" in called_with assert "klipper" not in called_with # clean repo not updated + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("restart_pending", "apt_spawned"), [(True, False), (False, True)] + ) + async def test_background_apt_skipped_when_daemon_restart_pending( + self, svc, restart_pending, apt_spawned + ): + """A pending daemon restart would SIGKILL apt mid-run, so the pass is skipped.""" + svc._svc.check_status = AsyncMock(return_value={}) + svc._svc.background_apt_upgrade = AsyncMock() + svc._svc.daemon_restart_pending = restart_pending + await svc._run_update_all() + await asyncio.sleep(0) # let a spawned task run + assert svc._svc.background_apt_upgrade.called is apt_spawned + class TestLockHeldSurfacesError: def _held_lock(self): diff --git a/tests/updater/test_executor_unit.py b/tests/updater/test_executor_unit.py index f0f53d2a..be1ffbcc 100644 --- a/tests/updater/test_executor_unit.py +++ b/tests/updater/test_executor_unit.py @@ -1382,7 +1382,9 @@ async def test_returns_true_on_2xx(self): @pytest.mark.asyncio async def test_times_out_when_never_ready(self): with patch("updater.executor._http_probe", return_value=False): - assert await wait_for_http_ready("http://127.0.0.1:7912/x", timeout=0) is False + assert ( + await wait_for_http_ready("http://127.0.0.1:7912/x", timeout=0) is False + ) @pytest.mark.asyncio async def test_polls_until_ready(self): diff --git a/tests/updater/test_service_unit.py b/tests/updater/test_service_unit.py index ed7ec906..367932c7 100644 --- a/tests/updater/test_service_unit.py +++ b/tests/updater/test_service_unit.py @@ -2397,6 +2397,7 @@ async def test_install_touches_deploy_flag_not_restart(self, tmp_path: Path): mock_restart.assert_not_called() mock_verify.assert_not_called() assert not sentinel.exists() # consumed + assert svc.daemon_restart_pending is True @pytest.mark.asyncio async def test_code_restarts_only_when_importable(self, tmp_path: Path): @@ -2413,6 +2414,7 @@ async def test_code_restarts_only_when_importable(self, tmp_path: Path): svc = self._svc_with_ui() await svc._apply_deferred_restart() mock_restart.assert_called_once_with("BlocksScreen-updater.service") + assert svc.daemon_restart_pending is True @pytest.mark.asyncio async def test_code_skips_restart_when_not_importable(self, tmp_path: Path): @@ -2430,6 +2432,7 @@ async def test_code_skips_restart_when_not_importable(self, tmp_path: Path): svc = self._svc_with_ui() await svc._apply_deferred_restart() mock_restart.assert_not_called() + assert svc.daemon_restart_pending is False def test_read_clear_sentinel_install_outranks_code(self, tmp_path: Path): sentinel = tmp_path / "updater-restart-needed" diff --git a/updater/dbus_service.py b/updater/dbus_service.py index 123c5625..e661fbeb 100644 --- a/updater/dbus_service.py +++ b/updater/dbus_service.py @@ -275,7 +275,10 @@ async def _run_update_all(self) -> None: self._update_all_locked, "update_all", "updater" ) # Silent apt pass only if we held the lock; else the CLI run owns apt. - if ran: + if ran and self._svc.daemon_restart_pending: + # A SIGKILL from the restart could land inside dpkg; the next poll re-offers the packages. + _log.info("background apt upgrade skipped: daemon restart pending") + elif ran: self._spawn( self._svc.background_apt_upgrade(), name="background_apt_upgrade" ) diff --git a/updater/service.py b/updater/service.py index e7b46314..ca4d6abb 100644 --- a/updater/service.py +++ b/updater/service.py @@ -260,6 +260,8 @@ def __init__(self, callback: ProgressCallback | None = None) -> None: self._log = logging.getLogger("updater") # Self-heal: trailing-window sample ring for crash-loop detection. self._nrestarts_samples: dict[str, list[tuple[float, int]]] = {} + # Set once this daemon is about to be stopped, so no apt child gets SIGKILLed with it. + self.daemon_restart_pending = False def has_component(self, name: str) -> bool: """Return True if a component with the given name is registered.""" @@ -1005,6 +1007,7 @@ async def _apply_deferred_restart(self) -> None: "(install-updater runs out-of-band)" ) await asyncio.to_thread(self._touch_deploy_flag) + self.daemon_restart_pending = True return comp = next( (c for c in self._components if c.service in _FIRE_AND_FORGET_SERVICES), @@ -1022,6 +1025,7 @@ async def _apply_deferred_restart(self) -> None: UPDATER_SERVICE, ) await restart_service_noblock(UPDATER_SERVICE) + self.daemon_restart_pending = True except Exception: # noqa: BLE001 self._log.error("deferred restart handling failed", exc_info=True) From d804063afbb6d0ff8ce4d22161bd22f897ab2e2e Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Tue, 29 Sep 2026 16:53:55 +0100 Subject: [PATCH 05/21] fix(updater): re-enable BlocksScreen.service after unit conversion --- scripts/install-updater.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/install-updater.sh b/scripts/install-updater.sh index 3921f32c..5e8e6b29 100755 --- a/scripts/install-updater.sh +++ b/scripts/install-updater.sh @@ -133,6 +133,8 @@ elif [[ "$(readlink -f "$_BS_SVC_DEST" 2>/dev/null)" != "$(readlink -f "$_BS_SVC sudo systemctl unmask BlocksScreen.service 2>/dev/null || true fi sudo systemctl daemon-reload +# A linked-but-not-enabled UI unit never starts at boot: blank screen and no SSH recovery. +sudo systemctl enable BlocksScreen.service 2>/dev/null || echo_info "WARN: could not enable BlocksScreen.service" echo_ok "BlocksScreen.service is a symlink - hook no longer needs sudo cp" echo_info "Setting up apt cache directory for blocks user ..." From c4f598f2332b94791d3b879064157c0235200de5 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Tue, 29 Sep 2026 17:08:06 +0100 Subject: [PATCH 06/21] fix(updater): show overlay before MainWindow at boot provision, skip apt on restart, enable UI unit --- tests/updater/conftest.py | 3 ++ tests/updater/test_dbus_service_unit.py | 40 +++++++++++++++++++++++++ updater/dbus_service.py | 19 +++++++++--- updater/service.py | 18 +++++++---- 4 files changed, 71 insertions(+), 9 deletions(-) diff --git a/tests/updater/conftest.py b/tests/updater/conftest.py index 3afde671..aa5f751f 100644 --- a/tests/updater/conftest.py +++ b/tests/updater/conftest.py @@ -49,6 +49,8 @@ def svc(): ) mock_svc.recover = AsyncMock() mock_svc.has_fetch_failures = MagicMock(return_value=False) + mock_svc.needs_provision = MagicMock(return_value=False) + mock_svc.provision_missing = AsyncMock(return_value=False) mock_svc._components = [ ComponentConfig(name="moonraker", kind="git"), ComponentConfig(name="klipper", kind="git"), @@ -63,6 +65,7 @@ def svc(): s = UpdaterDbusService.__new__(UpdaterDbusService) s._svc = mock_svc s._busy = False + s._boot_busy = False s._background_tasks = set() s._status_check_in_progress = False s._status_pending = False diff --git a/tests/updater/test_dbus_service_unit.py b/tests/updater/test_dbus_service_unit.py index 9940a16f..495c4d69 100644 --- a/tests/updater/test_dbus_service_unit.py +++ b/tests/updater/test_dbus_service_unit.py @@ -349,6 +349,46 @@ async def test_periodic_check_never_lengthens_a_short_poll_interval(self, svc): assert sleeps == [3.0, 42.0] +class TestBootProvisionBusy: + def _build(self, missing): + from updater import dbus_service + + mock_svc = MagicMock() + mock_svc.needs_provision.return_value = missing + with ( + patch.object(dbus_service, "UpdateService", return_value=mock_svc), + patch.object(dbus_service.UpdaterDbusService, "_spawn", MagicMock()), + ): + return dbus_service.UpdaterDbusService() + + @pytest.mark.parametrize("missing", [True, False]) + def test_busy_at_construction_iff_component_missing(self, missing): + """Busy must be set before export so the UI's get_busy on connect sees the provision.""" + assert self._build(missing)._busy is missing + + @pytest.mark.asyncio + async def test_boot_busy_skips_initial_sleep_and_releases(self, svc): + """Missing component: provision runs at once (no 3 s sleep), then busy drops.""" + from updater import dbus_service + + svc._boot_busy = svc._busy = True + sleeps: list[float] = [] + + async def fake_sleep(delay): + sleeps.append(delay) + raise asyncio.CancelledError + + with ( + patch.object(dbus_service.asyncio, "sleep", fake_sleep), + pytest.raises(asyncio.CancelledError), + ): + await svc._periodic_status_check() + + assert sleeps == [svc._svc.poll_interval] + assert svc._boot_busy is False + assert svc._busy is False + + class TestMethodReturnValues: @pytest.mark.asyncio async def test_update_all_rejected_when_busy_returns_false(self, svc): diff --git a/updater/dbus_service.py b/updater/dbus_service.py index e661fbeb..8af2186e 100644 --- a/updater/dbus_service.py +++ b/updater/dbus_service.py @@ -102,7 +102,9 @@ def __init__(self) -> None: """Wire the service and busy state, then spawn the boot, poll, and self-heal tasks.""" super().__init__() self._svc = UpdateService(callback=DbusProgressCallback(self)) - self._busy: bool = False + # Busy before export so the UI's get_busy on connect sees a boot provision, not a MainWindow flash. + self._boot_busy: bool = self._svc.needs_provision() + self._busy: bool = self._boot_busy self._background_tasks: set[asyncio.Task] = set() self._status_check_in_progress: bool = False self._status_pending: bool = False @@ -129,6 +131,12 @@ def _task_done(self, task: asyncio.Task) -> None: if exc is not None: _log.error("task %r failed", task.get_name(), exc_info=exc) + def _release_boot_busy(self) -> None: + """Drop the busy state pre-set at boot; no await between this and provision's own busy(False).""" + if self._boot_busy: + self._boot_busy = False + self._set_busy(False) + def _set_busy(self, busy: bool) -> None: """Emit busy_changed only on state transitions to avoid redundant signals.""" if busy != self._busy: @@ -191,14 +199,17 @@ async def _emit_status(self, force: bool = False) -> None: async def _periodic_status_check(self) -> None: """Emit status shortly after startup, then at the poll interval - or sooner while fetches fail.""" - await asyncio.sleep(3.0) + if not self._boot_busy: + await asyncio.sleep(3.0) while True: try: + # Provision first (a no-op stat when nothing is missing) so status reflects it. + await self._svc.provision_missing(self._set_busy) + self._release_boot_busy() await self._emit_status() - if await self._svc.provision_missing(self._set_busy): - await self._emit_status() # reflect freshly-installed components except Exception as exc: # noqa: BLE001 _log.error("periodic_check failed: %s", exc) + self._release_boot_busy() interval = self._svc.poll_interval if self._svc.has_fetch_failures(): interval = min(_FETCH_RETRY_INTERVAL_S, interval) diff --git a/updater/service.py b/updater/service.py index ca4d6abb..e5fd9aaf 100644 --- a/updater/service.py +++ b/updater/service.py @@ -480,17 +480,25 @@ async def _filter_dead_branch_batch(self, batch: list[ComponentConfig]) -> bool: ) return ok - async def provision_missing( - self, on_busy: Callable[[bool], None] | None = None - ) -> bool: - """Clone absent install_if_missing components at boot; on_busy brackets the work.""" - missing = [ + def _missing_provisions(self) -> list[ComponentConfig]: + """install_if_missing components whose directory is absent.""" + return [ c for c in self._components if c.install_if_missing and c.url and (c.path is None or not c.path.exists()) ] + + def needs_provision(self) -> bool: + """True if provision_missing() would clone something (cheap filesystem check).""" + return bool(self._missing_provisions()) + + async def provision_missing( + self, on_busy: Callable[[bool], None] | None = None + ) -> bool: + """Clone absent install_if_missing components at boot; on_busy brackets the work.""" + missing = self._missing_provisions() if not missing: return False provisioned = False From 394476ddb95a6d187067e2bf975d3c102c28023b Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Tue, 29 Sep 2026 17:23:54 +0100 Subject: [PATCH 07/21] fix(updater): retry boot provisioning while reconcile holds the process lock --- tests/updater/test_dbus_service_unit.py | 31 +++++++++++++++++++++++++ updater/dbus_service.py | 14 ++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/tests/updater/test_dbus_service_unit.py b/tests/updater/test_dbus_service_unit.py index 495c4d69..d5c68021 100644 --- a/tests/updater/test_dbus_service_unit.py +++ b/tests/updater/test_dbus_service_unit.py @@ -389,6 +389,37 @@ async def fake_sleep(delay): assert svc._busy is False +class TestProvisionRetry: + @pytest.mark.asyncio + async def test_retries_while_lock_defers_then_stops(self, svc): + """Deferred provisioning (lock held by boot reconcile) is retried, not left for the next poll.""" + from updater import dbus_service + + svc._svc.needs_provision = MagicMock(side_effect=[True, True, False]) + sleeps: list[float] = [] + + async def fake_sleep(delay): + sleeps.append(delay) + + with patch.object(dbus_service.asyncio, "sleep", fake_sleep): + await svc._provision_with_retry() + + assert svc._svc.provision_missing.await_count == 3 + assert sleeps == [dbus_service._PROVISION_RETRY_S] * 2 + + @pytest.mark.asyncio + async def test_gives_up_after_bounded_retries(self, svc): + """A component that never provisions must not loop forever.""" + from updater import dbus_service + + svc._svc.needs_provision = MagicMock(return_value=True) + + with patch.object(dbus_service.asyncio, "sleep", AsyncMock()): + await svc._provision_with_retry() + + assert svc._svc.provision_missing.await_count == dbus_service._PROVISION_RETRIES + + class TestMethodReturnValues: @pytest.mark.asyncio async def test_update_all_rejected_when_busy_returns_false(self, svc): diff --git a/updater/dbus_service.py b/updater/dbus_service.py index 8af2186e..94576be7 100644 --- a/updater/dbus_service.py +++ b/updater/dbus_service.py @@ -20,6 +20,9 @@ _STATUS_PATH = Path("/run/blockscreen/updater_status.json") # Poll again this soon while a git fetch is failing: a boot-time DNS miss must not hide updates for a full poll interval. _FETCH_RETRY_INTERVAL_S = 300.0 +# Boot reconcile holds the process lock briefly; provisioning is deferred, not lost. +_PROVISION_RETRIES = 10 +_PROVISION_RETRY_S = 3.0 class DbusProgressCallback: @@ -131,6 +134,15 @@ def _task_done(self, task: asyncio.Task) -> None: if exc is not None: _log.error("task %r failed", task.get_name(), exc_info=exc) + async def _provision_with_retry(self) -> None: + """Retry while boot reconcile still holds the process lock and defers provisioning.""" + for attempt in range(_PROVISION_RETRIES): + await self._svc.provision_missing(self._set_busy) + if not self._svc.needs_provision(): + return + if attempt + 1 < _PROVISION_RETRIES: + await asyncio.sleep(_PROVISION_RETRY_S) + def _release_boot_busy(self) -> None: """Drop the busy state pre-set at boot; no await between this and provision's own busy(False).""" if self._boot_busy: @@ -204,7 +216,7 @@ async def _periodic_status_check(self) -> None: while True: try: # Provision first (a no-op stat when nothing is missing) so status reflects it. - await self._svc.provision_missing(self._set_busy) + await self._provision_with_retry() self._release_boot_busy() await self._emit_status() except Exception as exc: # noqa: BLE001 From d13b4075510690a08e1675c0f4b38b6da9456869 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Tue, 29 Sep 2026 17:31:28 +0100 Subject: [PATCH 08/21] feat(update): show "Missing component, installing" overlay during boot provisioning --- .../panels/widgets/MainWindow/updatePage.py | 22 +++++++++++++++++- tests/widgets/test_update_page_unit.py | 23 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py index e195308e..9f842d02 100644 --- a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py +++ b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py @@ -46,6 +46,13 @@ class UpdatePage(QtWidgets.QWidget): } ) + # Boot provisioning of a missing component reuses steps 1-4 with different meanings. + _PROVISION_STEP_LABELS: typing.ClassVar[MappingProxyType[int, str]] = ( + MappingProxyType( + {1: "cloning", 2: "installing deps", 3: "setting up", 4: "starting"} + ) + ) + _APT_STEP_LABELS: typing.ClassVar[MappingProxyType[int, str]] = MappingProxyType( {1: "updating packages", 2: "upgrading packages"} ) @@ -73,6 +80,7 @@ def __init__(self) -> None: self._post_update_status_pending: bool = False self._overlay_shown: bool = False self._restart_pending: bool = False + self._provisioning: bool = False self._elapsed_time_seconds: int = 0 self._elapsed_timer: QtCore.QTimer = QtCore.QTimer(self) self._elapsed_timer.setSingleShot(False) @@ -352,6 +360,13 @@ def handle_busy_changed(self, busy: bool) -> None: self._busy = busy self.show_loading(busy) if busy: + # Busy with no user press = the daemon is installing a missing component. + self._provisioning = not self._overlay_shown + if self._provisioning: + self._overlay_shown = True + self.call_load_panel.emit( + True, "Missing component, installing ...", False + ) self._restart_pending = False self._elapsed_time_seconds = 0 self._elapsed_timer.start() @@ -361,6 +376,7 @@ def handle_busy_changed(self, busy: bool) -> None: self._progress_label.show() self._cancel_btn.show() else: + self._provisioning = False self._elapsed_timer.stop() self._busy_timeout_timer.stop() self._elapsed_time_label.hide() @@ -439,6 +455,8 @@ def handle_step_complete(self, name: str, step: int, total: int) -> None: status = self._statuses.get(name) if status and status.kind == "apt": label = self._APT_STEP_LABELS.get(step, "working") + elif self._provisioning: + label = self._PROVISION_STEP_LABELS.get(step, "working") else: label = self._STEP_LABELS.get(step, "working") _log.info("step_complete: %s %d/%d (%s)", name, step, total, label) @@ -448,7 +466,9 @@ def handle_step_complete(self, name: str, step: int, total: int) -> None: self._overlay_shown = True # BlocksScreen's last step restarts this very process. self._restart_pending = name == "BlocksScreen" and step == total - overlay_msg = f"{name}: {label}" + overlay_msg = ( + f"Installing {name}: {label}" if self._provisioning else f"{name}: {label}" + ) self._progress_label.setText(f"Step {step}/{total}") self.call_load_panel.emit(True, overlay_msg, False) diff --git a/tests/widgets/test_update_page_unit.py b/tests/widgets/test_update_page_unit.py index 8a6ed50e..31d5b53d 100644 --- a/tests/widgets/test_update_page_unit.py +++ b/tests/widgets/test_update_page_unit.py @@ -522,3 +522,26 @@ def test_second_confirm_deletes_previous_popup(self, page): page._show_update_confirm() first.deleteLater.assert_called_once() second.deleteLater.assert_not_called() + + +class TestBootProvisioning: + def test_busy_without_user_press_shows_installing_message(self, page, qtbot): + page.show_loading = MagicMock() + with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: + page.handle_busy_changed(True) + assert blocker.args == [True, "Missing component, installing ...", False] + + def test_provision_steps_name_the_component(self, page, qtbot): + page.show_loading = MagicMock() + page.handle_busy_changed(True) + with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: + page.handle_step_complete("Spoolman", 1, 4) + assert blocker.args == [True, "Installing Spoolman: cloning", False] + + def test_user_update_keeps_update_labels(self, page, qtbot): + page.show_loading = MagicMock() + page._overlay_shown = True + page.handle_busy_changed(True) + with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: + page.handle_step_complete("klipper", 1, 4) + assert blocker.args == [True, "klipper: fetching", False] From ea80663361c929ea9ef51351b3a3dffc5f2734d3 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Tue, 29 Sep 2026 17:40:54 +0100 Subject: [PATCH 09/21] fix(update): replay busy state after wiring so boot provisioning shows its overlay text --- BlocksScreen/lib/panels/mainWindow.py | 1 + .../lib/panels/widgets/MainWindow/updatePage.py | 2 +- BlocksScreen/lib/updater_worker.py | 9 +++++++++ tests/lib/test_updater_worker_unit.py | 12 ++++++++++++ tests/widgets/test_update_page_unit.py | 6 ++++++ 5 files changed, 29 insertions(+), 1 deletion(-) diff --git a/BlocksScreen/lib/panels/mainWindow.py b/BlocksScreen/lib/panels/mainWindow.py index 28b4f74d..5bbbd0b1 100644 --- a/BlocksScreen/lib/panels/mainWindow.py +++ b/BlocksScreen/lib/panels/mainWindow.py @@ -283,6 +283,7 @@ def __init__(self): self.controlPanel.disable_popups.connect(self.popup_toggle) self.updater_worker.status_ready.connect(self.update_page.handle_status_ready) self.updater_worker.busy_changed.connect(self.update_page.handle_busy_changed) + self.updater_worker.replay_busy() self.updater_worker.daemon_unavailable.connect(self.on_updater_unavailable) self.updater_worker.daemon_unavailable.connect( self.update_page.handle_daemon_unavailable diff --git a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py index 9f842d02..f9434cf9 100644 --- a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py +++ b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py @@ -361,7 +361,7 @@ def handle_busy_changed(self, busy: bool) -> None: self.show_loading(busy) if busy: # Busy with no user press = the daemon is installing a missing component. - self._provisioning = not self._overlay_shown + self._provisioning = self._provisioning or not self._overlay_shown if self._provisioning: self._overlay_shown = True self.call_load_panel.emit( diff --git a/BlocksScreen/lib/updater_worker.py b/BlocksScreen/lib/updater_worker.py index 58ee2e1a..de23f79a 100644 --- a/BlocksScreen/lib/updater_worker.py +++ b/BlocksScreen/lib/updater_worker.py @@ -77,6 +77,8 @@ def __init__(self) -> None: self._last_activity: float = 0.0 # Unique bus name of the live daemon; a change means it restarted. self._daemon_owner: str = "" + # Latest busy state, for replay_busy(); the worker thread runs before MainWindow wires slots. + self._last_busy: bool = False self._owner_task: asyncio.Task | None = None self._escalated: bool = False # Serializes the reconnect and owner-watch entry points into _connect(). @@ -218,12 +220,18 @@ async def _connect(self) -> None: else: self._busy_false_event.set() _log.info("connected to owner %s, busy=%s", self._daemon_owner, busy) + self._last_busy = busy self.busy_changed.emit(busy) if not busy: self.request_reconnect.emit() self.proxy_connected.emit() + def replay_busy(self) -> None: + """Re-emit busy=True once slots are wired; the connect-time emit can fire before they are.""" + if self._last_busy: + self.busy_changed.emit(True) + def _on_listener_done(self, task: asyncio.Task) -> None: """Emit daemon_unavailable and schedule reconnect if a listener exits unexpectedly.""" if task.cancelled(): @@ -591,6 +599,7 @@ async def _listen_busy_changed(self) -> None: async for busy in self._proxy.busy_changed: _log.info("busy_changed received: %s", busy) self._touch_activity() + self._last_busy = busy if busy: self._busy_false_event.clear() task = asyncio.create_task(self._busy_watchdog(), name="busy_watchdog") diff --git a/tests/lib/test_updater_worker_unit.py b/tests/lib/test_updater_worker_unit.py index 0f9ba42d..3e07e474 100644 --- a/tests/lib/test_updater_worker_unit.py +++ b/tests/lib/test_updater_worker_unit.py @@ -30,6 +30,7 @@ def _make_worker(): w._last_activity = 0.0 w._proxy = MagicMock() w._shutting_down = False + w._last_busy = False w._daemon_owner = "" w._owner_task = None w._escalated = False @@ -510,3 +511,14 @@ def test_shutdown_cancels_owner_watch(self, worker): worker.shutdown() owner_task.cancel.assert_called_once() listener.cancel.assert_called_once() + + +class TestReplayBusy: + def test_replays_true_only(self, worker, qtbot): + received: list[bool] = [] + worker.busy_changed.connect(received.append) + worker.replay_busy() + assert received == [] + worker._last_busy = True + worker.replay_busy() + assert received == [True] diff --git a/tests/widgets/test_update_page_unit.py b/tests/widgets/test_update_page_unit.py index 31d5b53d..b5ab1207 100644 --- a/tests/widgets/test_update_page_unit.py +++ b/tests/widgets/test_update_page_unit.py @@ -545,3 +545,9 @@ def test_user_update_keeps_update_labels(self, page, qtbot): with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: page.handle_step_complete("klipper", 1, 4) assert blocker.args == [True, "klipper: fetching", False] + + def test_replayed_busy_keeps_provisioning(self, page): + page.show_loading = MagicMock() + page.handle_busy_changed(True) + page.handle_busy_changed(True) + assert page._provisioning is True From ba12fddfc28c7de9dc98317504c76e7964a5cdb7 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Wed, 30 Sep 2026 16:38:26 +0100 Subject: [PATCH 10/21] fix(updater): retry provisioning only when deferred, daemon-declared install overlay, hold UI overlay on restart --- BlocksScreen/lib/panels/mainWindow.py | 3 + .../panels/widgets/MainWindow/updatePage.py | 35 ++++++----- BlocksScreen/lib/updater_worker.py | 28 ++++++++- scripts/install-updater.sh | 7 ++- tests/lib/test_updater_worker_unit.py | 25 ++++++++ tests/updater/conftest.py | 2 + tests/updater/test_dbus_service_unit.py | 44 +++++++++++++- tests/updater/test_executor_unit.py | 9 +++ tests/updater/test_service_unit.py | 60 +++++++++++++++---- tests/widgets/test_update_page_unit.py | 52 +++++++++++----- updater/dbus_service.py | 44 ++++++++++---- updater/executor.py | 15 ++--- updater/service.py | 41 ++++++++----- 13 files changed, 281 insertions(+), 84 deletions(-) diff --git a/BlocksScreen/lib/panels/mainWindow.py b/BlocksScreen/lib/panels/mainWindow.py index 5bbbd0b1..c27a1965 100644 --- a/BlocksScreen/lib/panels/mainWindow.py +++ b/BlocksScreen/lib/panels/mainWindow.py @@ -283,6 +283,9 @@ def __init__(self): self.controlPanel.disable_popups.connect(self.popup_toggle) self.updater_worker.status_ready.connect(self.update_page.handle_status_ready) self.updater_worker.busy_changed.connect(self.update_page.handle_busy_changed) + self.updater_worker.provisioning_changed.connect( + self.update_page.handle_provisioning_changed + ) self.updater_worker.replay_busy() self.updater_worker.daemon_unavailable.connect(self.on_updater_unavailable) self.updater_worker.daemon_unavailable.connect( diff --git a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py index f9434cf9..8b15e4b0 100644 --- a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py +++ b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py @@ -2,6 +2,7 @@ import json import logging +import re import typing from types import MappingProxyType @@ -15,6 +16,12 @@ from updater.models import ComponentStatus _log = logging.getLogger(__name__) +_DESCRIBE_SUFFIX = re.compile(r"-(\d+)-g[0-9a-f]+$") + + +def _compact_version(describe: str) -> str: + """`v1.0.0-12-gabc1234` -> `v1.0.0+12`, so commits past one tag stay distinguishable.""" + return _DESCRIBE_SUFFIX.sub(r"+\1", describe) class UpdatePage(QtWidgets.QWidget): @@ -46,7 +53,6 @@ class UpdatePage(QtWidgets.QWidget): } ) - # Boot provisioning of a missing component reuses steps 1-4 with different meanings. _PROVISION_STEP_LABELS: typing.ClassVar[MappingProxyType[int, str]] = ( MappingProxyType( {1: "cloning", 2: "installing deps", 3: "setting up", 4: "starting"} @@ -121,9 +127,6 @@ def _on_busy_timeout(self) -> None: self._overlay_shown = False self.show_loading(False) self.call_load_panel.emit(False, "", False) - self._show_toast( - "Update is taking longer than expected - tap refresh to check status" - ) def showEvent(self, a0: QtGui.QShowEvent | None) -> None: """Rebuild cards and request a fresh status poll each time the page becomes visible.""" @@ -164,8 +167,8 @@ def _version_string(self, status: ComponentStatus) -> str: return "status error" if status.kind in ("system", "apt"): return "updates available" - current = status.current_version or status.current_hash[:8] - return f"{current} → {status.remote_version or 'unknown'}" + current = _compact_version(status.current_version) or status.current_hash[:8] + return f"{current} → {_compact_version(status.remote_version) or 'unknown'}" def _make_white_label( self, @@ -360,13 +363,8 @@ def handle_busy_changed(self, busy: bool) -> None: self._busy = busy self.show_loading(busy) if busy: - # Busy with no user press = the daemon is installing a missing component. - self._provisioning = self._provisioning or not self._overlay_shown if self._provisioning: - self._overlay_shown = True - self.call_load_panel.emit( - True, "Missing component, installing ...", False - ) + self._show_provisioning_overlay() self._restart_pending = False self._elapsed_time_seconds = 0 self._elapsed_timer.start() @@ -392,6 +390,16 @@ def handle_busy_changed(self, busy: bool) -> None: QtCore.QTimer.singleShot(10000, self._dismiss_stale_overlay) self._request_status_debounced() + def handle_provisioning_changed(self, provisioning: bool) -> None: + """Daemon-declared: the current busy period installs a missing component.""" + self._provisioning = provisioning + if provisioning and self._busy: + self._show_provisioning_overlay() + + def _show_provisioning_overlay(self) -> None: + self._overlay_shown = True + self.call_load_panel.emit(True, "Missing component, installing ...", False) + def _dismiss_stale_overlay(self) -> None: """Drop the overlay if the post-update status never arrived.""" if self._overlay_shown and not self._busy: @@ -464,7 +472,6 @@ def handle_step_complete(self, name: str, step: int, total: int) -> None: if self._busy_timeout_timer.isActive(): self._busy_timeout_timer.start() self._overlay_shown = True - # BlocksScreen's last step restarts this very process. self._restart_pending = name == "BlocksScreen" and step == total overlay_msg = ( f"Installing {name}: {label}" if self._provisioning else f"{name}: {label}" @@ -519,7 +526,7 @@ def handle_daemon_unavailable(self) -> None: self._cancel_btn.hide() self.show_loading(False) self._show_toast( - "Updater unavailable. Check system logs or restart BlocksScreen.", + "Updater unavailable, restarting it automatically ...", success=False, ) self.update_all_btn.setEnabled(False) diff --git a/BlocksScreen/lib/updater_worker.py b/BlocksScreen/lib/updater_worker.py index de23f79a..291a7cba 100644 --- a/BlocksScreen/lib/updater_worker.py +++ b/BlocksScreen/lib/updater_worker.py @@ -37,7 +37,7 @@ def _dbus_daemon(bus: Any) -> Any: _UPDATER_UNIT = "BlocksScreen-updater.service" # Reconnect attempts before asking systemd to start a unit it has given up on. -_ESCALATE_AFTER = 3 +_ESCALATE_AFTER = 2 class UpdaterWorker(QtCore.QObject): @@ -55,6 +55,7 @@ class UpdaterWorker(QtCore.QObject): rollback_done = QtCore.pyqtSignal(str, bool) recover_done = QtCore.pyqtSignal(str, bool) busy_changed = QtCore.pyqtSignal(bool) + provisioning_changed = QtCore.pyqtSignal(bool) daemon_unavailable = QtCore.pyqtSignal() update_rejected = QtCore.pyqtSignal() # daemon refused the request (already busy) request_reconnect = QtCore.pyqtSignal() @@ -77,8 +78,9 @@ def __init__(self) -> None: self._last_activity: float = 0.0 # Unique bus name of the live daemon; a change means it restarted. self._daemon_owner: str = "" - # Latest busy state, for replay_busy(); the worker thread runs before MainWindow wires slots. + # For replay_busy(): this thread starts before MainWindow wires its slots. self._last_busy: bool = False + self._last_provisioning: bool = False self._owner_task: asyncio.Task | None = None self._escalated: bool = False # Serializes the reconnect and owner-watch entry points into _connect(). @@ -188,6 +190,7 @@ async def _connect(self) -> None: self._listen_rollback, self._listen_recover_done, self._listen_busy_changed, + self._listen_provisioning_changed, ] for fn in listeners: task = asyncio.create_task(fn(), name=fn.__name__) @@ -221,14 +224,26 @@ async def _connect(self) -> None: self._busy_false_event.set() _log.info("connected to owner %s, busy=%s", self._daemon_owner, busy) self._last_busy = busy + self._last_provisioning = busy and await self._get_provisioning() + self.provisioning_changed.emit(self._last_provisioning) self.busy_changed.emit(busy) if not busy: self.request_reconnect.emit() self.proxy_connected.emit() + async def _get_provisioning(self) -> bool: + """Daemons predating get_provisioning answer with an error: treat as not provisioning.""" + try: + async with asyncio.timeout(5): + return await self._proxy.get_provisioning() + except (sdbus.SdBusBaseError, TimeoutError): + return False + def replay_busy(self) -> None: - """Re-emit busy=True once slots are wired; the connect-time emit can fire before they are.""" + """Re-emit busy state once slots are wired; the connect-time emit can fire before they are.""" + if self._last_provisioning: + self.provisioning_changed.emit(True) if self._last_busy: self.busy_changed.emit(True) @@ -609,6 +624,13 @@ async def _listen_busy_changed(self) -> None: self._busy_false_event.set() self.busy_changed.emit(busy) + async def _listen_provisioning_changed(self) -> None: + """Forward provisioning_changed signals.""" + async for provisioning in self._proxy.provisioning_changed: + self._touch_activity() + self._last_provisioning = provisioning + self.provisioning_changed.emit(provisioning) + async def _busy_watchdog(self) -> None: """Emit daemon_unavailable after _BUSY_IDLE_LIMIT seconds of daemon silence. diff --git a/scripts/install-updater.sh b/scripts/install-updater.sh index 5e8e6b29..01e290f9 100755 --- a/scripts/install-updater.sh +++ b/scripts/install-updater.sh @@ -4,10 +4,12 @@ set -euo pipefail Red='\033[0;31m' Green='\033[0;32m' Blue='\033[0;34m' +Yellow='\033[0;33m' Normal='\033[0m' echo_info() { printf "${Blue}%s${Normal}\n" "$1"; } echo_ok() { printf "${Green}%s${Normal}\n" "$1"; } +echo_warn() { printf "${Yellow}%s${Normal}\n" "$1"; } echo_error() { printf "${Red}%s${Normal}\n" "$1"; } # Root and blocks both run this: O_CREAT on the other's file in sticky /tmp is denied, a read-only open is not. @@ -125,7 +127,7 @@ _BS_SVC_SRC="$BS_PATH/scripts/BlocksScreen.service" _BS_SVC_DEST="/etc/systemd/system/BlocksScreen.service" if [[ ! -f "$_BS_SVC_SRC" ]]; then # Never remove the running unit without a replacement; the device has no SSH recovery. - echo_info "WARN: $_BS_SVC_SRC missing, leaving existing BlocksScreen.service intact" + echo_warn "$_BS_SVC_SRC missing, leaving existing BlocksScreen.service intact" elif [[ "$(readlink -f "$_BS_SVC_DEST" 2>/dev/null)" != "$(readlink -f "$_BS_SVC_SRC")" ]]; then # Atomic replace via temp symlink + rename: the unit is never absent. sudo ln -sfn "$_BS_SVC_SRC" "${_BS_SVC_DEST}.new" @@ -133,8 +135,7 @@ elif [[ "$(readlink -f "$_BS_SVC_DEST" 2>/dev/null)" != "$(readlink -f "$_BS_SVC sudo systemctl unmask BlocksScreen.service 2>/dev/null || true fi sudo systemctl daemon-reload -# A linked-but-not-enabled UI unit never starts at boot: blank screen and no SSH recovery. -sudo systemctl enable BlocksScreen.service 2>/dev/null || echo_info "WARN: could not enable BlocksScreen.service" +sudo systemctl enable BlocksScreen.service 2>/dev/null || echo_warn "could not enable BlocksScreen.service" echo_ok "BlocksScreen.service is a symlink - hook no longer needs sudo cp" echo_info "Setting up apt cache directory for blocks user ..." diff --git a/tests/lib/test_updater_worker_unit.py b/tests/lib/test_updater_worker_unit.py index 3e07e474..baa9ab92 100644 --- a/tests/lib/test_updater_worker_unit.py +++ b/tests/lib/test_updater_worker_unit.py @@ -31,6 +31,7 @@ def _make_worker(): w._proxy = MagicMock() w._shutting_down = False w._last_busy = False + w._last_provisioning = False w._daemon_owner = "" w._owner_task = None w._escalated = False @@ -522,3 +523,27 @@ def test_replays_true_only(self, worker, qtbot): worker._last_busy = True worker.replay_busy() assert received == [True] + + def test_replays_provisioning_before_busy(self, worker, qtbot): + order: list[str] = [] + worker.provisioning_changed.connect(lambda v: order.append(f"prov={v}")) + worker.busy_changed.connect(lambda v: order.append(f"busy={v}")) + worker._last_busy = worker._last_provisioning = True + worker.replay_busy() + assert order == ["prov=True", "busy=True"] + + +class TestGetProvisioning: + @pytest.mark.asyncio + async def test_old_daemon_without_method_is_not_provisioning(self, worker): + import sdbus + + worker._proxy.get_provisioning = AsyncMock( + side_effect=sdbus.SdBusBaseError("unknown method") + ) + assert await worker._get_provisioning() is False + + @pytest.mark.asyncio + async def test_returns_daemon_answer(self, worker): + worker._proxy.get_provisioning = AsyncMock(return_value=True) + assert await worker._get_provisioning() is True diff --git a/tests/updater/conftest.py b/tests/updater/conftest.py index aa5f751f..a3416c6e 100644 --- a/tests/updater/conftest.py +++ b/tests/updater/conftest.py @@ -66,10 +66,12 @@ def svc(): s._svc = mock_svc s._busy = False s._boot_busy = False + s._provisioning = False s._background_tasks = set() s._status_check_in_progress = False s._status_pending = False s.busy_changed = MagicMock() + s.provisioning_changed = MagicMock() s.status_ready = MagicMock() s.error = MagicMock() return s diff --git a/tests/updater/test_dbus_service_unit.py b/tests/updater/test_dbus_service_unit.py index d5c68021..02661f32 100644 --- a/tests/updater/test_dbus_service_unit.py +++ b/tests/updater/test_dbus_service_unit.py @@ -395,7 +395,7 @@ async def test_retries_while_lock_defers_then_stops(self, svc): """Deferred provisioning (lock held by boot reconcile) is retried, not left for the next poll.""" from updater import dbus_service - svc._svc.needs_provision = MagicMock(side_effect=[True, True, False]) + svc._svc.provision_missing = AsyncMock(side_effect=[True, True, False]) sleeps: list[float] = [] async def fake_sleep(delay): @@ -407,12 +407,25 @@ async def fake_sleep(delay): assert svc._svc.provision_missing.await_count == 3 assert sleeps == [dbus_service._PROVISION_RETRY_S] * 2 + @pytest.mark.asyncio + async def test_failed_install_is_not_retried(self, svc): + """A tried-and-failed install (offline, broken unit) runs once, not 10 times.""" + from updater import dbus_service + + svc._svc.needs_provision = MagicMock(return_value=True) # dir still absent + svc._svc.provision_missing = AsyncMock(return_value=False) + + with patch.object(dbus_service.asyncio, "sleep", AsyncMock()): + await svc._provision_with_retry() + + svc._svc.provision_missing.assert_awaited_once() + @pytest.mark.asyncio async def test_gives_up_after_bounded_retries(self, svc): - """A component that never provisions must not loop forever.""" + """A lock that never frees must not loop forever.""" from updater import dbus_service - svc._svc.needs_provision = MagicMock(return_value=True) + svc._svc.provision_missing = AsyncMock(return_value=True) with patch.object(dbus_service.asyncio, "sleep", AsyncMock()): await svc._provision_with_retry() @@ -420,6 +433,31 @@ async def test_gives_up_after_bounded_retries(self, svc): assert svc._svc.provision_missing.await_count == dbus_service._PROVISION_RETRIES +class TestProvisioningFlag: + def test_provision_busy_emits_provisioning_then_busy(self, svc): + svc._provisioning = False + svc._provision_busy(True) + svc.provisioning_changed.emit.assert_called_once_with((True,)) + svc.busy_changed.emit.assert_called_once_with((True,)) + assert svc._provisioning is True + + def test_boot_release_clears_both(self, svc): + svc._boot_busy = svc._busy = svc._provisioning = True + svc._release_boot_busy() + assert (svc._busy, svc._provisioning) == (False, False) + + @pytest.mark.asyncio + async def test_get_provisioning_reports_flag(self, svc): + svc._provisioning = True + assert await svc.get_provisioning() is True + + @pytest.mark.asyncio + async def test_cancel_ignored_while_provisioning(self, svc): + svc._provisioning = svc._busy = True + await svc.cancel() + assert svc._busy is True + + class TestMethodReturnValues: @pytest.mark.asyncio async def test_update_all_rejected_when_busy_returns_false(self, svc): diff --git a/tests/updater/test_executor_unit.py b/tests/updater/test_executor_unit.py index be1ffbcc..9b5c0bcc 100644 --- a/tests/updater/test_executor_unit.py +++ b/tests/updater/test_executor_unit.py @@ -373,6 +373,15 @@ async def test_uses_custom_ref(self, tmp_path): cmd = exec_mock.call_args.args assert "origin/main" in cmd + @pytest.mark.asyncio + async def test_describes_with_tags_and_hash_fallback(self, tmp_path): + proc = _make_proc(0, b"v1.0.0-12-gabc1234\n", b"") + exec_mock = AsyncMock(return_value=proc) + with patch("asyncio.create_subprocess_exec", exec_mock): + assert await git_describe(tmp_path) == "v1.0.0-12-gabc1234" + cmd = exec_mock.call_args.args + assert "--tags" in cmd and "--always" in cmd + class TestGitResetToHash: @pytest.mark.asyncio diff --git a/tests/updater/test_service_unit.py b/tests/updater/test_service_unit.py index 367932c7..1bbbf183 100644 --- a/tests/updater/test_service_unit.py +++ b/tests/updater/test_service_unit.py @@ -341,6 +341,7 @@ async def test_emits_step_progress_in_order(self, tmp_path): call("klipper", 2, 4), call("klipper", 3, 4), call("klipper", 4, 4), + call("BlocksScreen", 4, 4), # restart_ui: UI holds its overlay ] @pytest.mark.asyncio @@ -1758,7 +1759,7 @@ async def test_provision_waits_for_service_active(self, tmp_path): patch( "updater.service.wait_for_service_active", return_value=False ) as mock_wait, - patch("updater.service.stop_service", return_value=(True, "")) as mock_stop, + patch("updater.service.disable_service", return_value=(True, "")) as mock_stop, patch("updater.service.shutil.rmtree") as mock_rmtree, ): svc = UpdateService(callback=cb) @@ -1792,7 +1793,7 @@ async def test_provision_fails_when_health_check_fails(self, tmp_path): patch( "updater.service.wait_for_http_ready", return_value=False ) as mock_health, - patch("updater.service.stop_service", return_value=(True, "")) as mock_stop, + patch("updater.service.disable_service", return_value=(True, "")) as mock_stop, patch("updater.service.shutil.rmtree") as mock_rmtree, ): svc = UpdateService(callback=cb) @@ -1961,10 +1962,21 @@ async def test_provisions_absent_opted_in_component(self, tmp_path): ): svc = UpdateService() svc._components = [comp] - did = await svc.provision_missing() - assert did is True + deferred = await svc.provision_missing() + assert deferred is False mock_prov.assert_awaited_once_with(comp) + @pytest.mark.asyncio + async def test_failed_install_is_not_reported_as_deferred(self, tmp_path): + comp = self._comp(tmp_path) + with ( + patch("updater.service.process_lock", lambda: nullcontext(True)), + patch.object(UpdateService, "_provision_component", return_value=False), + ): + svc = UpdateService() + svc._components = [comp] + assert await svc.provision_missing() is False + @pytest.mark.asyncio async def test_present_component_is_never_provisioned(self, tmp_path): comp = self._comp(tmp_path) @@ -1975,8 +1987,8 @@ async def test_present_component_is_never_provisioned(self, tmp_path): ): svc = UpdateService() svc._components = [comp] - did = await svc.provision_missing() - assert did is False + deferred = await svc.provision_missing() + assert deferred is False mock_prov.assert_not_called() @pytest.mark.asyncio @@ -1989,8 +2001,8 @@ async def test_not_opted_in_is_skipped(self, tmp_path): ): svc = UpdateService() svc._components = [comp] - did = await svc.provision_missing() - assert did is False + deferred = await svc.provision_missing() + assert deferred is False mock_prov.assert_not_called() @pytest.mark.asyncio @@ -2003,8 +2015,8 @@ async def test_defers_when_process_lock_held(self, tmp_path): ): svc = UpdateService() svc._components = [comp] - did = await svc.provision_missing() - assert did is False + deferred = await svc.provision_missing() + assert deferred is True mock_prov.assert_not_called() @@ -2409,13 +2421,39 @@ async def test_code_restarts_only_when_importable(self, tmp_path: Path): "updater.service.verify_updater_importable", new=AsyncMock(return_value=True), ), - patch("updater.service.restart_service_noblock") as mock_restart, + patch( + "updater.service.restart_service_noblock", return_value=(True, "") + ) as mock_restart, ): svc = self._svc_with_ui() await svc._apply_deferred_restart() mock_restart.assert_called_once_with("BlocksScreen-updater.service") assert svc.daemon_restart_pending is True + @pytest.mark.asyncio + async def test_failed_restart_request_is_not_pending(self, tmp_path: Path): + sentinel = tmp_path / "updater-restart-needed" + sentinel.write_text("code\n") + with ( + patch("updater.service.restart_sentinel_path", return_value=sentinel), + patch( + "updater.service.verify_updater_importable", + new=AsyncMock(return_value=True), + ), + patch( + "updater.service.restart_service_noblock", return_value=(False, "x") + ), + ): + svc = self._svc_with_ui() + await svc._apply_deferred_restart() + assert svc.daemon_restart_pending is False + + def test_restart_pending_expires(self): + svc = self._svc_with_ui() + svc._mark_restart_pending() + with patch("updater.service.time.monotonic", return_value=1e12): + assert svc.daemon_restart_pending is False + @pytest.mark.asyncio async def test_code_skips_restart_when_not_importable(self, tmp_path: Path): """Brick-guard: a broken new updater must not restart the daemon.""" diff --git a/tests/widgets/test_update_page_unit.py b/tests/widgets/test_update_page_unit.py index b5ab1207..80d75eb2 100644 --- a/tests/widgets/test_update_page_unit.py +++ b/tests/widgets/test_update_page_unit.py @@ -11,12 +11,8 @@ def page(qapp): """UpdatePage instance with all heavy UI deps mocked.""" patches = [ - patch( - "BlocksScreen.lib.panels.widgets.MainWindow.updatePage.LoadingOverlayWidget" - ), - patch( - "BlocksScreen.lib.panels.widgets.MainWindow.updatePage.BlocksCustomButton" - ), + patch("BlocksScreen.lib.panels.widgets.MainWindow.updatePage.LoadingOverlayWidget"), + patch("BlocksScreen.lib.panels.widgets.MainWindow.updatePage.BlocksCustomButton"), patch("BlocksScreen.lib.panels.widgets.MainWindow.updatePage.IconButton"), ] for p in patches: @@ -138,6 +134,12 @@ def test_git_falls_back_to_unknown_when_no_remote(self, page): s = _make_status(current_version="v0.1.0", remote_version="") assert page._version_string(s) == "v0.1.0 → unknown" + def test_same_tag_commits_ahead_stay_distinguishable(self, page): + s = _make_status( + current_version="v1.0.0-12-gabc1234", remote_version="v1.0.0-15-gdef5678" + ) + assert page._version_string(s) == "v1.0.0+12 → v1.0.0+15" + def test_system_returns_updates_available(self, page): s = _make_status(kind="system", packages_upgradable=12) assert page._version_string(s) == "updates available" @@ -427,13 +429,13 @@ class TestHandleStepComplete: def test_emits_call_load_panel_with_step_message(self, page, qtbot): with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: page.handle_step_complete("klipper", 1, 4) - assert blocker.args == [True, "klipper: fetching", False] + assert blocker.args == [True, "klipper: fetching",False] page._progress_label.setText.assert_called_with("Step 1/4") def test_unknown_steps_falls_back_to_working(self, page, qtbot): with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: page.handle_step_complete("moonraker", 99, 4) - assert blocker.args == [True, "moonraker: working", False] + assert blocker.args == [True, "moonraker: working",False] page._progress_label.setText.assert_called_with("Step 99/4") @@ -512,9 +514,7 @@ def test_bad_payload_keeps_statuses_and_toasts(self, page): class TestConfirmPopupCleanup: def test_second_confirm_deletes_previous_popup(self, page): - with patch( - "BlocksScreen.lib.panels.widgets.MainWindow.updatePage.BasePopup" - ) as popup_cls: + with patch("BlocksScreen.lib.panels.widgets.MainWindow.updatePage.BasePopup") as popup_cls: first = MagicMock() second = MagicMock() popup_cls.side_effect = [first, second] @@ -525,14 +525,29 @@ def test_second_confirm_deletes_previous_popup(self, page): class TestBootProvisioning: - def test_busy_without_user_press_shows_installing_message(self, page, qtbot): + def test_declared_provisioning_shows_installing_message(self, page, qtbot): page.show_loading = MagicMock() + page.handle_provisioning_changed(True) with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: page.handle_busy_changed(True) assert blocker.args == [True, "Missing component, installing ...", False] + def test_provisioning_after_busy_still_shows_message(self, page, qtbot): + page.show_loading = MagicMock() + page.handle_busy_changed(True) + with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: + page.handle_provisioning_changed(True) + assert blocker.args == [True, "Missing component, installing ...", False] + + def test_undeclared_busy_is_not_an_install(self, page, qtbot): + page.show_loading = MagicMock() + with qtbot.assertNotEmitted(page.call_load_panel, wait=200): + page.handle_busy_changed(True) + assert page._provisioning is False + def test_provision_steps_name_the_component(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_step_complete("Spoolman", 1, 4) @@ -546,8 +561,15 @@ def test_user_update_keeps_update_labels(self, page, qtbot): page.handle_step_complete("klipper", 1, 4) assert blocker.args == [True, "klipper: fetching", False] - def test_replayed_busy_keeps_provisioning(self, page): + def test_provisioning_clears_when_busy_ends(self, page): page.show_loading = MagicMock() + page.handle_provisioning_changed(True) page.handle_busy_changed(True) - page.handle_busy_changed(True) - assert page._provisioning is True + page.handle_busy_changed(False) + assert page._provisioning is False + + +class TestRestartPending: + def test_ui_restart_step_holds_overlay(self, page): + page.handle_step_complete("BlocksScreen", 4, 4) + assert page._restart_pending is True diff --git a/updater/dbus_service.py b/updater/dbus_service.py index 94576be7..1b40d8a8 100644 --- a/updater/dbus_service.py +++ b/updater/dbus_service.py @@ -20,7 +20,7 @@ _STATUS_PATH = Path("/run/blockscreen/updater_status.json") # Poll again this soon while a git fetch is failing: a boot-time DNS miss must not hide updates for a full poll interval. _FETCH_RETRY_INTERVAL_S = 300.0 -# Boot reconcile holds the process lock briefly; provisioning is deferred, not lost. +# Retries while boot reconcile holds the process lock. _PROVISION_RETRIES = 10 _PROVISION_RETRY_S = 3.0 @@ -101,13 +101,19 @@ def busy_changed(self) -> tuple[bool]: """Emitted on True↔False transition only (state-machine guard).""" raise NotImplementedError + @sdbus.dbus_signal_async("b") + def provisioning_changed(self) -> tuple[bool]: + """Emitted on True↔False transition while a missing component is being installed.""" + raise NotImplementedError + def __init__(self) -> None: """Wire the service and busy state, then spawn the boot, poll, and self-heal tasks.""" super().__init__() self._svc = UpdateService(callback=DbusProgressCallback(self)) - # Busy before export so the UI's get_busy on connect sees a boot provision, not a MainWindow flash. + # Set before export so the UI's first get_busy sees a boot install. self._boot_busy: bool = self._svc.needs_provision() self._busy: bool = self._boot_busy + self._provisioning: bool = self._boot_busy self._background_tasks: set[asyncio.Task] = set() self._status_check_in_progress: bool = False self._status_pending: bool = False @@ -135,19 +141,26 @@ def _task_done(self, task: asyncio.Task) -> None: _log.error("task %r failed", task.get_name(), exc_info=exc) async def _provision_with_retry(self) -> None: - """Retry while boot reconcile still holds the process lock and defers provisioning.""" - for attempt in range(_PROVISION_RETRIES): - await self._svc.provision_missing(self._set_busy) - if not self._svc.needs_provision(): + """Retry only while boot reconcile's process lock defers provisioning.""" + for _ in range(_PROVISION_RETRIES): + if not await self._svc.provision_missing(self._provision_busy): return - if attempt + 1 < _PROVISION_RETRIES: - await asyncio.sleep(_PROVISION_RETRY_S) + await asyncio.sleep(_PROVISION_RETRY_S) + + def _provision_busy(self, busy: bool) -> None: + self._set_provisioning(busy) + self._set_busy(busy) def _release_boot_busy(self) -> None: - """Drop the busy state pre-set at boot; no await between this and provision's own busy(False).""" + """Drop the state pre-set at boot.""" if self._boot_busy: self._boot_busy = False - self._set_busy(False) + self._provision_busy(False) + + def _set_provisioning(self, provisioning: bool) -> None: + if provisioning != self._provisioning: + self._provisioning = provisioning + self.provisioning_changed.emit((provisioning,)) def _set_busy(self, busy: bool) -> None: """Emit busy_changed only on state transitions to avoid redundant signals.""" @@ -215,7 +228,6 @@ async def _periodic_status_check(self) -> None: await asyncio.sleep(3.0) while True: try: - # Provision first (a no-op stat when nothing is missing) so status reflects it. await self._provision_with_retry() self._release_boot_busy() await self._emit_status() @@ -299,7 +311,7 @@ async def _run_update_all(self) -> None: ) # Silent apt pass only if we held the lock; else the CLI run owns apt. if ran and self._svc.daemon_restart_pending: - # A SIGKILL from the restart could land inside dpkg; the next poll re-offers the packages. + # A restart SIGKILL could land inside dpkg. _log.info("background apt upgrade skipped: daemon restart pending") elif ran: self._spawn( @@ -348,9 +360,17 @@ async def get_busy(self) -> bool: """D-Bus method: return current busy state so reconnecting clients can sync.""" return self._busy + @sdbus.dbus_method_async(result_signature="b") + async def get_provisioning(self) -> bool: + """D-Bus method: True while a missing component is being installed.""" + return self._provisioning + @sdbus.dbus_method_async() async def cancel(self) -> None: """D-Bus method: cancel the running update or recover task and wait for cleanup.""" + if self._provisioning: + _log.info("cancel() ignored: component install in progress") + return cancelled_tasks: list[asyncio.Task] = [] for task in list(self._background_tasks): name = task.get_name() diff --git a/updater/executor.py b/updater/executor.py index 3e9e7c35..b9a08217 100644 --- a/updater/executor.py +++ b/updater/executor.py @@ -719,8 +719,8 @@ async def git_default_branch(path: Path | None) -> str: async def git_describe(path: Path, ref: str | None = None) -> str: - """Return the nearest tag for ref (or HEAD), or empty string.""" - cmd = [GIT, "describe", "--tags", "--abbrev=0"] + """Return `tag-N-gHASH` (or a bare hash without tags) for ref or HEAD; empty on error.""" + cmd = [GIT, "describe", "--tags", "--always"] if ref: cmd.append(ref) ok, output = await _run(cmd, cwd=path, timeout=10.0) @@ -1051,10 +1051,7 @@ async def wait_for_http_ready( logger.info("health check ok: %s", url) return True # A crash-looping unit is 'activating', never 'active': don't wait out the timeout. - if ( - service - and not (await _run([SYSTEMCTL, "is-active", service], timeout=10.0))[0] - ): + if service and not await is_service_active(service): logger.warning("service %r left active during health check", service) return False if asyncio.get_running_loop().time() >= deadline: @@ -1086,13 +1083,13 @@ async def verify_updater_importable(component_path: Path | None) -> bool: return ok -async def stop_service(name: str | None) -> tuple[bool, str]: - """Stop a systemd service.""" +async def disable_service(name: str | None) -> tuple[bool, str]: + """Stop and disable a systemd service.""" if name is None: return (False, "service name is None") if not _SERVICE_RE.match(name): return (False, f"service name {name!r} is invalid") - return await _run([SUDO, SYSTEMCTL, "stop", name], timeout=30.0) + return await _run([SUDO, SYSTEMCTL, "disable", "--now", name], timeout=30.0) async def restart_service(name: str | None) -> tuple[bool, str]: diff --git a/updater/service.py b/updater/service.py index e5fd9aaf..c2258567 100644 --- a/updater/service.py +++ b/updater/service.py @@ -33,6 +33,7 @@ check_apt_status, check_git_status, classify_apt_error, + disable_service, enable_service, git_checkout, git_clone, @@ -51,7 +52,6 @@ restart_service, restart_service_noblock, run_hook, - stop_service, verify_updater_importable, wait_for_http_ready, wait_for_service_active, @@ -167,6 +167,8 @@ def reset(self) -> None: # Self-heal: the UI component name (components.yaml) that the supervisor watches. _UI_COMPONENT = "BlocksScreen" +# A requested daemon restart that has not happened by now is assumed lost. +_RESTART_PENDING_TTL_S = 600.0 # Marker file proving updater exists: absence at target ref aborts update (lack bricks Type=notify host with no self-heal). _UPDATER_MARKER = "updater/dbus_service.py" # Forward-heal always targets the curated-stable channel, not the configured branch. @@ -260,8 +262,15 @@ def __init__(self, callback: ProgressCallback | None = None) -> None: self._log = logging.getLogger("updater") # Self-heal: trailing-window sample ring for crash-loop detection. self._nrestarts_samples: dict[str, list[tuple[float, int]]] = {} - # Set once this daemon is about to be stopped, so no apt child gets SIGKILLed with it. - self.daemon_restart_pending = False + self._restart_pending_until = 0.0 + + @property + def daemon_restart_pending(self) -> bool: + """True while this daemon is about to be stopped, so no apt child gets SIGKILLed with it.""" + return time.monotonic() < self._restart_pending_until + + def _mark_restart_pending(self) -> None: + self._restart_pending_until = time.monotonic() + _RESTART_PENDING_TTL_S def has_component(self, name: str) -> bool: """Return True if a component with the given name is registered.""" @@ -497,26 +506,24 @@ def needs_provision(self) -> bool: async def provision_missing( self, on_busy: Callable[[bool], None] | None = None ) -> bool: - """Clone absent install_if_missing components at boot; on_busy brackets the work.""" + """Clone absent install_if_missing components; True if deferred by a held lock.""" missing = self._missing_provisions() if not missing: return False - provisioned = False with process_lock() as acquired: if not acquired: self._log.info("provision_missing: update in progress, deferring") - return False + return True if on_busy: - on_busy(True) # UI shows step_complete only while busy + on_busy(True) try: for c in missing: if c.path is None or not c.path.exists(): # recheck under lock await self._provision_component(c) - provisioned = True finally: if on_busy: on_busy(False) - return provisioned + return False async def _preflight_fetch( self, sorted_components: list[ComponentConfig] @@ -802,6 +809,8 @@ async def _finalize_git_batch( ui_services.add(c.service) # klipper/RF50 hold config the UI reads at startup: refresh it too. if any(c.restart_ui for c in alive): + if _UI_SERVICE not in ui_services: + self._cb("on_step", _UI_COMPONENT, 4, 4) # UI holds its overlay ui_services.add(_UI_SERVICE) for svc in ui_services: self._log.info("git batch: fire-and-forget restart of %s (no wait)", svc) @@ -1015,7 +1024,7 @@ async def _apply_deferred_restart(self) -> None: "(install-updater runs out-of-band)" ) await asyncio.to_thread(self._touch_deploy_flag) - self.daemon_restart_pending = True + self._mark_restart_pending() return comp = next( (c for c in self._components if c.service in _FIRE_AND_FORGET_SERVICES), @@ -1032,8 +1041,11 @@ async def _apply_deferred_restart(self) -> None: "deferred: updater code changed, clean self-restart of %s", UPDATER_SERVICE, ) - await restart_service_noblock(UPDATER_SERVICE) - self.daemon_restart_pending = True + ok, err = await restart_service_noblock(UPDATER_SERVICE) + if ok: + self._mark_restart_pending() + else: + self._log.error("daemon restart request failed: %s", err) except Exception: # noqa: BLE001 self._log.error("deferred restart handling failed", exc_info=True) @@ -1718,8 +1730,8 @@ async def _remove_clone(self, component: ComponentConfig) -> None: async def _fail_provision(self, component: ComponentConfig, reason: str) -> bool: """Remove the partial clone, log, and report failure.""" if component.service and reason in ("hook", "restart"): - # Else systemd crash-loops the unit on the deleted dir until StartLimit. - await stop_service(component.service) + # The hook may have enabled it: it would crash-loop on the deleted dir. + await disable_service(component.service) await self._remove_clone(component) self._history("install_failed", component.name, reason=reason) self._log.warning( @@ -2170,6 +2182,7 @@ async def _fire_and_forget_restart(self, component: ComponentConfig) -> None: component.name, _UI_SERVICE, ) + self._cb("on_step", _UI_COMPONENT, 4, 4) # UI holds its overlay await restart_service_noblock(_UI_SERVICE) async def _run_git_update(self, component: ComponentConfig) -> bool: From 0f593f5a95c076fe80cd55a0015bad41ee2e5952 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Thu, 1 Oct 2026 09:08:45 +0100 Subject: [PATCH 11/21] fix(updater): provision missing components once per start, daemon-declared install overlay, hold UI overlay on restart --- tests/updater/conftest.py | 1 + tests/updater/test_dbus_service_unit.py | 38 +++++++++++++++++++++++-- updater/dbus_service.py | 11 ++++--- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/tests/updater/conftest.py b/tests/updater/conftest.py index a3416c6e..14e943bf 100644 --- a/tests/updater/conftest.py +++ b/tests/updater/conftest.py @@ -67,6 +67,7 @@ def svc(): s._busy = False s._boot_busy = False s._provisioning = False + s._provisioned = False s._background_tasks = set() s._status_check_in_progress = False s._status_pending = False diff --git a/tests/updater/test_dbus_service_unit.py b/tests/updater/test_dbus_service_unit.py index 02661f32..061ccdb7 100644 --- a/tests/updater/test_dbus_service_unit.py +++ b/tests/updater/test_dbus_service_unit.py @@ -416,7 +416,7 @@ async def test_failed_install_is_not_retried(self, svc): svc._svc.provision_missing = AsyncMock(return_value=False) with patch.object(dbus_service.asyncio, "sleep", AsyncMock()): - await svc._provision_with_retry() + assert await svc._provision_with_retry() is False svc._svc.provision_missing.assert_awaited_once() @@ -428,10 +428,44 @@ async def test_gives_up_after_bounded_retries(self, svc): svc._svc.provision_missing = AsyncMock(return_value=True) with patch.object(dbus_service.asyncio, "sleep", AsyncMock()): - await svc._provision_with_retry() + assert await svc._provision_with_retry() is True assert svc._svc.provision_missing.await_count == dbus_service._PROVISION_RETRIES + async def _run_polls(self, svc, polls: int) -> None: + from updater import dbus_service + + svc._boot_busy = True # skip the initial 3 s sleep + calls = 0 + + async def fake_sleep(_delay): + nonlocal calls + calls += 1 + if calls >= polls: + raise asyncio.CancelledError + + with ( + patch.object(dbus_service.asyncio, "sleep", fake_sleep), + pytest.raises(asyncio.CancelledError), + ): + await svc._periodic_status_check() + + @pytest.mark.asyncio + async def test_failed_install_not_retried_on_later_polls(self, svc): + """Boot tries once; later polls never re-clone (user Update does).""" + svc._svc.provision_missing = AsyncMock(return_value=False) + await self._run_polls(svc, polls=3) + svc._svc.provision_missing.assert_awaited_once() + + @pytest.mark.asyncio + async def test_still_deferred_after_retries_is_tried_on_next_poll(self, svc): + from updater import dbus_service + + svc._svc.provision_missing = AsyncMock(return_value=True) + with patch.object(dbus_service, "_PROVISION_RETRIES", 1): + await self._run_polls(svc, polls=3) + assert svc._svc.provision_missing.await_count == 2 + class TestProvisioningFlag: def test_provision_busy_emits_provisioning_then_busy(self, svc): diff --git a/updater/dbus_service.py b/updater/dbus_service.py index 1b40d8a8..012cb473 100644 --- a/updater/dbus_service.py +++ b/updater/dbus_service.py @@ -114,6 +114,7 @@ def __init__(self) -> None: self._boot_busy: bool = self._svc.needs_provision() self._busy: bool = self._boot_busy self._provisioning: bool = self._boot_busy + self._provisioned: bool = False self._background_tasks: set[asyncio.Task] = set() self._status_check_in_progress: bool = False self._status_pending: bool = False @@ -140,12 +141,13 @@ def _task_done(self, task: asyncio.Task) -> None: if exc is not None: _log.error("task %r failed", task.get_name(), exc_info=exc) - async def _provision_with_retry(self) -> None: - """Retry only while boot reconcile's process lock defers provisioning.""" + async def _provision_with_retry(self) -> bool: + """Retry while boot reconcile's lock defers provisioning; True if still deferred.""" for _ in range(_PROVISION_RETRIES): if not await self._svc.provision_missing(self._provision_busy): - return + return False await asyncio.sleep(_PROVISION_RETRY_S) + return True def _provision_busy(self, busy: bool) -> None: self._set_provisioning(busy) @@ -228,7 +230,8 @@ async def _periodic_status_check(self) -> None: await asyncio.sleep(3.0) while True: try: - await self._provision_with_retry() + if not self._provisioned: # one attempt per start; user Update retries + self._provisioned = not await self._provision_with_retry() self._release_boot_busy() await self._emit_status() except Exception as exc: # noqa: BLE001 From efee51b56c045d9c8c2e3d305ab45ddda0d88a29 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Thu, 1 Oct 2026 12:40:50 +0100 Subject: [PATCH 12/21] fix(updater): hold overlay across UI restart, hide cancel while provisioning, fix provisioning-state race --- .../panels/widgets/MainWindow/updatePage.py | 22 ++++++-- BlocksScreen/lib/updater_worker.py | 15 +++++- tests/lib/test_updater_worker_unit.py | 28 +++++++++++ tests/widgets/test_update_page_unit.py | 50 ++++++++++++++++++- updater/dbus_service.py | 7 ++- 5 files changed, 112 insertions(+), 10 deletions(-) diff --git a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py index 8b15e4b0..4ec0d64b 100644 --- a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py +++ b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py @@ -96,6 +96,15 @@ def __init__(self) -> None: self._busy_timeout_timer.setSingleShot(True) self._busy_timeout_timer.setInterval(400_000) # 400s > 360s watchdog self._busy_timeout_timer.timeout.connect(self._on_busy_timeout) + # Reusable: a stale singleShot from update N would close update N+1's overlay. + self._restart_grace_timer: QtCore.QTimer = QtCore.QTimer(self) + self._restart_grace_timer.setSingleShot(True) + self._restart_grace_timer.setInterval(15000) + self._restart_grace_timer.timeout.connect(self._dismiss_after_restart_grace) + self._stale_overlay_timer: QtCore.QTimer = QtCore.QTimer(self) + self._stale_overlay_timer.setSingleShot(True) + self._stale_overlay_timer.setInterval(10000) + self._stale_overlay_timer.timeout.connect(self._dismiss_stale_overlay) self._update_confirm_popup: BasePopup | None = None self.show_loading(True) @@ -366,13 +375,16 @@ def handle_busy_changed(self, busy: bool) -> None: if self._provisioning: self._show_provisioning_overlay() self._restart_pending = False + self._restart_grace_timer.stop() + self._stale_overlay_timer.stop() self._elapsed_time_seconds = 0 self._elapsed_timer.start() self._busy_timeout_timer.start() self._elapsed_time_label.show() self._progress_label.setText("") self._progress_label.show() - self._cancel_btn.show() + # The daemon ignores cancel() while installing a component. + self._cancel_btn.setVisible(not self._provisioning) else: self._provisioning = False self._elapsed_timer.stop() @@ -383,17 +395,18 @@ def handle_busy_changed(self, busy: bool) -> None: self.update_all_btn.setEnabled(True) if self._restart_pending: # Keep the overlay up: SIGTERM is imminent, MainWindow would flash. - QtCore.QTimer.singleShot(15000, self._dismiss_after_restart_grace) + self._restart_grace_timer.start() elif self._overlay_shown: # Hold the overlay until fresh status lands, else stale cards flash. self._post_update_status_pending = True - QtCore.QTimer.singleShot(10000, self._dismiss_stale_overlay) + self._stale_overlay_timer.start() self._request_status_debounced() def handle_provisioning_changed(self, provisioning: bool) -> None: """Daemon-declared: the current busy period installs a missing component.""" self._provisioning = provisioning if provisioning and self._busy: + self._cancel_btn.hide() self._show_provisioning_overlay() def _show_provisioning_overlay(self) -> None: @@ -472,7 +485,8 @@ def handle_step_complete(self, name: str, step: int, total: int) -> None: if self._busy_timeout_timer.isActive(): self._busy_timeout_timer.start() self._overlay_shown = True - self._restart_pending = name == "BlocksScreen" and step == total + # Latch: a later step from another component must not re-arm the flash path. + self._restart_pending |= name == "BlocksScreen" and step == total overlay_msg = ( f"Installing {name}: {label}" if self._provisioning else f"{name}: {label}" ) diff --git a/BlocksScreen/lib/updater_worker.py b/BlocksScreen/lib/updater_worker.py index 291a7cba..a00926e4 100644 --- a/BlocksScreen/lib/updater_worker.py +++ b/BlocksScreen/lib/updater_worker.py @@ -81,6 +81,7 @@ def __init__(self) -> None: # For replay_busy(): this thread starts before MainWindow wires its slots. self._last_busy: bool = False self._last_provisioning: bool = False + self._provisioning_signals: int = 0 self._owner_task: asyncio.Task | None = None self._escalated: bool = False # Serializes the reconnect and owner-watch entry points into _connect(). @@ -224,14 +225,23 @@ async def _connect(self) -> None: self._busy_false_event.set() _log.info("connected to owner %s, busy=%s", self._daemon_owner, busy) self._last_busy = busy - self._last_provisioning = busy and await self._get_provisioning() + await self._poll_provisioning(busy) self.provisioning_changed.emit(self._last_provisioning) - self.busy_changed.emit(busy) + self.busy_changed.emit(self._last_busy) if not busy: self.request_reconnect.emit() self.proxy_connected.emit() + async def _poll_provisioning(self, busy: bool) -> None: + """Seed _last_provisioning unless a live signal landed during the poll.""" + seen = self._provisioning_signals + polled = busy and await self._get_provisioning() + if self._provisioning_signals == seen: + self._last_provisioning = polled + else: + _log.info("provisioning signal beat the connect-time poll; keeping it") + async def _get_provisioning(self) -> bool: """Daemons predating get_provisioning answer with an error: treat as not provisioning.""" try: @@ -629,6 +639,7 @@ async def _listen_provisioning_changed(self) -> None: async for provisioning in self._proxy.provisioning_changed: self._touch_activity() self._last_provisioning = provisioning + self._provisioning_signals += 1 self.provisioning_changed.emit(provisioning) async def _busy_watchdog(self) -> None: diff --git a/tests/lib/test_updater_worker_unit.py b/tests/lib/test_updater_worker_unit.py index baa9ab92..72b23038 100644 --- a/tests/lib/test_updater_worker_unit.py +++ b/tests/lib/test_updater_worker_unit.py @@ -32,6 +32,7 @@ def _make_worker(): w._shutting_down = False w._last_busy = False w._last_provisioning = False + w._provisioning_signals = 0 w._daemon_owner = "" w._owner_task = None w._escalated = False @@ -547,3 +548,30 @@ async def test_old_daemon_without_method_is_not_provisioning(self, worker): async def test_returns_daemon_answer(self, worker): worker._proxy.get_provisioning = AsyncMock(return_value=True) assert await worker._get_provisioning() is True + + +class TestPollProvisioning: + @pytest.mark.asyncio + async def test_poll_seeds_the_value(self, worker): + worker._proxy.get_provisioning = AsyncMock(return_value=True) + await worker._poll_provisioning(True) + assert worker._last_provisioning is True + + @pytest.mark.asyncio + async def test_not_busy_skips_the_poll(self, worker): + worker._proxy.get_provisioning = AsyncMock(return_value=True) + await worker._poll_provisioning(False) + assert worker._last_provisioning is False + worker._proxy.get_provisioning.assert_not_called() + + @pytest.mark.asyncio + async def test_signal_during_poll_wins(self, worker): + async def slow_poll(): + # The listener delivers the real transition while the poll is in flight. + worker._last_provisioning = False + worker._provisioning_signals += 1 + return True + + worker._proxy.get_provisioning = slow_poll + await worker._poll_provisioning(True) + assert worker._last_provisioning is False diff --git a/tests/widgets/test_update_page_unit.py b/tests/widgets/test_update_page_unit.py index 80d75eb2..25b9cfcd 100644 --- a/tests/widgets/test_update_page_unit.py +++ b/tests/widgets/test_update_page_unit.py @@ -341,7 +341,7 @@ def test_true_shows_elapsed_time_label_and_cancel_btn(self, page): page.show_loading = MagicMock() page.handle_busy_changed(True) page._elapsed_time_label.show.assert_called_once() - page._cancel_btn.show.assert_called_once() + page._cancel_btn.setVisible.assert_called_once_with(True) def test_false_stops_elapsed_timer(self, page): page.show_loading = MagicMock() @@ -386,7 +386,7 @@ def test_cancel_btn_emits_request_cancel(self, page, qtbot): def test_cancel_btn_visible_only_when_busy(self, page): page.show_loading = MagicMock() page.handle_busy_changed(True) - page._cancel_btn.show.assert_called() + page._cancel_btn.setVisible.assert_called_with(True) page._cancel_btn.reset_mock() page.handle_busy_changed(False) page._cancel_btn.hide.assert_called() @@ -573,3 +573,49 @@ class TestRestartPending: def test_ui_restart_step_holds_overlay(self, page): page.handle_step_complete("BlocksScreen", 4, 4) assert page._restart_pending is True + + def test_later_step_does_not_clear_the_latch(self, page): + page.handle_step_complete("BlocksScreen", 4, 4) + page.handle_step_complete("updater", 2, 4) + assert page._restart_pending is True + + def test_new_busy_period_clears_the_latch(self, page): + page.show_loading = MagicMock() + page.handle_step_complete("BlocksScreen", 4, 4) + page.handle_busy_changed(True) + assert page._restart_pending is False + + +class TestDismissTimers: + def test_busy_true_stops_pending_dismiss_timers(self, page): + page.show_loading = MagicMock() + page._overlay_shown = True + page.handle_busy_changed(True) + page.handle_busy_changed(False) + assert page._stale_overlay_timer.isActive() + page.handle_busy_changed(True) + assert not page._stale_overlay_timer.isActive() + assert not page._restart_grace_timer.isActive() + + def test_restart_grace_uses_the_reusable_timer(self, page): + page.show_loading = MagicMock() + page.handle_busy_changed(True) + page.handle_step_complete("BlocksScreen", 4, 4) + page.handle_busy_changed(False) + assert page._restart_grace_timer.isActive() + assert not page._stale_overlay_timer.isActive() + + +class TestCancelHiddenWhileProvisioning: + def test_provisioning_busy_hides_cancel(self, page): + page.show_loading = MagicMock() + page.handle_provisioning_changed(True) + page.handle_busy_changed(True) + page._cancel_btn.setVisible.assert_called_with(False) + + def test_provisioning_after_busy_hides_cancel(self, page): + page.show_loading = MagicMock() + page.handle_busy_changed(True) + page._cancel_btn.reset_mock() + page.handle_provisioning_changed(True) + page._cancel_btn.hide.assert_called_once() diff --git a/updater/dbus_service.py b/updater/dbus_service.py index 012cb473..5ba3cd9e 100644 --- a/updater/dbus_service.py +++ b/updater/dbus_service.py @@ -230,8 +230,11 @@ async def _periodic_status_check(self) -> None: await asyncio.sleep(3.0) while True: try: - if not self._provisioned: # one attempt per start; user Update retries - self._provisioned = not await self._provision_with_retry() + if not self._provisioned: + # Once per start; re-armed only by a lock deferral or an error. + deferred = await self._provision_with_retry() + self._provisioned = not deferred + _log.info("provisioning pass done (deferred=%s)", deferred) self._release_boot_busy() await self._emit_status() except Exception as exc: # noqa: BLE001 From 3e70ae56970abecfb78ff3c3a03cc5da140c606f Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Thu, 1 Oct 2026 14:18:27 +0100 Subject: [PATCH 13/21] fix(updater): allow sudoers disable --now Spoolman.service for failed-provision cleanup --- scripts/install-updater.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/install-updater.sh b/scripts/install-updater.sh index 01e290f9..a794fd0a 100755 --- a/scripts/install-updater.sh +++ b/scripts/install-updater.sh @@ -101,6 +101,7 @@ _emit_svc_rules BlocksScreen-updater.service # (hooks/Spoolman.sh uses `enable --now`; sudoers args must match exactly). printf 'blocks ALL=(ALL) NOPASSWD: /usr/bin/systemctl enable Spoolman.service\n' >>"$SUDOERS_TMP" printf 'blocks ALL=(ALL) NOPASSWD: /usr/bin/systemctl enable --now Spoolman.service\n' >>"$SUDOERS_TMP" +printf 'blocks ALL=(ALL) NOPASSWD: /usr/bin/systemctl disable --now Spoolman.service\n' >>"$SUDOERS_TMP" if sudo visudo -cf "$SUDOERS_TMP" >/dev/null 2>&1; then sudo install -m 0440 "$SUDOERS_TMP" "$SUDOERS_FILE" echo_ok "Sudoers rules installed" From 1cd2b55290c42b749f16cced441902a3e109fb10 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Thu, 1 Oct 2026 15:36:06 +0100 Subject: [PATCH 14/21] fix(updater): provision after boot reconcile, undo hook-enabled units on failure, revive stopped daemon from UI, trim comments --- .../panels/widgets/MainWindow/updatePage.py | 11 +- BlocksScreen/lib/updater_worker.py | 78 ++------- scripts/install-updater.sh | 6 +- tests/lib/test_updater_worker_unit.py | 7 +- tests/updater/conftest.py | 9 + tests/updater/test_dbus_service_unit.py | 96 +++++------ tests/updater/test_service_unit.py | 79 ++++++++- updater/__init__.py | 6 +- updater/__main__.py | 7 +- updater/components.py | 8 +- updater/components.yaml | 8 +- updater/dbus_service.py | 41 ++--- updater/executor.py | 41 ++--- updater/hooks/BlocksScreen.sh | 5 +- updater/locking.py | 8 +- updater/models.py | 8 +- updater/service.py | 156 ++++++------------ 17 files changed, 252 insertions(+), 322 deletions(-) diff --git a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py index 4ec0d64b..2b2cca3f 100644 --- a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py +++ b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py @@ -161,7 +161,7 @@ def resizeEvent(self, a0: QtGui.QResizeEvent | None) -> None: return super().resizeEvent(a0) def _needs_update(self, status: ComponentStatus) -> bool: - # Mirrors daemon dirty-set: errored git repos self-heal; apt errors don't. + """Mirror the daemon's dirty set: errored git repos count, apt errors don't.""" return bool( status.commits_behind or status.packages_upgradable > 0 @@ -331,7 +331,7 @@ def _toggle_details(self) -> None: ) def handle_status_ready(self, json_str: str) -> None: - """Update component statuses from a JSON payload and refresh the list.""" + """Parse statuses per entry so one bad entry can't blank the list; refresh.""" self.update_all_btn.setEnabled(True) _log.debug("handle_status_ready: busy=%s", self._busy) try: @@ -339,10 +339,8 @@ def handle_status_ready(self, json_str: str) -> None: except (json.JSONDecodeError, TypeError) as exc: _log.error("handle_status_ready: bad payload '%s'", exc) _log.debug(json_str) - # Keep the last good list but tell the user it may be stale. self._show_toast("Status update failed - tap refresh to retry") return - # Build per-component so one malformed entry can't blank the whole list. self._statuses = {} for name, fields in data.items(): try: @@ -443,7 +441,7 @@ def on_update_all_clicked(self) -> None: self._show_update_confirm() def _show_update_confirm(self) -> None: - # Dialogs parented to the page outlive close(); drop the previous one. + """Show the confirm dialog; delete the previous one, which outlives close().""" if self._update_confirm_popup is not None: self._update_confirm_popup.deleteLater() popup = BasePopup(self, floating=True) @@ -485,7 +483,7 @@ def handle_step_complete(self, name: str, step: int, total: int) -> None: if self._busy_timeout_timer.isActive(): self._busy_timeout_timer.start() self._overlay_shown = True - # Latch: a later step from another component must not re-arm the flash path. + # Latched: a later step of another component must not clear it. self._restart_pending |= name == "BlocksScreen" and step == total overlay_msg = ( f"Installing {name}: {label}" if self._provisioning else f"{name}: {label}" @@ -662,7 +660,6 @@ def _setup_ui(self) -> None: self._progress_label.setWordWrap(True) self._progress_label.hide() - # Touch target size: minimum 44×44 px per WCAG; set to 60px tall for comfort self._cancel_btn = BlocksCustomButton(self._loadwidget) self._cancel_btn.setMinimumSize(QtCore.QSize(240, 60)) self._cancel_btn.setMaximumSize(QtCore.QSize(320, 60)) diff --git a/BlocksScreen/lib/updater_worker.py b/BlocksScreen/lib/updater_worker.py index a00926e4..244e92b9 100644 --- a/BlocksScreen/lib/updater_worker.py +++ b/BlocksScreen/lib/updater_worker.py @@ -41,12 +41,7 @@ def _dbus_daemon(bus: Any) -> Any: class UpdaterWorker(QtCore.QObject): - """Async D-Bus client for the blockscreen updater daemon. - - Owns an asyncio event loop on a dedicated daemon thread. - All D-Bus operations execute as coroutines on that loop. - Results are bridged back to Qt via pyqtSignals - """ + """Updater D-Bus client on a private asyncio thread, bridged to Qt via signals.""" status_ready = QtCore.pyqtSignal(str) step_complete = QtCore.pyqtSignal(str, int, int) @@ -57,7 +52,7 @@ class UpdaterWorker(QtCore.QObject): busy_changed = QtCore.pyqtSignal(bool) provisioning_changed = QtCore.pyqtSignal(bool) daemon_unavailable = QtCore.pyqtSignal() - update_rejected = QtCore.pyqtSignal() # daemon refused the request (already busy) + update_rejected = QtCore.pyqtSignal() request_reconnect = QtCore.pyqtSignal() proxy_connected = QtCore.pyqtSignal() @@ -76,7 +71,6 @@ def __init__(self) -> None: self._reconnect_task: asyncio.Task | None = None self._shutting_down: bool = False self._last_activity: float = 0.0 - # Unique bus name of the live daemon; a change means it restarted. self._daemon_owner: str = "" # For replay_busy(): this thread starts before MainWindow wires its slots. self._last_busy: bool = False @@ -84,7 +78,6 @@ def __init__(self) -> None: self._provisioning_signals: int = 0 self._owner_task: asyncio.Task | None = None self._escalated: bool = False - # Serializes the reconnect and owner-watch entry points into _connect(). self._init_lock = asyncio.Lock() self._thread = threading.Thread( target=self._run_loop, daemon=True, name="UpdaterAsyncLoop" @@ -98,7 +91,7 @@ def _track_task(self, task: asyncio.Task) -> asyncio.Task: return task def _run_loop(self) -> None: - """Entry point for the asyncio daemon thread.""" + """Thread entry: run the loop; the owner watch outlives listener teardown.""" asyncio.set_event_loop(self._loop) # Recreated per thread: an asyncio.Lock binds to the loop of its first await. self._init_lock = asyncio.Lock() @@ -110,7 +103,6 @@ def _run_loop(self) -> None: if not self._shutting_down: self._restart_loop_thread(delay=10.0) return - # Outlives _async_initialize's listener teardown: it is what triggers it. self._owner_task = self._loop.create_task( self._watch_daemon_owner(), name="updater_owner_watch" ) @@ -155,13 +147,12 @@ async def _async_initialize(self, expect_owner: str = "") -> None: await self._connect() async def _connect(self) -> None: - """Connect proxy and start all signal listener tasks.""" + """Supersede any pending retry, connect the proxy and start the listeners.""" from updater.dbus_service import UpdaterInterface if self._busy_false_event is not None: self._busy_false_event.set() self._busy_false_event = asyncio.Event() - # This attempt supersedes a pending backoff retry; a failure below re-arms one. pending, self._reconnect_task = self._reconnect_task, None if pending is not None and pending is not asyncio.current_task(): pending.cancel() @@ -203,18 +194,15 @@ async def _connect(self) -> None: await asyncio.sleep(0) try: - # Bounded: an unresponsive daemon holding an open socket must not hang reconnect forever. async with asyncio.timeout(10): busy = await self._proxy.get_busy() except (sdbus.SdBusBaseError, TimeoutError) as exc: - # Proxy is lazy; this first call proves the daemon is reachable. _log.warning("get_busy failed on (re)connect: %s - scheduling retry", exc) self.daemon_unavailable.emit() self._schedule_reconnect() return - # Reset only once the daemon answers: new_proxy() is lazy and always "succeeds", - # so resetting earlier pins backoff at 5s and starves the escalation threshold. + # Reset only now: new_proxy() is lazy, so an earlier reset pins backoff at 5s. self._reconnect_attempt = 0 self._escalated = False self._daemon_owner = await self._name_owner() @@ -274,11 +262,7 @@ def _on_listener_done(self, task: asyncio.Task) -> None: self._schedule_reconnect() def _schedule_reconnect(self) -> None: - """Schedule _async_initialize retry with exponential backoff. - - Idempotent: if a reconnect is already pending this is a no-op, so it is - safe to call from every failing listener without spawning duplicate tasks. - """ + """Schedule a backoff retry of _async_initialize; no-op while one is pending.""" if self._reconnecting: return self._reconnecting = True @@ -300,17 +284,12 @@ def _schedule_reconnect(self) -> None: _log.warning("_schedule_reconnect called outside running loop - skipped") async def _delayed_reconnect(self, delay: float) -> None: - """Sleep for ``delay`` seconds then re-run ``_async_initialize``. - - Every exit path clears ``_reconnecting`` explicitly rather than via finally: - _async_initialize may legitimately re-arm it, and finally would clobber that. - """ + """Sleep then reconnect; no finally, so a re-armed _reconnecting survives.""" try: await asyncio.sleep(delay) if self._shutting_down: self._reconnecting = False return - # Nothing owns the name after several tries: activation itself is failing. if ( self._reconnect_attempt >= _ESCALATE_AFTER and not await self._name_owner() @@ -323,20 +302,12 @@ async def _delayed_reconnect(self, delay: float) -> None: self._reconnecting = False raise except Exception: # noqa: BLE001 - # Never leave the latch stuck: it would silence every future reconnect. self._reconnecting = False _log.error("reconnect attempt failed - rescheduling", exc_info=True) self._schedule_reconnect() - # --- Daemon lifecycle tracking ---------------------------------- - async def _watch_daemon_owner(self) -> None: - """Resync on every owner change of the daemon's bus name (crash + systemd restart). - - Signal match rules use the well-known name, so listeners survive a restart - - but the new instance never re-emits busy_changed, leaving a mid-update UI stuck - until the 6-minute busy watchdog. This turns that into a millisecond recovery. - """ + """Resync on owner change: a restarted daemon never re-emits busy_changed.""" resync = False # the first seed races updater_init's own connect while not self._shutting_down: try: @@ -345,7 +316,6 @@ async def _watch_daemon_owner(self) -> None: # Seeded after subscribing so no change can slip through the gap. owner = await self._name_owner() if resync and owner and owner != self._daemon_owner: - # Restarted while the watch was down: no signal will report it. await self._async_initialize(owner) else: self._daemon_owner = owner @@ -355,20 +325,19 @@ async def _watch_daemon_owner(self) -> None: continue if not new_owner: self._daemon_owner = "" - # No retry: systemd restarts it; a failing call escalates. + # systemd never restarts a clean stop; the retry revives it. _log.error("updater daemon left the bus - awaiting restart") self.daemon_unavailable.emit() + self._schedule_reconnect() continue _log.warning( "updater daemon restarted (owner=%s) - resyncing", new_owner ) - # _async_initialize owns _daemon_owner: setting it here would - # make its own duplicate-resync guard skip this connect. + # _daemon_owner is set by _async_initialize, or its guard skips. await self._async_initialize(new_owner) except asyncio.CancelledError: raise except Exception: # noqa: BLE001 - # Losing the watch must not be terminal: it is the fast recovery path. _log.error("daemon owner watch failed - retrying in 10s", exc_info=True) # Also covers a stream that ends without raising, which would else hot-spin. if not self._shutting_down: @@ -382,15 +351,13 @@ async def _name_owner(self) -> str: _DAEMON_BUS_NAME ) except (sdbus.SdBusBaseError, TimeoutError, OSError, ImportError): - # NameHasNoOwner for an activatable-but-stopped unit lands here too. _log.debug("GetNameOwner(%s) failed", _DAEMON_BUS_NAME, exc_info=True) return "" async def _escalate_restart(self) -> None: - """Ask systemd once to start a unit it has given up on (stale unit without StartLimitIntervalSec=0).""" + """Ask systemd once (latched until connect) to restart a unit it gave up on.""" if self._escalated: return - # Latched until the next successful connect so a dead unit is not hammered. self._escalated = True _log.error( "daemon absent after %d attempts - asking systemd to start %s", @@ -423,7 +390,6 @@ async def _run_systemctl(self, args: tuple[str, ...]) -> None: async with asyncio.timeout(30): _, err = await proc.communicate() except TimeoutError: - # Reap it: an orphaned sudo would hold the PIPE and the child slot forever. _log.error("systemctl %s timed out - killing", label) with suppress(ProcessLookupError): proc.kill() @@ -446,8 +412,6 @@ def _require_proxy(self) -> bool: return False return True - # --- Public API (QT -> asyncio thread) --------------------------- - def trigger_update(self, name: str = "") -> None: """Queue an update; name='' updates all components.""" if not self._require_proxy(): @@ -510,15 +474,8 @@ def _submit(self, coro: Coroutine[Any, Any, None]) -> None: _log.error("asyncio loop is closed, daemon is unavailable") self.daemon_unavailable.emit() - # --- Internal coroutines --------------------------------------- - def _handle_proxy_error(self, exc: Exception, method: str) -> None: - """Log a D-Bus call failure, emit daemon_unavailable, and schedule a reconnect. - - While a reconnect is already pending the emit is suppressed: the UI's - daemon-unavailable handler triggers a status refresh, which would fail - and re-emit here - an endless toast/request storm without this guard. - """ + """Log a failed call; emit daemon_unavailable unless reconnecting; retry.""" _log.error("%s D-Bus call failed: %s", method, exc) if not self._reconnecting: self.daemon_unavailable.emit() @@ -574,8 +531,6 @@ async def _call_bless(self, name: str) -> None: except sdbus.SdBusBaseError as exc: self._handle_proxy_error(exc, "bless_healthy") - # --- Signal listeners ------------------------------------------ - async def _listen_status_ready(self) -> None: """Forward status_ready D-Bus signals to the Qt status_ready signal.""" async for json_str in self._proxy.status_ready: @@ -643,12 +598,7 @@ async def _listen_provisioning_changed(self) -> None: self.provisioning_changed.emit(provisioning) async def _busy_watchdog(self) -> None: - """Emit daemon_unavailable after _BUSY_IDLE_LIMIT seconds of daemon silence. - - Any progress signal (step_complete, component_done, error, busy_changed) - refreshes the deadline via _touch_activity, so the watchdog only fires - when a busy daemon stops reporting entirely - not on long updates. - """ + """Emit daemon_unavailable after _BUSY_IDLE_LIMIT s of no daemon progress.""" if self._busy_false_event is None: _msg = "_busy_false_event not initialized" raise RuntimeError(_msg) diff --git a/scripts/install-updater.sh b/scripts/install-updater.sh index a794fd0a..a1582da6 100755 --- a/scripts/install-updater.sh +++ b/scripts/install-updater.sh @@ -27,8 +27,7 @@ BSENV="${BLOCKSSCREEN_VENV:-${_BSENV_HOME}/.BlocksScreen-env}" # Venv-mutating commands run as blocks: root-owned dists break later pip-as-blocks runs. _as_blocks() { if [ "$(id -u)" = "0" ]; then runuser -u "$_BSENV_USER" -- "$@"; else "$@"; fi; } -# Atomic root install: a power cut must never leave a truncated-but-present file -# (the [ -f ] self-heal guards would then never rewrite it). +# Atomic root install: a truncated file would defeat the [ -f ] self-heal guards. _install_atomic() { local mode="$1" src="$2" dst="$3" sudo install -m "$mode" "$src" "${dst}.new" && sudo mv -Tf "${dst}.new" "$dst" @@ -97,8 +96,7 @@ else fi # Daemon self-restart target; never in components.yaml. _emit_svc_rules BlocksScreen-updater.service -# Spoolman is provisioned on demand; enable rules needed for its first clean start -# (hooks/Spoolman.sh uses `enable --now`; sudoers args must match exactly). +# Spoolman enable rules: hooks/Spoolman.sh runs enable --now, args must match exactly. printf 'blocks ALL=(ALL) NOPASSWD: /usr/bin/systemctl enable Spoolman.service\n' >>"$SUDOERS_TMP" printf 'blocks ALL=(ALL) NOPASSWD: /usr/bin/systemctl enable --now Spoolman.service\n' >>"$SUDOERS_TMP" printf 'blocks ALL=(ALL) NOPASSWD: /usr/bin/systemctl disable --now Spoolman.service\n' >>"$SUDOERS_TMP" diff --git a/tests/lib/test_updater_worker_unit.py b/tests/lib/test_updater_worker_unit.py index 72b23038..ef4a7ece 100644 --- a/tests/lib/test_updater_worker_unit.py +++ b/tests/lib/test_updater_worker_unit.py @@ -251,14 +251,19 @@ async def test_new_owner_triggers_resync(self, worker): worker._async_initialize.assert_awaited_once_with(":1.5") @pytest.mark.asyncio - async def test_owner_lost_emits_unavailable_without_resync(self, worker, qtbot): + async def test_owner_lost_emits_unavailable_and_schedules_reconnect( + self, worker, qtbot + ): + """systemd never restarts a clean stop, so the worker must retry on its own.""" received = [] worker.daemon_unavailable.connect(lambda: received.append(True)) worker._async_initialize = AsyncMock() + worker._schedule_reconnect = MagicMock() with self._patch_dbus(worker, ":1.5", [(_BUS, ":1.5", "")]): await worker._watch_daemon_owner() assert received == [True] worker._async_initialize.assert_not_awaited() + worker._schedule_reconnect.assert_called_once_with() @pytest.mark.asyncio async def test_other_names_and_repeat_owner_ignored(self, worker): diff --git a/tests/updater/conftest.py b/tests/updater/conftest.py index 14e943bf..c8aeed01 100644 --- a/tests/updater/conftest.py +++ b/tests/updater/conftest.py @@ -4,6 +4,7 @@ withouth a real D-Bus session bus. """ +import asyncio import sys from unittest.mock import AsyncMock, MagicMock @@ -76,3 +77,11 @@ def svc(): s.status_ready = MagicMock() s.error = MagicMock() return s + + +@pytest.fixture +async def reconciled(svc): + """svc whose boot reconcile already finished, so the periodic check can provision.""" + svc._reconcile_task = asyncio.get_running_loop().create_future() + svc._reconcile_task.set_result(None) + return svc diff --git a/tests/updater/test_dbus_service_unit.py b/tests/updater/test_dbus_service_unit.py index 061ccdb7..ce9574fe 100644 --- a/tests/updater/test_dbus_service_unit.py +++ b/tests/updater/test_dbus_service_unit.py @@ -274,6 +274,7 @@ def mock_spawn(coro, *, name=None): assert svc._status_pending is False +@pytest.mark.usefixtures("reconciled") class TestPollIntervalUsage: @pytest.mark.asyncio async def test_periodic_status_check_uses_poll_interval(self, svc): @@ -357,7 +358,11 @@ def _build(self, missing): mock_svc.needs_provision.return_value = missing with ( patch.object(dbus_service, "UpdateService", return_value=mock_svc), - patch.object(dbus_service.UpdaterDbusService, "_spawn", MagicMock()), + patch.object( + dbus_service.UpdaterDbusService, + "_spawn", + MagicMock(side_effect=lambda coro, **_: coro.close()), + ), ): return dbus_service.UpdaterDbusService() @@ -367,6 +372,7 @@ def test_busy_at_construction_iff_component_missing(self, missing): assert self._build(missing)._busy is missing @pytest.mark.asyncio + @pytest.mark.usefixtures("reconciled") async def test_boot_busy_skips_initial_sleep_and_releases(self, svc): """Missing component: provision runs at once (no 3 s sleep), then busy drops.""" from updater import dbus_service @@ -389,59 +395,42 @@ async def fake_sleep(delay): assert svc._busy is False -class TestProvisionRetry: - @pytest.mark.asyncio - async def test_retries_while_lock_defers_then_stops(self, svc): - """Deferred provisioning (lock held by boot reconcile) is retried, not left for the next poll.""" - from updater import dbus_service - - svc._svc.provision_missing = AsyncMock(side_effect=[True, True, False]) - sleeps: list[float] = [] - - async def fake_sleep(delay): - sleeps.append(delay) - - with patch.object(dbus_service.asyncio, "sleep", fake_sleep): - await svc._provision_with_retry() - - assert svc._svc.provision_missing.await_count == 3 - assert sleeps == [dbus_service._PROVISION_RETRY_S] * 2 - +@pytest.mark.usefixtures("reconciled") +class TestBootProvision: @pytest.mark.asyncio - async def test_failed_install_is_not_retried(self, svc): - """A tried-and-failed install (offline, broken unit) runs once, not 10 times.""" - from updater import dbus_service - - svc._svc.needs_provision = MagicMock(return_value=True) # dir still absent - svc._svc.provision_missing = AsyncMock(return_value=False) - - with patch.object(dbus_service.asyncio, "sleep", AsyncMock()): - assert await svc._provision_with_retry() is False - + @pytest.mark.parametrize("raised", [False, True]) + async def test_provisions_only_after_boot_reconcile(self, svc, raised): + """Reconcile holds the process lock: provisioning first would always defer.""" + gate = asyncio.get_running_loop().create_future() + svc._reconcile_task = gate + svc._boot_busy = True # skip the initial 3 s sleep + svc._svc.poll_interval = 3600.0 + task = asyncio.create_task(svc._periodic_status_check()) + await asyncio.sleep(0.05) + svc._svc.provision_missing.assert_not_awaited() + + if raised: + gate.set_exception(RuntimeError("reconcile crashed")) + else: + gate.set_result(None) + await asyncio.sleep(0.05) svc._svc.provision_missing.assert_awaited_once() - @pytest.mark.asyncio - async def test_gives_up_after_bounded_retries(self, svc): - """A lock that never frees must not loop forever.""" - from updater import dbus_service - - svc._svc.provision_missing = AsyncMock(return_value=True) - - with patch.object(dbus_service.asyncio, "sleep", AsyncMock()): - assert await svc._provision_with_retry() is True - - assert svc._svc.provision_missing.await_count == dbus_service._PROVISION_RETRIES + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + if raised: + gate.exception() - async def _run_polls(self, svc, polls: int) -> None: + async def _run_polls(self, svc, polls: int) -> list[float]: from updater import dbus_service svc._boot_busy = True # skip the initial 3 s sleep - calls = 0 + sleeps: list[float] = [] - async def fake_sleep(_delay): - nonlocal calls - calls += 1 - if calls >= polls: + async def fake_sleep(delay): + sleeps.append(delay) + if len(sleeps) >= polls: raise asyncio.CancelledError with ( @@ -449,22 +438,27 @@ async def fake_sleep(_delay): pytest.raises(asyncio.CancelledError), ): await svc._periodic_status_check() + return sleeps @pytest.mark.asyncio async def test_failed_install_not_retried_on_later_polls(self, svc): """Boot tries once; later polls never re-clone (user Update does).""" + svc._svc.poll_interval = 86_400.0 svc._svc.provision_missing = AsyncMock(return_value=False) - await self._run_polls(svc, polls=3) + sleeps = await self._run_polls(svc, polls=3) svc._svc.provision_missing.assert_awaited_once() + assert sleeps == [86_400.0] * 3 @pytest.mark.asyncio - async def test_still_deferred_after_retries_is_tried_on_next_poll(self, svc): + async def test_deferred_provision_repolls_soon_then_stops(self, svc): + """A lock deferral is retried at the retry interval, not left for a full poll.""" from updater import dbus_service - svc._svc.provision_missing = AsyncMock(return_value=True) - with patch.object(dbus_service, "_PROVISION_RETRIES", 1): - await self._run_polls(svc, polls=3) + svc._svc.poll_interval = 86_400.0 + svc._svc.provision_missing = AsyncMock(side_effect=[True, False]) + sleeps = await self._run_polls(svc, polls=3) assert svc._svc.provision_missing.await_count == 2 + assert sleeps == [dbus_service._FETCH_RETRY_INTERVAL_S, 86_400.0, 86_400.0] class TestProvisioningFlag: diff --git a/tests/updater/test_service_unit.py b/tests/updater/test_service_unit.py index 1bbbf183..e2efe53b 100644 --- a/tests/updater/test_service_unit.py +++ b/tests/updater/test_service_unit.py @@ -1759,7 +1759,9 @@ async def test_provision_waits_for_service_active(self, tmp_path): patch( "updater.service.wait_for_service_active", return_value=False ) as mock_wait, - patch("updater.service.disable_service", return_value=(True, "")) as mock_stop, + patch( + "updater.service.disable_service", return_value=(True, "") + ) as mock_stop, patch("updater.service.shutil.rmtree") as mock_rmtree, ): svc = UpdateService(callback=cb) @@ -1793,7 +1795,9 @@ async def test_provision_fails_when_health_check_fails(self, tmp_path): patch( "updater.service.wait_for_http_ready", return_value=False ) as mock_health, - patch("updater.service.disable_service", return_value=(True, "")) as mock_stop, + patch( + "updater.service.disable_service", return_value=(True, "") + ) as mock_stop, patch("updater.service.shutil.rmtree") as mock_rmtree, ): svc = UpdateService(callback=cb) @@ -1805,6 +1809,73 @@ async def test_provision_fails_when_health_check_fails(self, tmp_path): mock_rmtree.assert_called_once() assert cb.on_error.call_args[0][1] == "restart" + @pytest.mark.asyncio + async def test_failure_before_hook_leaves_service_alone(self, tmp_path): + """The hook never ran, so there is no unit of ours to disable.""" + comp = self._comp(tmp_path, service="newcomp.service") + with ( + patch("updater.service.git_clone", return_value=(False, "boom")), + patch("updater.service.disable_service") as mock_stop, + patch("updater.service.shutil.rmtree") as mock_rmtree, + ): + svc = UpdateService(callback=MagicMock()) + svc._components = [comp] + assert await svc.update_component("newcomp") is False + mock_stop.assert_not_called() + mock_rmtree.assert_called_once() + + @pytest.mark.asyncio + async def test_unexpected_error_after_hook_disables_service(self, tmp_path): + comp = self._comp(tmp_path, service="newcomp.service") + cb = MagicMock() + with ( + patch("updater.service.git_clone", return_value=(True, "")), + patch("updater.service.git_get_hash", return_value="newhash"), + patch( + "updater.service.UpdateService._install_dependencies", + return_value=(True, ""), + ), + patch("updater.service.run_hook", return_value=(True, "")), + patch( + "updater.service.UpdateService._provision_restart_service", + side_effect=RuntimeError("boom"), + ), + patch( + "updater.service.disable_service", return_value=(True, "") + ) as mock_stop, + patch("updater.service.shutil.rmtree") as mock_rmtree, + ): + svc = UpdateService(callback=cb) + svc._components = [comp] + assert await svc.update_component("newcomp") is False + mock_stop.assert_called_once_with("newcomp.service") + mock_rmtree.assert_called_once() + assert cb.on_error.call_args[0][1] == "unexpected_error" + + @pytest.mark.asyncio + async def test_cancel_during_hook_disables_service(self, tmp_path): + """A daemon stop mid-hook must not leave the unit enabled on a deleted clone.""" + comp = self._comp(tmp_path, service="newcomp.service") + with ( + patch("updater.service.git_clone", return_value=(True, "")), + patch("updater.service.git_get_hash", return_value="newhash"), + patch( + "updater.service.UpdateService._install_dependencies", + return_value=(True, ""), + ), + patch("updater.service.run_hook", side_effect=asyncio.CancelledError), + patch( + "updater.service.disable_service", return_value=(True, "") + ) as mock_stop, + patch("updater.service.shutil.rmtree") as mock_rmtree, + ): + svc = UpdateService(callback=MagicMock()) + svc._components = [comp] + with pytest.raises(asyncio.CancelledError): + await svc.update_component("newcomp") + mock_stop.assert_called_once_with("newcomp.service") + mock_rmtree.assert_called_once() + @pytest.mark.asyncio async def test_provision_succeeds_when_health_ready(self, tmp_path): comp = self._comp( @@ -2440,9 +2511,7 @@ async def test_failed_restart_request_is_not_pending(self, tmp_path: Path): "updater.service.verify_updater_importable", new=AsyncMock(return_value=True), ), - patch( - "updater.service.restart_service_noblock", return_value=(False, "x") - ), + patch("updater.service.restart_service_noblock", return_value=(False, "x")), ): svc = self._svc_with_ui() await svc._apply_deferred_restart() diff --git a/updater/__init__.py b/updater/__init__.py index cb3578fd..f6781a72 100644 --- a/updater/__init__.py +++ b/updater/__init__.py @@ -1,6 +1,7 @@ +"""Updater package; dbus_service stays unimported so the CLI runs without sdbus.""" + from .components import load_components -# dbus_service (imports sdbus) intentionally not imported here: CLI runs without sdbus. from .executor import ( apt_update, apt_upgrade, @@ -21,11 +22,9 @@ from .service import LoggingCallback, ProgressCallback, UpdateService __all__ = [ - # Components "ComponentConfig", "ComponentStatus", "load_components", - # Executor "apt_update", "apt_upgrade", "check_apt_status", @@ -40,7 +39,6 @@ "git_remote_url", "git_reset_to_hash", "restart_service", - # Service "LoggingCallback", "ProgressCallback", "UpdateService", diff --git a/updater/__main__.py b/updater/__main__.py index 68614d0b..281ec437 100644 --- a/updater/__main__.py +++ b/updater/__main__.py @@ -11,8 +11,6 @@ from updater.models import ComponentStatus from updater.service import LoggingCallback, UpdateService -# NOTE: sdbus imports are lazy (in _run_daemon) so the CLI works without sdbus. - def _sd_notify(msg: str) -> None: """Send a notification to systemd via NOTIFY_SOCKET (python-sdbus has no sd_notify).""" @@ -52,7 +50,7 @@ def build_parser() -> argparse.ArgumentParser: async def _run_daemon() -> None: - """Start the updater D-Bus service on the system bus.""" + """Start the D-Bus service; sdbus is imported here so the CLI runs without it.""" import sdbus from updater.dbus_service import UpdaterDbusService @@ -64,7 +62,7 @@ async def _run_daemon() -> None: try: await bus.request_name_async("com.blockscreen.Updater", 0) except sdbus.SdBusBaseError as exc: - # Exit nonzero (not READY) so systemd Restart=always retries until the name frees. + # Not READY: Restart=always retries until the name frees. _log.error("failed to claim D-Bus name: %s - another instance running?", exc) raise SystemExit(1) from exc _log.info("updater daemon running on com.blockscreen.Updater") @@ -148,7 +146,6 @@ async def main() -> None: else: ok = await svc.update_component(args.name) if not ok: - # Scripts/harnesses rely on the exit code, not just the log. raise SystemExit(1) case "status": result = await svc.check_status() diff --git a/updater/components.py b/updater/components.py index c847a7c5..1b0e1b54 100644 --- a/updater/components.py +++ b/updater/components.py @@ -21,7 +21,7 @@ _SERVICE_BANNED = set("/\\;&|$`") | {" ", "\t"} OVERRIDE_PATH = Path("~/printer_data/config/blockscreen_updater.yaml").expanduser() -# Unioned into EVERY apt component: a kernel/firmware bump is unrecoverable on a 1-partition no-SSH Pi. +# Unioned into every apt component: a kernel/firmware bump bricks a no-SSH Pi. _KERNEL_FIRMWARE_EXCLUDES: tuple[str, ...] = ( "^linux-image", "^linux-headers", @@ -53,7 +53,6 @@ def _parse_git_branch(name: str, raw_branch: object) -> tuple[str | None, bool]: if raw_branch is None: return None, True branch = str(raw_branch) - # Strip a stray remote prefix: `origin/x` would fetch `origin/origin/x`. if branch.startswith("origin/"): logger.warning( "Component %r branch %r has an 'origin/' prefix - stripping it", @@ -92,7 +91,6 @@ def _parse_health_url(name: str, url: object) -> str | None: def _parse_reset_mode(name: str, reset_mode: object) -> str: """Return 'hard'/'soft'; unknown values fall back to 'hard' (fleet default).""" if reset_mode not in ("hard", "soft"): - # An unknown value must not silently take the soft path (fleet default is hard). logger.warning( "Component %r has invalid reset_mode %r - using 'hard'", name, @@ -169,7 +167,6 @@ def _validate_apt_component(name: str, data: dict) -> ComponentConfig: apt_exclude: tuple[str, ...] = () if isinstance(raw_exclude, list): apt_exclude = tuple(str(p) for p in raw_exclude if isinstance(p, str)) - # Kernel/firmware guard is non-negotiable: prepend it, drop any duplicates. apt_exclude = _KERNEL_FIRMWARE_EXCLUDES + tuple( p for p in apt_exclude if p not in _KERNEL_FIRMWARE_EXCLUDES ) @@ -290,7 +287,6 @@ def _build_configs(raw_components: list[dict]) -> list[ComponentConfig]: cfg = _validate_component(entry) if cfg is not None: configs.append(cfg) - # Auto-inject system apt component if none configured in YAML. if not any(c.kind == "apt" for c in configs): configs.insert( 0, @@ -309,7 +305,7 @@ def load_components() -> tuple[list[ComponentConfig], float]: try: import yaml # noqa: PLC0415 - # A truncated install (interrupted pip, power cut) still imports but exposes only dunders. + # A truncated install (power cut mid-pip) imports but exposes only dunders. _ = (yaml.safe_load, yaml.YAMLError) except (ImportError, AttributeError): logger.exception("PyYAML missing or broken; updater idle until venv repair") diff --git a/updater/components.yaml b/updater/components.yaml index f52e6f7c..69acd1a0 100644 --- a/updater/components.yaml +++ b/updater/components.yaml @@ -1,11 +1,6 @@ poll_interval_minutes: 1440 -# Optional keys (also settable per-unit in -# ~/printer_data/config/blockscreen_updater.yaml, merged by name): -# url: https://... https-only clone URL (install_if_missing needs it) -# install_if_missing: true clone + deps + hook when the path is absent -# restart_ui: true also restart BlocksScreen after this component updates -# restart_klipper: true also restart klipper after this component updates +# Override per unit (merged by name) in ~/printer_data/config/blockscreen_updater.yaml. components: - name: klipper @@ -63,7 +58,6 @@ components: reset_mode: hard order: 20 - # Provisioned by hooks/Spoolman.sh - name: Spoolman type: git path: ~/Spoolman diff --git a/updater/dbus_service.py b/updater/dbus_service.py index 5ba3cd9e..46988bce 100644 --- a/updater/dbus_service.py +++ b/updater/dbus_service.py @@ -18,11 +18,7 @@ _log = logging.getLogger(__name__) _STATUS_PATH = Path("/run/blockscreen/updater_status.json") -# Poll again this soon while a git fetch is failing: a boot-time DNS miss must not hide updates for a full poll interval. _FETCH_RETRY_INTERVAL_S = 300.0 -# Retries while boot reconcile holds the process lock. -_PROVISION_RETRIES = 10 -_PROVISION_RETRY_S = 3.0 class DbusProgressCallback: @@ -107,10 +103,9 @@ def provisioning_changed(self) -> tuple[bool]: raise NotImplementedError def __init__(self) -> None: - """Wire the service and busy state, then spawn the boot, poll, and self-heal tasks.""" + """Set busy before export so the UI's first get_busy sees a boot install.""" super().__init__() self._svc = UpdateService(callback=DbusProgressCallback(self)) - # Set before export so the UI's first get_busy sees a boot install. self._boot_busy: bool = self._svc.needs_provision() self._busy: bool = self._boot_busy self._provisioning: bool = self._boot_busy @@ -119,7 +114,7 @@ def __init__(self) -> None: self._status_check_in_progress: bool = False self._status_pending: bool = False self._invalid_requests: int = 0 - self._spawn(self._svc.reconcile(), name="boot_reconcile") + self._reconcile_task = self._spawn(self._svc.reconcile(), name="boot_reconcile") self._spawn(self._svc.background_prime_nrestarts(), name="boot_prime_nrestarts") self._spawn(self._periodic_status_check(), name="periodic_status_check") self._spawn(self._svc.supervise_ui(), name="supervise_ui") @@ -141,14 +136,6 @@ def _task_done(self, task: asyncio.Task) -> None: if exc is not None: _log.error("task %r failed", task.get_name(), exc_info=exc) - async def _provision_with_retry(self) -> bool: - """Retry while boot reconcile's lock defers provisioning; True if still deferred.""" - for _ in range(_PROVISION_RETRIES): - if not await self._svc.provision_missing(self._provision_busy): - return False - await asyncio.sleep(_PROVISION_RETRY_S) - return True - def _provision_busy(self, busy: bool) -> None: self._set_provisioning(busy) self._set_busy(busy) @@ -225,14 +212,14 @@ async def _emit_status(self, force: bool = False) -> None: self.status_ready.emit((json_payload,)) async def _periodic_status_check(self) -> None: - """Emit status shortly after startup, then at the poll interval - or sooner while fetches fail.""" + """Provision once; emit status per poll, sooner on fetch failure or deferral.""" if not self._boot_busy: await asyncio.sleep(3.0) while True: try: if not self._provisioned: - # Once per start; re-armed only by a lock deferral or an error. - deferred = await self._provision_with_retry() + await asyncio.wait({self._reconcile_task}) + deferred = await self._svc.provision_missing(self._provision_busy) self._provisioned = not deferred _log.info("provisioning pass done (deferred=%s)", deferred) self._release_boot_busy() @@ -244,6 +231,9 @@ async def _periodic_status_check(self) -> None: if self._svc.has_fetch_failures(): interval = min(_FETCH_RETRY_INTERVAL_S, interval) _log.info("fetch failures pending - re-polling in %.0fs", interval) + elif not self._provisioned: + interval = min(_FETCH_RETRY_INTERVAL_S, interval) + _log.info("provisioning deferred - re-polling in %.0fs", interval) await asyncio.sleep(interval) @sdbus.dbus_method_async(result_signature="b") @@ -260,7 +250,7 @@ async def update_component(self, name: str) -> bool: """D-Bus method: fire-and-forget; reply is sent immediately, update runs as a task.""" if self._busy: return False - if not self._validate_component_name(name): # SEC: reject unknown components + if not self._validate_component_name(name): _log.warning("update_component called with unknown component %r", name) return False self._set_busy(busy=True) @@ -272,7 +262,7 @@ async def recover(self, name: str, hard: bool) -> bool: """D-Bus method: fire-and-forget; reply is sent immediately, recover runs as a task.""" if self._busy: return False - if not self._validate_component_name(name): # SEC: reject unknown components + if not self._validate_component_name(name): _log.warning("recover called with unknown component %r", name) return False self._set_busy(busy=True) @@ -299,7 +289,6 @@ async def _run_with_lock( with process_lock() as acquired: if not acquired: _log.warning("%s: a CLI run holds the lock; skipping", label) - # Surface the rejection so the UI toasts instead of going silent. self.error.emit((target, "another update is running")) return False ran = True @@ -311,13 +300,11 @@ async def _run_with_lock( return ran async def _run_update_all(self) -> None: - """Update dirty components under the process lock, then a background apt pass.""" + """Update dirty components; no background apt if a restart may SIGKILL dpkg.""" ran = await self._run_with_lock( self._update_all_locked, "update_all", "updater" ) - # Silent apt pass only if we held the lock; else the CLI run owns apt. if ran and self._svc.daemon_restart_pending: - # A restart SIGKILL could land inside dpkg. _log.info("background apt upgrade skipped: daemon restart pending") elif ran: self._spawn( @@ -325,7 +312,7 @@ async def _run_update_all(self) -> None: ) async def _update_all_locked(self) -> None: - """Update only the components whose status is dirty.""" + """Update dirty components and errored git repos (update self-heals those).""" statuses = await self._svc.check_status() dirty = { name @@ -335,7 +322,6 @@ async def _update_all_locked(self) -> None: or s.has_local_changes or s.needs_install or s.branch_mismatch - # Errored git repos included: the update flow self-heals them. or (s.error is not None and s.kind != "apt") } if dirty: @@ -373,7 +359,7 @@ async def get_provisioning(self) -> bool: @sdbus.dbus_method_async() async def cancel(self) -> None: - """D-Bus method: cancel the running update or recover task and wait for cleanup.""" + """D-Bus method: cancel the task, then wait (not re-cancel) for its rollback.""" if self._provisioning: _log.info("cancel() ignored: component install in progress") return @@ -385,7 +371,6 @@ async def cancel(self) -> None: cancelled_tasks.append(task) _log.info("cancelled task %r", name) if cancelled_tasks: - # asyncio.wait never re-cancels: rollback isn't interrupted again. _done, pending = await asyncio.wait(cancelled_tasks, timeout=150.0) if pending: _log.error( diff --git a/updater/executor.py b/updater/executor.py index b9a08217..05e57296 100644 --- a/updater/executor.py +++ b/updater/executor.py @@ -22,7 +22,7 @@ UPDATER_SERVICE = "BlocksScreen-updater.service" -# Hook budget: a deps-heavy hook (Spoolman uv sync) runs minutes; timeout = abort. +# Spoolman's uv sync runs for minutes; a timeout aborts the hook. HOOK_TIMEOUT = 600.0 GIT = "/usr/bin/git" @@ -76,12 +76,11 @@ def _kill_proc_group(proc, sig): async def _reap(proc, sig: int, grace: float) -> bool: - """Signal the group and drain within grace; True if the process is gone.""" + """Signal the group, then drain (wait() hangs on a paused pipe); True if gone.""" if proc.returncode is None: _kill_proc_group(proc, sig) if proc.stdin is not None and not proc.stdin.is_closing(): - proc.stdin.close() # retires the cancelled feed's drain future, else it logs BrokenPipeError - # Must drain not wait(): a cancelled communicate() leaves the reader paused above its 128KB buffer, so the pipe never sees EOF and wait() never wakes. + proc.stdin.close() # else the cancelled feed's drain logs BrokenPipeError try: await asyncio.wait_for(proc.communicate(), timeout=grace) except TimeoutError: @@ -90,22 +89,20 @@ async def _reap(proc, sig: int, grace: float) -> bool: def _make_clean_env() -> dict[str, str]: - """Build a minimal sanitized environment for updater subprocesses.""" + """Build a minimal env without session bus/XDG vars or unsafe SUDO_ vars.""" env: dict[str, str] = {} for key in ( "PATH", "HOME", "USER", - # SEC: session bus + XDG runtime vars excluded; hooks must not use them. "TMPDIR", ): val = os.environ.get(key) if val is not None: env[key] = val env["GIT_TERMINAL_PROMPT"] = "0" - # git_fetch's broken-ref self-heal and the apt parser match English messages + # git/apt error parsing matches English text. env["LC_ALL"] = "C" - # SEC: only copy safe SUDO_ vars; reject SUDO_ASKPASS and others safe_sudo = {"SUDO_USER", "SUDO_UID", "SUDO_GID"} for key, val in os.environ.items(): if key in safe_sudo: @@ -265,7 +262,6 @@ async def _commits_behind_or_error( commits_behind = await git_commits_behind(path, remote_ref) if commits_behind != -1: return commits_behind, None - # a configured branch whose origin ref is gone is a config error, not transient if branch and not await git_ref_hash(path, remote_ref): return -1, ComponentStatus( name=name, @@ -297,7 +293,6 @@ async def check_git_status( remote_ref = f"origin/{branch}" else: remote_ref = f"origin/{current_branch}" if current_branch else "origin/HEAD" - # Configured branch != checked-out branch: needs an update to switch. branch_mismatch = bool(branch) and current_branch != branch commits_behind, err = await _commits_behind_or_error( path, name, branch, version, remote_ref, current_hash, current_branch @@ -379,7 +374,7 @@ async def git_prune_extra_remotes(path: Path) -> None: return extras = [r for r in output.splitlines() if r and r != "origin"] if not extras: - return # No extra remotes to remove + return for remote in extras: ok, err = await _run([GIT, "remote", "remove", remote], cwd=path, timeout=10.0) if ok: @@ -488,7 +483,6 @@ async def git_reset_to_hash(path: Path | None, prev_hash: str = "") -> tuple[boo # Quarantine dir inside .git/objects so it never appears as untracked. _QUARANTINE_DIRNAME = "objects-corrupt" -# fsck names corrupt objects by path (.git/objects/ab/<38hex>) or 40-hex SHA. _GIT_OBJ_PATH_RE = re.compile(r"objects/([0-9a-f]{2})/([0-9a-f]{38})") _GIT_OBJ_SHA_RE = re.compile(r"\b([0-9a-f]{40})\b") @@ -512,7 +506,7 @@ def _prune_empty_loose_objects(objects: Path) -> int: removed = 0 for sub in objects.iterdir(): if len(sub.name) != 2 or not sub.is_dir(): - continue # loose objects live in 2-hex-char subdirs only + continue for obj in sub.iterdir(): try: if obj.is_file() and obj.stat().st_size == 0: @@ -531,7 +525,7 @@ async def _quarantine_corrupt_objects(path: Path) -> int: timeout=120.0, ) if ok: - return 0 # fsck --full clean: corruption is elsewhere (e.g. a packfile) + return 0 objects = path / ".git" / "objects" quarantine = objects / _QUARANTINE_DIRNAME candidates: set[Path] = set() @@ -548,7 +542,7 @@ async def _quarantine_corrupt_objects(path: Path) -> int: moved = 0 for obj in candidates: if not obj.is_file(): - continue # e.g. a "missing blob" object that does not exist on disk + continue try: dest = quarantine / obj.parent.name dest.mkdir(parents=True, exist_ok=True) @@ -743,9 +737,8 @@ async def git_checkout( if current_branch == branch: return (True, "already on branch") - # force: overwrite untracked collisions (e.g. build artifacts) that block a switch. cmd = [GIT, "checkout", "-f", branch] if force else [GIT, "checkout", branch] - # Generous: a big checkout on slow SD can pass 10s; SIGTERM = half-written tree. + # A timeout SIGTERM half-writes the tree: be generous on slow SD. return await _run(cmd, cwd=path, timeout=60.0) @@ -833,10 +826,9 @@ async def check_apt_status( def _apt_env() -> dict[str, str]: - """Return the apt subprocess env: noninteractive frontend, needrestart disabled.""" + """Return the apt env: noninteractive, no needrestart prompt (it hangs upgrades).""" env = _make_clean_env() env["DEBIAN_FRONTEND"] = "noninteractive" - # needrestart can otherwise open an interactive prompt mid-upgrade and hang. env["NEEDRESTART_MODE"] = "a" return env @@ -920,7 +912,7 @@ async def _apt_restore_packages(snapshot_path: Path) -> tuple[bool, str]: def classify_apt_error(err: str) -> str: """Classify an apt failure: 'permanent' won't clear by retrying, 'transient' might.""" lowered = err.lower() - # A missing apt helper self-heals once bootstrap installs it: retry, never a 1h cooldown. + # Missing helper: bootstrap installs it, so retry instead of a 1h cooldown. if str(APT_HELPER).lower() in lowered and ( "command not found" in lowered or "no such file" in lowered ): @@ -966,10 +958,10 @@ async def run_hook( prev_hash: str, timeout: float = 60.0, ) -> tuple[bool, str]: - """Run the per-component update hook if it exists.""" - hook = (_HOOKS_DIR / f"{name}.sh").resolve() # SEC: resolve symlinks + """Run the component's update hook if present, refusing paths outside hooks/.""" + hook = (_HOOKS_DIR / f"{name}.sh").resolve() try: - hook.relative_to(_HOOKS_DIR.resolve()) # SEC: prevent path traversal + hook.relative_to(_HOOKS_DIR.resolve()) except ValueError: return (False, "hook path escapes hooks directory") if not hook.exists(): @@ -1050,7 +1042,6 @@ async def wait_for_http_ready( if await asyncio.to_thread(_http_probe, url): logger.info("health check ok: %s", url) return True - # A crash-looping unit is 'activating', never 'active': don't wait out the timeout. if service and not await is_service_active(service): logger.warning("service %r left active during health check", service) return False @@ -1098,7 +1089,7 @@ async def restart_service(name: str | None) -> tuple[bool, str]: return (False, "service name is None") if not _SERVICE_RE.match(name): return (False, f"service name {name!r} is invalid") - # 120s timeout: Type=notify unit READY wait (up to 90s default TimeoutStartSec) plus margin for slow cold UI start. + # Type=notify READY wait (90s TimeoutStartSec default) plus slow-start margin. ok, err = await _run([SUDO, SYSTEMCTL, "restart", name], timeout=120.0) if ok: return (True, "") diff --git a/updater/hooks/BlocksScreen.sh b/updater/hooks/BlocksScreen.sh index 687010a9..d94a670e 100755 --- a/updater/hooks/BlocksScreen.sh +++ b/updater/hooks/BlocksScreen.sh @@ -20,7 +20,6 @@ _set_deploy_flag() { echo "[hook:BlocksScreen] deploy flag set - BlocksScreen-deploy.path will run install-updater.sh" } -# --- BlocksScreen.service changed --- if ! git -C "$COMPONENT_PATH" diff --quiet "$PREV_HASH" "$NEW_HASH" \ -- scripts/BlocksScreen.service 2>/dev/null; then @@ -55,7 +54,6 @@ if ! git -C "$COMPONENT_PATH" diff --quiet "$PREV_HASH" "$NEW_HASH" \ echo "[hook:BlocksScreen] daemon-reload done" fi -# --- BlocksScreen-xorg.service changed --- if ! git -C "$COMPONENT_PATH" diff --quiet "$PREV_HASH" "$NEW_HASH" \ -- scripts/BlocksScreen-xorg.service 2>/dev/null; then @@ -75,11 +73,10 @@ if ! git -C "$COMPONENT_PATH" diff --quiet "$PREV_HASH" "$NEW_HASH" \ echo "[hook:BlocksScreen] daemon-reload done (xorg service)" fi -# --- install files changed (checked independently) --- if ! git -C "$COMPONENT_PATH" diff --quiet "$PREV_HASH" "$NEW_HASH" \ -- scripts/install-updater.sh scripts/bs-apt-helper.sh 2>/dev/null; then echo "[hook:BlocksScreen] install files changed - setting deploy flag" _set_deploy_flag fi -# NOTE: no daemon restart here on updater/ changes: mid-batch restart cancels+reverts (see 2026-06-19 self-update-ordering spec). +# No daemon restart on updater/ changes: mid-batch it would cancel and revert the batch. diff --git a/updater/locking.py b/updater/locking.py index d0f4ac10..3e2e642d 100644 --- a/updater/locking.py +++ b/updater/locking.py @@ -9,18 +9,17 @@ def _runtime_dir() -> Path: - """Return a writable user-owned runtime dir, preferring tmpfs over the cache.""" + """Return a 0700 runtime dir (tmpfs first) so no one else can plant a sentinel.""" cache = Path.home() / ".cache" / "blockscreen" for d in (Path("/run/blockscreen"), cache): try: d.mkdir(parents=True, exist_ok=True) - # Owner-only: nothing else may plant a sentinel to force a restart. with contextlib.suppress(OSError): d.chmod(0o700) return d except OSError: continue - # Broken home: return the cache path so open() surfaces the error (no /tmp). + # Broken home: let open() fail loudly instead of falling back to /tmp. return cache @@ -36,11 +35,10 @@ def restart_sentinel_path() -> Path: @contextlib.contextmanager def process_lock() -> Iterator[bool]: - """Acquire the shared updater lock non-blocking.""" + """Acquire the shared updater lock non-blocking; an OSError counts as not held.""" try: f = open(lock_path(), "w") # noqa: SIM115, PTH123 except OSError: - # Disk-full/RO SD: treat as "not acquired" so the caller degrades gracefully. yield False return try: diff --git a/updater/models.py b/updater/models.py index 8cbbd2ea..edd9ea63 100644 --- a/updater/models.py +++ b/updater/models.py @@ -6,6 +6,8 @@ @dataclass class ComponentConfig: + """One components.yaml entry; restart_ui/restart_klipper add that restart.""" + name: str kind: str path: Path | None = None @@ -17,15 +19,15 @@ class ComponentConfig: apt_exclude: tuple[str, ...] = () url: str | None = None install_if_missing: bool = False - # Restart BlocksScreen on update even when the component's service differs. restart_ui: bool = False - # Restart klipper on update even when the component's own service differs. restart_klipper: bool = False health_url: str | None = None @dataclass(frozen=True) class ComponentStatus: + """Point-in-time update status of one component.""" + name: str kind: str = "git" commits_behind: int = 0 @@ -37,7 +39,5 @@ class ComponentStatus: error: str | None = None has_local_changes: bool = False needs_install: bool = False - # Checked-out branch differs from configured branch (switch needed). branch_mismatch: bool = False - # Actual checked-out branch, surfaced for debugging branch switches. current_branch: str = "" diff --git a/updater/service.py b/updater/service.py index c2258567..fbd40859 100644 --- a/updater/service.py +++ b/updater/service.py @@ -60,30 +60,26 @@ from updater.models import ComponentConfig, ComponentStatus _STATE_PATH = Path.home() / ".cache" / "blockscreen" / "updater_state.json" -# SD-backed batch map name->pre-update hash; present at boot = revert those repos. +# name -> pre-update hash; present at boot = revert those repos. _INFLIGHT_PATH = Path.home() / ".cache" / "blockscreen" / "updater_inflight.json" -# Self-heal fault marker: present = fast recovery saturated (golden also looped). +# Present = fast recovery saturated (golden also looped). _FAULT_MARKER_PATH = Path.home() / ".cache" / "blockscreen" / "selfheal_fault.json" _HISTORY_PATH = Path.home() / ".cache" / "blockscreen" / "update_history.jsonl" -# Upgradable-count cache written by check_apt_status; stale after any apt upgrade. _APT_STATUS_CACHE = Path.home() / ".cache" / "blockscreen" / "apt_status_cache.json" -# Cap the history so a device running for years cannot fill the SD card. _HISTORY_MAX_BYTES = 1_000_000 _HISTORY_KEEP_LINES = 2000 -# Circuit-breaker backoff for network ops (apt, git fetch): skip while cooling down so a failure can't storm-retry. +# Failure backoff for network ops (apt and git fetch) so they can't storm-retry. _APT_BACKOFF_BASE_S = 30.0 _APT_BACKOFF_MAX_S = 1800.0 _APT_PERMANENT_COOLDOWN_S = 3600.0 -# apt-get update interval on the background poll, and floor between user-triggered refreshes. +# apt-get update TTL on the poll, and the floor between forced refreshes. _APT_LIST_TTL_S = 86_400.0 _APT_LIST_FORCE_TTL_S = 300.0 _FETCH_BACKOFF_BASE_S = 30.0 _FETCH_BACKOFF_MAX_S = 900.0 -# Self-heal: NRestarts polling interval (seconds) for crash-loop detection. _NRESTARTS_POLL_INTERVAL_S = 15.0 -# Trailing window for crash-loop detection: 5+ restarts in 180 seconds. _NRESTARTS_WINDOW_S = 180.0 _NRESTARTS_THRESHOLD = 5 @@ -117,7 +113,7 @@ def _move_untracked(src: Path, dst: Path, entries: list[str]) -> list[str]: for rel in entries: target = dst / rel if os.path.lexists(target): - continue # the fresh clone wins + continue try: (src / rel).rename(target) except OSError: @@ -159,27 +155,24 @@ def reset(self) -> None: # git's empty tree as provisioning prev_hash: diff hooks see all files as new. _GIT_EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" -# UI services (our D-Bus client): no-block restart so self-update can't kill the batch. +# No-block restart: restarting our own D-Bus client must not kill the batch. _UI_SERVICE = "BlocksScreen.service" _FIRE_AND_FORGET_SERVICES = frozenset({_UI_SERVICE}) -# Fallback klipper unit for restart_klipper if no klipper component is configured. _KLIPPER_SERVICE = "klipper.service" -# Self-heal: the UI component name (components.yaml) that the supervisor watches. _UI_COMPONENT = "BlocksScreen" # A requested daemon restart that has not happened by now is assumed lost. _RESTART_PENDING_TTL_S = 600.0 -# Marker file proving updater exists: absence at target ref aborts update (lack bricks Type=notify host with no self-heal). +# Absent at the target ref = abort: a Type=notify host without it crash-loops. _UPDATER_MARKER = "updater/dbus_service.py" -# Forward-heal always targets the curated-stable channel, not the configured branch. +# Forward-heal targets the curated stable channel, not the configured branch. _HEAL_REMOTE_REF = "origin/main" -# Settle window after a rung (debounce plus margin) so a new build can bless first. +# Debounce plus margin so a new build can bless itself first. _RECOVERY_SETTLE_S = 90.0 -# Slow forward-heal cadence base and jitter spread (seconds), per fleet OTA practice. _FORWARD_HEAL_BASE_S = 1800.0 _FORWARD_HEAL_JITTER_S = 300.0 -# Deploy flag for BlocksScreen-deploy.path: runs install-updater.sh in its own cgroup. +# Watched by BlocksScreen-deploy.path: install-updater.sh runs in its own cgroup. _DEPLOY_FLAG = Path.home() / ".config" / "blockscreen" / ".run-install-updater" @@ -252,7 +245,7 @@ def __init__(self, callback: ProgressCallback | None = None) -> None: self._apt_backoff = _Backoff( _APT_BACKOFF_BASE_S, _APT_BACKOFF_MAX_S, _APT_PERMANENT_COOLDOWN_S ) - # -inf, not 0.0: time.monotonic()'s epoch is undefined and can be near-zero on a freshly booted host, which would make a real "never refreshed" sentinel look recent. + # -inf: monotonic() may start near 0 on a fresh boot, faking a recent refresh. self._apt_list_time: float = float("-inf") self._fetch_backoff: dict[str, _Backoff] = {} self._state_path = _STATE_PATH @@ -260,7 +253,6 @@ def __init__(self, callback: ProgressCallback | None = None) -> None: self._history_path = _HISTORY_PATH self._fault_marker_path = _FAULT_MARKER_PATH self._log = logging.getLogger("updater") - # Self-heal: trailing-window sample ring for crash-loop detection. self._nrestarts_samples: dict[str, list[tuple[float, int]]] = {} self._restart_pending_until = 0.0 @@ -289,14 +281,14 @@ async def _refresh_apt_lists(self, force: bool) -> None: """Run apt-get update: the upgradable count reads local lists and is stale without it.""" now = time.monotonic() ttl = _APT_LIST_FORCE_TTL_S if force else _APT_LIST_TTL_S - # A held lock means an update is already running, and it refreshes the lists itself. + # A held lock = an update is running, and it refreshes the lists itself. if ( (now - self._apt_list_time) < ttl or self._apt_backoff.cooling_down() or self._apt_lock.locked() ): return - # Rate-limit attempts, not successes, so an offline box cannot retry on every refresh. + # Stamp attempts, not successes: an offline box must not retry every refresh. self._apt_list_time = now async with self._apt_lock: ok, err = await apt_update() @@ -305,7 +297,6 @@ async def _refresh_apt_lists(self, force: bool) -> None: def has_fetch_failures(self) -> bool: """True while any component's git fetch is failing (entry is popped on success).""" - # No _git_lock: a plain dict truthiness read has no await point, so it cannot interleave. return bool(self._fetch_backoff) async def check_status(self, force: bool = False) -> dict[str, ComponentStatus]: @@ -316,12 +307,10 @@ async def _check_one(c: ComponentConfig) -> None: """Fetch and record one component's status into the results dict.""" if c.kind == "apt": await self._refresh_apt_lists(force) - # force bypasses the apt cache, mirroring the git fetch TTL bypass. status = await check_apt_status( cache_ttl_seconds=0 if force else 86_400, exclude=c.apt_exclude ) elif c.path is None or not c.path.exists(): - # Missing opted-in comps surface as needs_install; rest stay skipped. if c.install_if_missing and c.url: results[c.name] = ComponentStatus(name=c.name, needs_install=True) return @@ -337,7 +326,6 @@ async def _check_one(c: ComponentConfig) -> None: status = await check_git_status( c.name, c.path, c.branch, c.version, skip_fetch ) - # Record success; back off a failing fetch per-component so it can't storm the poll. if not skip_fetch: async with self._git_lock: if status.error is None: @@ -422,11 +410,10 @@ async def _run_update_phases( batch: list[ComponentConfig], provision: list[ComponentConfig], ) -> bool: - """Run apt updates, then provisioning, then the git batch; AND all results.""" + """Run apt, provisioning, then the git batch (its UI restart must come last).""" ok = True for c in apt: ok = await self._run_apt_update(c) and ok - # Provision first: the batch ends with a fire-and-forget UI restart, so a component installed after it stays invisible until next reboot. for c in provision: ok = await self._provision_component(c) and ok if batch: @@ -448,7 +435,6 @@ async def _filter_offline_batch( self, batch: list[ComponentConfig], offline: set[str] ) -> bool: """Drop offline components from the batch, erroring each individually.""" - # An unreachable remote drops only that component, never the whole update. ok = True for c in [c for c in batch if c.name in offline]: batch.remove(c) @@ -457,12 +443,11 @@ async def _filter_offline_batch( async def _filter_nonrepo_batch(self, batch: list[ComponentConfig]) -> bool: """Drop non-git-repo dirs, quarantining installable ones for a fresh clone.""" - # A dir without .git (tarball install) errors individually, not the batch. ok = True for c in [c for c in batch if not is_git_repo(c.path)]: batch.remove(c) if c.install_if_missing and c.url and await self._quarantine_nonrepo(c): - continue # path is now absent: the provision pass below clones fresh + continue self._log.error("%s: %s is not a git repository - skipping", c.name, c.path) ok = self._cb_error_done( c.name, "not a git repository - reinstall required" @@ -471,7 +456,6 @@ async def _filter_nonrepo_batch(self, batch: list[ComponentConfig]) -> bool: async def _filter_dead_branch_batch(self, batch: list[ComponentConfig]) -> bool: """Drop components whose effective upstream ref (configured or current) is gone.""" - # A dead ref (configured or deleted current branch) must not abort the batch. ok = True for c in batch.copy(): branch = c.branch @@ -529,18 +513,16 @@ async def _preflight_fetch( self, sorted_components: list[ComponentConfig] ) -> set[str]: """Fetch every existing git component up-front (network phase).""" - # Non-repo dirs excluded: their fetch failure is not "offline" (see update_all). + # Non-repos excluded: their fetch failure does not mean offline. targets = [ c for c in sorted_components if c.kind == "git" and c.path is not None and is_git_repo(c.path) ] offline: set[str] = set() - # Lock guards only _fetch_times; git_fetch runs outside it (as check_status). for c in targets: now = time.monotonic() async with self._git_lock: - # Skip if fetched <30s ago; apply phase still skips its own fetch. recent = now - self._fetch_times.get(c.name, float("-inf")) < 30 if recent: continue @@ -579,11 +561,10 @@ async def _persist_batch_rollback(self, prev: dict[str, str]) -> bool: async with self._state_lock: state = await asyncio.to_thread(self._read_state) for name, ph in prev.items(): - # Merge: replacing the entry would wipe the self-heal anchors (last_good/golden). + # Merge, not replace: keeps the self-heal anchors (last_good/golden). _ensure_comp(state, name)["prev_hash"] = ph if not await asyncio.to_thread(self._write_state, state): return False - # Mark in-flight so a pre-commit power cut is reverted on next boot. if not await asyncio.to_thread(self._write_inflight, prev.copy()): return False return True @@ -720,7 +701,6 @@ async def _batch_restart_services( await self._drop_component( m, "restart", alive, prev, touched, pending_revert ) - # Members reverted: bring the service back up on the old code. if not await self._restart_one(c.service): self._log.error("%s did not recover after revert", c.service) restarted.remove(c.service) @@ -736,7 +716,6 @@ async def _batch_restart_klipper_bounce( seen: set[str], ) -> bool: """Bounce klipper once for restart_klipper components, reverting them on failure.""" - # Bounce klipper once for restart_klipper components that aren't klipper. klipper_svc = self._klipper_service() requesters = [ c for c in alive if c.restart_klipper and c.service != klipper_svc @@ -753,14 +732,13 @@ async def _batch_restart_klipper_bounce( await self._drop_component( m, "restart", alive, prev, touched, pending_revert ) - # Service runs new code: revert it to old code unless a surviving component shares the service. + # Revert-restart its service unless a surviving component shares it. shared = any(o.service == m.service for o in alive) if m.service and m.service in restarted and not shared: if not await self._restart_one(m.service): self._log.error("%s did not recover after revert", m.service) restarted.remove(m.service) restarted.remove(klipper_svc) - # Requesters reverted: try to bring klipper back up. if not await self._restart_one(klipper_svc): self._log.error("%s did not recover after revert", klipper_svc) return failed @@ -774,7 +752,6 @@ async def _batch_restart_phase( pending_revert: dict[str, str], ) -> tuple[bool, list[ComponentConfig]]: """Restart each unique service once, reverting all components behind a failed one.""" - # Restart each unique service once; failure reverts all components behind it and re-restarts onto old code. self._log.info("git batch: restart phase") seen: set[str] = set() svc_failed, ui_components = await self._batch_restart_services( @@ -807,7 +784,6 @@ async def _finalize_git_batch( self._cb("on_step", c.name, 4, 4) if c.service: ui_services.add(c.service) - # klipper/RF50 hold config the UI reads at startup: refresh it too. if any(c.restart_ui for c in alive): if _UI_SERVICE not in ui_services: self._cb("on_step", _UI_COMPONENT, 4, 4) # UI holds its overlay @@ -831,7 +807,6 @@ async def _git_batch_preflight( alive, sec_failed = await self._security_check_batch(alive, prev) failed = failed or sec_failed if not alive: - # Nothing staged: drop the marker (avoids a spurious boot revert). await asyncio.to_thread(self._clear_inflight) return None return alive, prev, failed @@ -873,7 +848,7 @@ async def _run_git_batch(self, batch: list[ComponentConfig]) -> bool: ) touched: list[ComponentConfig] = [] restarted: list[str] = [] - # Repos whose revert failed: kept in in-flight marker so boot reconcile retries them, not committed survivors. + # Failed reverts stay in the inflight marker for the boot reconcile. pending_revert: dict[str, str] = {} committed = False try: @@ -952,7 +927,6 @@ async def _abort_batch( self._log.error( "abort: %s reset to %s failed", c.name, prev[c.name][:12] ) - # Only unresolved repos stay in the marker for a boot-time retry. await self._settle_inflight(pending) revert_ok = not pending restart_ok = True @@ -1071,7 +1045,6 @@ def _touch_deploy_flag(self) -> None: if _DEPLOY_FLAG.is_symlink(): _DEPLOY_FLAG.unlink() _DEPLOY_FLAG.touch() - # Persist the dirent so a power cut right after this can't drop the flag. self._fsync_dir(_DEPLOY_FLAG.parent) async def recover(self, name: str, hard: bool = False) -> bool: @@ -1131,7 +1104,7 @@ def _apply(state: dict) -> None: comp = _ensure_comp(state, name) comp["last_good"] = hash_val if not _is_sha(comp.get("golden")): - comp["golden"] = hash_val # seed once, or repair a corrupt golden + comp["golden"] = hash_val comp["fast_attempt"] = 0 comp["nrestarts_baseline"] = nrestarts_baseline comp.pop("last_failed_remote", None) @@ -1151,7 +1124,7 @@ def _apply(state: dict) -> None: def _check_crash_loop(self, name: str, nrestarts: int) -> bool: """Return True if NRestarts rose by >= 5 within the trailing 180s window.""" if name not in self._nrestarts_samples: - self._nrestarts_samples[name] = [] # fresh device: start tracking now + self._nrestarts_samples[name] = [] samples = self._nrestarts_samples[name] now = time.monotonic() window_start = now - _NRESTARTS_WINDOW_S @@ -1198,7 +1171,6 @@ async def run_recovery_rung(self, name: str, attempt: int) -> bool: lambda s: _ensure_comp(s, name).update(fast_attempt=attempt) ) comp_state = (await asyncio.to_thread(self._read_state)).get(name, {}) - # entry-counter write above may not have persisted; state may still be corrupt if not isinstance(comp_state, dict): comp_state = {} if attempt == 1: @@ -1243,7 +1215,6 @@ async def _recovery_rung2(self, component: ComponentConfig, name: str) -> bool: if not tip: self._log.warning("recovery rung 2: %s unresolved", _HEAL_REMOTE_REF) return False - # Never heal the host onto a pre-updater tip (would re-brick, not fix). if name == _UI_COMPONENT and not await git_tree_has_path( component.path, tip, _UPDATER_MARKER ): @@ -1307,7 +1278,6 @@ async def supervise_ui(self) -> None: if self._check_crash_loop(_UI_COMPONENT, nrestarts): await self._handle_crash_loop(nrestarts) except Exception: # noqa: BLE001 - # One bad pass must not kill crash-loop supervision for good. self._log.error("supervise_ui pass failed", exc_info=True) async def _handle_crash_loop(self, nrestarts: int) -> None: @@ -1352,11 +1322,10 @@ async def _forward_heal_target( async with self._git_lock: ok_fetch, _ = await git_fetch(component.path) if not ok_fetch: - return None # offline: connectivity gate + return None tip = await git_ref_hash(component.path, _HEAL_REMOTE_REF) if not tip or tip == comp_state.get("last_failed_remote"): - return None # no new stable tip since the last failure - # Never heal onto a pre-updater tip; by SHA, as a later fetch may move the ref. + return None if not await git_tree_has_path(component.path, tip, _UPDATER_MARKER): self._log.warning( "forward-heal: %s lacks the updater package - skipping", @@ -1373,7 +1342,7 @@ async def _forward_heal_once(self) -> bool: return False raw = comp_state.get("fast_attempt", 0) if not (isinstance(raw, int) and not isinstance(raw, bool)) or raw < 2: - return False # healthy, not yet in deep fallback, or corrupt counter + return False target = await self._forward_heal_target(comp_state) if target is None: return False @@ -1430,7 +1399,7 @@ async def reconcile(self) -> None: await self._reconcile_locked() async def _revert_inflight(self) -> None: - """Revert any update cut off mid-flight by a power loss before it committed.""" + """Revert power-cut batches; failed reverts stay in the marker for retry.""" inflight = await asyncio.to_thread(self._read_inflight) if not inflight: return @@ -1439,10 +1408,9 @@ async def _revert_inflight(self) -> None: len(inflight), ) by_name = {c.name: c for c in self._components} - unresolved: dict[str, str] = {} # reverts that failed: kept for next-boot retry + unresolved: dict[str, str] = {} for name, prev_hash in inflight.items(): comp = by_name.get(name) - # Gone component/path or invalid hash can never revert: drop (bounds retries). if comp is None or comp.path is None or not comp.path.exists(): continue if not _GIT_SHA_RE.match(prev_hash): @@ -1450,7 +1418,7 @@ async def _revert_inflight(self) -> None: continue async with self._git_lock: if await git_get_hash(comp.path) == prev_hash: - continue # already at the pre-update commit + continue rok, _ = await git_reset_to_hash(comp.path, prev_hash) self._history("boot_rollback", name, ok=rok, reverted_to=prev_hash[:12]) self._log.warning( @@ -1460,8 +1428,7 @@ async def _revert_inflight(self) -> None: rok, ) if not rok: - unresolved[name] = prev_hash # keep marker so next boot retries - # Clear on full success; else persist only the still-failing entries (retry). + unresolved[name] = prev_hash if unresolved: await asyncio.to_thread(self._write_inflight, unresolved) else: @@ -1475,7 +1442,6 @@ async def _boot_repair_component(self, c: ComponentConfig) -> bool: if await git_get_hash(c.path) != "": return False self._log.warning("reconcile: %s HEAD unreadable - repairing", c.name) - # No configured branch: git_repair derives the repo's own default. ok, msg = await git_repair(c.path, c.branch) if ok and await git_get_hash(c.path) != "": self._history("boot_repair", c.name, detail=msg[:80]) @@ -1509,7 +1475,6 @@ async def _boot_reclone_hook(self, c: ComponentConfig) -> None: if not hook_ok: self._log.warning("%s: post-reclone hook failed: %s", c.name, hook_err) except Exception: # noqa: BLE001 - # Best-effort: a hook crash must not kill the rest of boot heal. self._log.warning("%s: post-reclone hook raised", c.name, exc_info=True) async def _reconcile_locked(self) -> None: @@ -1522,7 +1487,6 @@ async def _reconcile_locked(self) -> None: continue if await self._boot_repair_component(c): recloned.append(c) - # Reclone drops in-repo artifacts; rebuild them best-effort outside _git_lock. for c in recloned: await self._boot_reclone_hook(c) await self._reconcile_self_heal_state() @@ -1582,7 +1546,6 @@ async def _rollback( if ok: await asyncio.to_thread(self._clear_inflight) else: - # A failed revert keeps the marker so boot _revert_inflight retries it. self._log.warning("rollback: revert failed - keeping in-flight marker") if component.service and not await self._safe_restart( component.service, component.health_url @@ -1609,7 +1572,7 @@ async def _install_dependencies( pip_path = self._component_pip_cache[cache_key] if pip_path == PIP: - # No venv: PEP 668 blocks system pip; component installer owns deps. + # No venv: PEP 668 blocks system pip; the component's installer owns deps. self._log.info( "%s: no component venv - skipping dep install", component.name ) @@ -1619,12 +1582,11 @@ async def _install_dependencies( return (True, "no requirements.txt") mode = req.stat().st_mode & 0o777 if mode & 0o002: - # SEC: world-writable only; group-writable is permitted (blocksscreen group is trusted) + # Group-writable is fine: the blocksscreen group is trusted. return (False, "world-writable permissions") - # Keep pip current (best-effort: a failed upgrade must not block reqs). await _run([pip_path, "install", "--upgrade", "pip", "--quiet"], timeout=120.0) - # Generous: one aarch64 source build (no wheel) easily exceeds 120s on a Pi. + # One aarch64 source build (no wheel) can exceed 120s on a Pi. return await _run( [pip_path, "install", "-r", str(req), "--quiet"], timeout=600.0 ) @@ -1703,7 +1665,6 @@ def _quarantine_sync(self, path: Path) -> Path | None: except OSError as exc: self._log.error("quarantine of %s failed: %s", path, exc) return None - # The venv moved with the dir: a cached pip path would now dangle. self._component_pip_cache.pop(str(path), None) return dest @@ -1727,12 +1688,17 @@ async def _remove_clone(self, component: ComponentConfig) -> None: if component.path is not None: await asyncio.to_thread(shutil.rmtree, component.path, ignore_errors=True) - async def _fail_provision(self, component: ComponentConfig, reason: str) -> bool: - """Remove the partial clone, log, and report failure.""" - if component.service and reason in ("hook", "restart"): - # The hook may have enabled it: it would crash-loop on the deleted dir. + async def _undo_provision(self, component: ComponentConfig, hooked: bool) -> None: + """Drop the clone, first disabling a hook-enabled unit (else it crash-loops).""" + if hooked and component.service: await disable_service(component.service) await self._remove_clone(component) + + async def _fail_provision( + self, component: ComponentConfig, reason: str, hooked: bool = False + ) -> bool: + """Undo the partial install, log, and report failure.""" + await self._undo_provision(component, hooked) self._history("install_failed", component.name, reason=reason) self._log.warning( "%s: provision failed (%s), partial clone removed", component.name, reason @@ -1768,6 +1734,7 @@ async def _provision_component(self, component: ComponentConfig) -> bool: return self._cb_error_done(component.name, "no clone url") self._log.info("%s: provisioning via clone %s", component.name, component.url) self._history("install_start", component.name, url=component.url) + hooked = False try: self._cb("on_step", component.name, 1, 4) ok, err = await git_clone(component.url, component.path, component.branch) @@ -1789,6 +1756,7 @@ async def _provision_component(self, component: ComponentConfig) -> bool: return await self._fail_provision(component, "deps") self._cb("on_step", component.name, 3, 4) + hooked = True hook_ok, hook_err = await self._ping_while( run_hook( component.name, @@ -1803,12 +1771,12 @@ async def _provision_component(self, component: ComponentConfig) -> bool: ) if not hook_ok: self._log.error("%s: provision hook: %s", component.name, hook_err) - return await self._fail_provision(component, "hook") + return await self._fail_provision(component, "hook", hooked) self._cb("on_step", component.name, 4, 4) reason = await self._provision_restart_service(component) if reason: - return await self._fail_provision(component, reason) + return await self._fail_provision(component, reason, hooked) self._history("install_success", component.name, new_hash=new_hash[:12]) self._cb("on_component_done", component.name, True) @@ -1818,14 +1786,15 @@ async def _provision_component(self, component: ComponentConfig) -> bool: "%s: provision cancelled, removing partial clone", component.name ) await self._shielded( - self._remove_clone(component), f"{component.name} provision-cleanup" + self._undo_provision(component, hooked), + f"{component.name} provision-cleanup", ) raise except Exception: # noqa: BLE001 self._log.error( "%s: unexpected error during provision", component.name, exc_info=True ) - return await self._fail_provision(component, "unexpected_error") + return await self._fail_provision(component, "unexpected_error", hooked) async def _reclone_into_tmp(self, component: ComponentConfig, tmp: Path) -> bool: """Clone (+ optional version pin) into a temp dir; rmtree + False on failure.""" @@ -1855,7 +1824,7 @@ async def _reclone_swap( await asyncio.to_thread(os.rename, path, old) await asyncio.to_thread(os.rename, tmp, path) except OSError as exc: - if not path.exists() and old.exists(): # restore after a half-done swap + if not path.exists() and old.exists(): with contextlib.suppress(OSError): await asyncio.to_thread(os.rename, old, path) await asyncio.to_thread(shutil.rmtree, tmp, ignore_errors=True) @@ -1872,7 +1841,7 @@ async def _carry_untracked( """Carry untracked files (.config, venvs, symlinks) into the fresh tree.""" entries = None # The fresh index is used as the old one may be the corrupt part. - with contextlib.suppress(OSError): # a spawn failure must not undo the swap + with contextlib.suppress(OSError): entries = await git_untracked_paths(old, path / ".git") if entries is None: self._log.warning( @@ -1890,13 +1859,12 @@ async def _reclone_component(self, component: ComponentConfig) -> bool: path = component.path tmp = path.parent / f".{path.name}.reclone-tmp" old = path.parent / f".{path.name}.reclone-old" - for stale in (tmp, old): # clear orphans from a crashed prior reclone + for stale in (tmp, old): await asyncio.to_thread(shutil.rmtree, stale, ignore_errors=True) if not await self._reclone_into_tmp(component, tmp): return False if not await self._reclone_swap(component, path, tmp, old): return False - # An in-repo venv may not have been carried over: re-resolve pip. self._component_pip_cache.pop(str(path), None) self._history("reclone", component.name, url=component.url) self._log.warning("%s: recloned successfully", component.name) @@ -1932,7 +1900,7 @@ async def _stage_fetch_gate( rok, rmsg = await git_repair(component.path) if rok: self._history("repair", component.name, detail=rmsg[:80]) - elif await self._reclone_component(component): # deepest rung + elif await self._reclone_component(component): self._history("repair", component.name, detail="recloned") else: return (False, "corrupt") @@ -1942,11 +1910,9 @@ async def _stage_guard_target( self, component: ComponentConfig, ref: str, tip: str ) -> tuple[bool, str] | None: """Pre-checkout guards: dead upstream branch + updater-marker brick guard.""" - # A deleted upstream branch must fail here, not strand the repo mid-switch. if component.branch and not tip: return (False, f"branch {ref} not found - fix components.yaml") - # Updater host must never checkout code lacking updater: Type=notify unit lacks sd_notify READY causes crash loop with no self-heal. if component.name == _UI_COMPONENT: target = component.version or tip if not target or not await git_tree_has_path( @@ -1966,9 +1932,7 @@ async def _stage_apply_ref( """Checkout target branch, then hard-reset to the guarded tip / version-pin / soft-pull.""" if component.path is None: return (False, "path not found") - # Switch to the target branch FIRST so reset/pull act on the right branch. if component.branch: - # hard mode forces past untracked collisions (build artifacts). force = component.reset_mode == "hard" ok, err = await git_checkout(component.path, component.branch, force=force) if not ok: @@ -1976,7 +1940,6 @@ async def _stage_apply_ref( return (False, "branch") if component.reset_mode == "hard": - # Reset the (now current) branch to its remote tip, discarding divergence. ok, err = ( await git_reset_to_hash(component.path, tip) if tip @@ -1994,7 +1957,6 @@ async def _stage_apply_ref( self._log.error("%s: version pin failed: %s", component.name, err) return (False, "version") elif component.reset_mode != "hard": - # Soft mode: fast-forward (branch already checked out, or default). ok, err = await git_pull(component.path) if not ok: self._log.error("%s: git_pull failed: %s", component.name, err) @@ -2046,7 +2008,6 @@ async def _preflight_git_update(self, component: ComponentConfig) -> bool | None if component.install_if_missing and component.url: return await self._provision_component(component) return self._cb_error_done(component.name, "path not found") - # Non-repo dir (e.g. pre-updater tarball install): nothing to update or revert. if not is_git_repo(component.path): if ( component.install_if_missing @@ -2072,7 +2033,7 @@ async def _prepare_rollback_point( return self._cb_error_done(component.name, "prev_hash empty"), "" async with self._state_lock: state = await asyncio.to_thread(self._read_state) - # Merge: replacing the entry would wipe the self-heal anchors (last_good/golden). + # Merge, not replace: keeps the self-heal anchors (last_good/golden). _ensure_comp(state, component.name)["prev_hash"] = prev_hash if not await asyncio.to_thread(self._write_state, state): return ( @@ -2093,7 +2054,6 @@ async def _prepare_rollback_point( ok, reason = await _assert_https_remote(component.path) if not ok: self._log.error("SEC-4 remote check failed: %s", reason) - # Nothing staged: drop the marker (avoids a spurious boot revert). await asyncio.to_thread(self._clear_inflight) return self._cb_error_done(component.name, "insecure remote"), "" self._history("update_start", component.name, prev_hash=prev_hash[:12]) @@ -2114,7 +2074,6 @@ async def _run_git_phases( ): await asyncio.to_thread(self._clear_inflight) return self._cb_error_done(component.name, stage_reason), "" - # checkout/reset/pin/pull may have moved the tree: full rollback. await self._rollback(component, prev_hash, stage_reason) return False, "" @@ -2153,7 +2112,6 @@ async def _run_git_phases( ): await self._rollback(component, prev_hash, "restart") return False, "" - # Bounce klipper too when requested and it isn't the component's own service. klipper_svc = self._klipper_service() if ( component.restart_klipper @@ -2167,7 +2125,6 @@ async def _run_git_phases( async def _fire_and_forget_restart(self, component: ComponentConfig) -> None: """Post-commit: kick self/UI/klipper-config restart without waiting.""" fire_and_forget = component.service in _FIRE_AND_FORGET_SERVICES - # Self/UI service: queue the restart only after success is recorded. if fire_and_forget and component.service: self._log.info( "%s updated; fire-and-forget restart of %s (no wait)", @@ -2175,7 +2132,6 @@ async def _fire_and_forget_restart(self, component: ComponentConfig) -> None: component.service, ) await restart_service_noblock(component.service) - # klipper/RF50 hold config the UI reads at startup: refresh it too. elif component.restart_ui: self._log.info( "%s updated (restart_ui); fire-and-forget restart of %s", @@ -2326,7 +2282,6 @@ def _read_state(self) -> dict: data = json.loads(self._state_path.read_text()) except (OSError, ValueError): return {} - # Valid JSON of the wrong shape (torn/corrupt write) must read as empty. return data if isinstance(data, dict) else {} def _read_inflight(self) -> dict[str, str]: @@ -2337,7 +2292,6 @@ def _read_inflight(self) -> dict[str, str]: return {} if not isinstance(data, dict): return {} - # Drop corrupt entries so a torn write can't crash the boot revert. return {k: v for k, v in data.items() if isinstance(k, str) and _is_sha(v)} def _write_inflight(self, mapping: dict[str, str]) -> bool: @@ -2374,7 +2328,6 @@ def _write_state(self, data: dict) -> bool: """Atomically write the self-heal state file (temp, fsync, replace).""" try: self._state_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - # SEC: atomic write via temp file prevents symlink attacks and partial writes with tempfile.NamedTemporaryFile( mode="w", dir=self._state_path.parent, @@ -2382,7 +2335,6 @@ def _write_state(self, data: dict) -> bool: prefix=".updater_state_", ) as f: f.write(json.dumps(data, indent=2)) - # fsync file+dir so the rollback point survives a power cut. f.flush() os.fsync(f.fileno()) temp_path = Path(f.name) @@ -2428,7 +2380,7 @@ async def _background_apt_upgrade_locked(self) -> None: self._apt_failed(err) return self._apt_list_time = time.monotonic() - # Honor the apt excludes: a silent background kernel/firmware bump is the brick risk they prevent. + # A silent kernel/firmware bump is exactly the brick the excludes prevent. exclude = tuple( pat for c in self._components if c.kind == "apt" for pat in c.apt_exclude ) @@ -2438,7 +2390,7 @@ async def _background_apt_upgrade_locked(self) -> None: self._apt_failed(err) return self._apt_backoff.reset() - # Drop the count cache: the sweep upgraded, so the UI would keep showing pending packages. + # The sweep upgraded: drop the count cache or the UI keeps showing pending. _APT_STATUS_CACHE.unlink(missing_ok=True) self._log.info("background apt upgrade: packages done") autoremove_ok, autoremove_err = await apt_autoremove() From 6aae6f22af5445e438f7639b35386d655aff96d4 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Thu, 1 Oct 2026 16:56:23 +0100 Subject: [PATCH 15/21] fix(updater): bus activation Exec=, install triggers, single rollback authority, deferred boot heal, shutdown/hook/log fixes --- scripts/BlocksScreen-start.sh | 7 ++- scripts/bs-common.sh | 17 ++++++ scripts/com.blockscreen.Updater.service | 1 + scripts/post-merge | 2 +- tests/scripts/test_dbus_activation.py | 56 ++++++++++++++++++ tests/scripts/test_selfheal_engaged.py | 69 +++++++++++++++++++++++ tests/updater/conftest.py | 3 + tests/updater/test_dbus_service_unit.py | 75 +++++++++++++++++++++++++ tests/updater/test_executor_unit.py | 17 ++++++ tests/updater/test_service_unit.py | 59 ++++++++++++++++++- updater/__main__.py | 3 + updater/dbus_service.py | 32 ++++++++++- updater/executor.py | 2 + updater/hooks/BlocksScreen.sh | 6 +- updater/service.py | 26 ++++++--- 15 files changed, 362 insertions(+), 13 deletions(-) create mode 100644 tests/scripts/test_dbus_activation.py create mode 100644 tests/scripts/test_selfheal_engaged.py diff --git a/scripts/BlocksScreen-start.sh b/scripts/BlocksScreen-start.sh index b1950016..1b866a02 100755 --- a/scripts/BlocksScreen-start.sh +++ b/scripts/BlocksScreen-start.sh @@ -146,7 +146,12 @@ printf '%s\n' "$_attempts" > "$_BOOT_DIR/.boot_attempts.tmp" 2>/dev/null \ echo "[BlocksScreen-start] boot attempt $_attempts" _last_good=$(tr -d '[:space:]' < "$_BOOT_DIR/last_good_commit" 2>/dev/null || echo "") _cur_head=$(git -C "$BS_PATH" rev-parse HEAD 2>/dev/null || echo "") -if [ "$_attempts" -ge "$_MAX_BOOT_ATTEMPTS" ] && [ -n "$_last_good" ] \ +if [ "$_attempts" -ge "$_MAX_BOOT_ATTEMPTS" ] \ + && bs_selfheal_engaged "$_BOOT_DIR/updater_state.json" "$BSENV/bin/python3.11"; then + # One rollback authority at a time: reverting here would undo the daemon's rung (it resets HEAD on purpose). + echo "BlocksScreen: crash loop ($_attempts boots) - updater self-heal owns recovery, not rolling back" + printf '0\n' > "$_BOOT_DIR/boot_attempts" 2>/dev/null || true +elif [ "$_attempts" -ge "$_MAX_BOOT_ATTEMPTS" ] && [ -n "$_last_good" ] \ && [ -n "$_cur_head" ] && [ "$_last_good" != "$_cur_head" ]; then echo "BlocksScreen: crash loop ($_attempts boots) - rolling back to ${_last_good:0:8}" if git -C "$BS_PATH" reset --hard "$_last_good" 2>/dev/null; then diff --git a/scripts/bs-common.sh b/scripts/bs-common.sh index 596a0282..e97a51a5 100644 --- a/scripts/bs-common.sh +++ b/scripts/bs-common.sh @@ -168,6 +168,23 @@ bs_ensure_usb_max_current() { return 0 } +# True while a live updater daemon runs its UI recovery ladder (fast_attempt > 0). $1 = state file, $2 = python. +bs_selfheal_engaged() { + local state="$1" py="$2" + [ -f "$state" ] && [ -x "$py" ] || return 1 + systemctl is-active --quiet BlocksScreen-updater.service 2>/dev/null || return 1 + "$py" - "$state" 2>/dev/null <<'PY' +import json, sys +try: + with open(sys.argv[1], encoding="utf-8") as f: + comp = json.load(f).get("BlocksScreen") +except (OSError, ValueError, AttributeError): + sys.exit(1) +n = comp.get("fast_attempt") if isinstance(comp, dict) else 0 +sys.exit(0 if type(n) is int and n > 0 else 1) +PY +} + # Disable Moonraker management of repos the BlocksScreen daemon now owns, so a # Mainsail "Update All" can't trip on them. Grep-gated marker so it runs once. bs_disable_overlapping_update_managers() { diff --git a/scripts/com.blockscreen.Updater.service b/scripts/com.blockscreen.Updater.service index d4cea4da..49a85732 100644 --- a/scripts/com.blockscreen.Updater.service +++ b/scripts/com.blockscreen.Updater.service @@ -1,4 +1,5 @@ [D-BUS Service] Name=com.blockscreen.Updater +Exec=/bin/false User=blocks SystemdService=BlocksScreen-updater.service diff --git a/scripts/post-merge b/scripts/post-merge index cd3c8a0c..9aaa2ac3 100755 --- a/scripts/post-merge +++ b/scripts/post-merge @@ -104,7 +104,7 @@ _record_or() { fi } -if echo "$changed" | grep -qE '^scripts/BlocksScreen-updater\.service$|^scripts/BlocksScreen-bootstrap\.service$|^scripts/com\.blockscreen\.Updater\.conf$|^scripts/install-updater\.sh$|^scripts/bs-apt-helper\.sh$'; then +if echo "$changed" | grep -qE '^scripts/BlocksScreen-updater\.service$|^scripts/BlocksScreen-bootstrap\.service$|^scripts/com\.blockscreen\.Updater\.conf$|^scripts/com\.blockscreen\.Updater\.service$|^scripts/install-updater\.sh$|^scripts/bs-apt-helper\.sh$'; then echo "[post-merge] Updater install files changed - reinstalling updater ..." _record_or install sudo bash "$SCRIPT_PATH/install-updater.sh" elif echo "$changed" | grep -qE '^scripts/bs-splash-holder\.py$'; then diff --git a/tests/scripts/test_dbus_activation.py b/tests/scripts/test_dbus_activation.py new file mode 100644 index 00000000..fa4c3571 --- /dev/null +++ b/tests/scripts/test_dbus_activation.py @@ -0,0 +1,56 @@ +"""Guard: the D-Bus activation file must be one dbus-daemon will actually load.""" + +from __future__ import annotations + +import configparser +import re +from pathlib import Path + +_ROOT = Path(__file__).resolve().parents[2] +_SCRIPTS = _ROOT / "scripts" +_ACTIVATION = _SCRIPTS / "com.blockscreen.Updater.service" + + +def _section(path: Path, section: str) -> configparser.SectionProxy: + """Parse a systemd-style ini file, keeping key case.""" + cfg = configparser.ConfigParser(interpolation=None) + cfg.optionxform = str # type: ignore[assignment,method-assign] + cfg.read_string(path.read_text()) + return cfg[section] + + +def test_has_exec_line() -> None: + # dbus-daemon activation.c skips any entry without Exec=; systemd units use /bin/false. + assert _section(_ACTIVATION, "D-BUS Service").get("Exec") == "/bin/false" + + +def test_name_matches_filename_daemon_and_client() -> None: + name = _section(_ACTIVATION, "D-BUS Service")["Name"] + assert name == _ACTIVATION.stem + daemon = (_ROOT / "updater" / "__main__.py").read_text() + assert f'request_name_async("{name}"' in daemon + client = (_ROOT / "BlocksScreen" / "lib" / "updater_worker.py").read_text() + assert re.search(rf'^_DAEMON_BUS_NAME = "{re.escape(name)}"$', client, re.M) + + +def test_systemd_service_exists_and_user_matches() -> None: + entry = _section(_ACTIVATION, "D-BUS Service") + unit = _SCRIPTS / entry["SystemdService"] + assert entry["User"] == _section(unit, "Service")["User"] + + +def test_install_triggers_agree_and_cover_bus_files() -> None: + # Only install-updater.sh deploys these: a file missing from a trigger never ships. + merge = (_SCRIPTS / "post-merge").read_text() + regex = re.search(r"Updater install files changed", merge) + assert regex + line = merge[: regex.start()].rsplit("if echo", 1)[1] + merge_set = { + m.replace("\\.", ".") for m in re.findall(r"\^(scripts/[^$]+)\$", line) + } + hook = (_ROOT / "updater" / "hooks" / "BlocksScreen.sh").read_text() + block = hook[: hook.index("install files changed")].rsplit("diff --quiet", 1)[1] + hook_set = set(re.findall(r"scripts/[\w.-]+", block)) + assert merge_set == hook_set + for name in ("com.blockscreen.Updater.service", "com.blockscreen.Updater.conf"): + assert f"scripts/{name}" in hook_set diff --git a/tests/scripts/test_selfheal_engaged.py b/tests/scripts/test_selfheal_engaged.py new file mode 100644 index 00000000..f820d5fa --- /dev/null +++ b/tests/scripts/test_selfheal_engaged.py @@ -0,0 +1,69 @@ +"""Tests for bs-common.sh bs_selfheal_engaged (start-script rollback hand-off).""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +_SCRIPTS = Path(__file__).resolve().parents[2] / "scripts" +_FN = _SCRIPTS / "bs-common.sh" + + +def _engaged(tmp_path: Path, state: object, *, daemon_active: bool = True) -> bool: + # systemctl shim: the helper only asks whether the daemon unit is active. + shim = tmp_path / "bin" + shim.mkdir(exist_ok=True) + fake = shim / "systemctl" + fake.write_text(f"#!/bin/sh\nexit {0 if daemon_active else 3}\n") + fake.chmod(0o755) + path = tmp_path / "updater_state.json" + if state is not None: + path.write_text(state if isinstance(state, str) else json.dumps(state)) + env = {**os.environ, "PATH": f"{shim}:{os.environ.get('PATH', '')}"} + res = subprocess.run( + ["bash", "-c", f'. "{_FN}"; bs_selfheal_engaged "{path}" "{sys.executable}"'], + check=False, + env=env, + timeout=20, + ) + return res.returncode == 0 + + +@pytest.mark.parametrize("attempt", [1, 2, 3]) +def test_engaged_while_ladder_runs(tmp_path: Path, attempt: int) -> None: + assert _engaged(tmp_path, {"BlocksScreen": {"fast_attempt": attempt}}) + + +@pytest.mark.parametrize( + "state", + [ + None, + "{not json", + "[]", + {}, + {"BlocksScreen": "x"}, + {"BlocksScreen": {}}, + {"BlocksScreen": {"fast_attempt": 0}}, + {"BlocksScreen": {"fast_attempt": True}}, + {"BlocksScreen": {"fast_attempt": "2"}}, + {"klipper": {"fast_attempt": 2}}, + ], +) +def test_not_engaged_without_active_ladder(tmp_path: Path, state: object) -> None: + assert not _engaged(tmp_path, state) + + +def test_dead_daemon_hands_rollback_back(tmp_path: Path) -> None: + # A stale fast_attempt must not disable the legacy rollback when nobody is healing. + state = {"BlocksScreen": {"fast_attempt": 2}} + assert not _engaged(tmp_path, state, daemon_active=False) + + +def test_start_script_consults_helper_before_rolling_back() -> None: + text = (_SCRIPTS / "BlocksScreen-start.sh").read_text() + assert text.index("bs_selfheal_engaged") < text.index('reset --hard "$_last_good"') diff --git a/tests/updater/conftest.py b/tests/updater/conftest.py index c8aeed01..cb01a306 100644 --- a/tests/updater/conftest.py +++ b/tests/updater/conftest.py @@ -52,6 +52,8 @@ def svc(): mock_svc.has_fetch_failures = MagicMock(return_value=False) mock_svc.needs_provision = MagicMock(return_value=False) mock_svc.provision_missing = AsyncMock(return_value=False) + mock_svc.reconcile = AsyncMock(return_value=True) + mock_svc.reconcile_if_pending = AsyncMock() mock_svc._components = [ ComponentConfig(name="moonraker", kind="git"), ComponentConfig(name="klipper", kind="git"), @@ -70,6 +72,7 @@ def svc(): s._provisioning = False s._provisioned = False s._background_tasks = set() + s._closing = False s._status_check_in_progress = False s._status_pending = False s.busy_changed = MagicMock() diff --git a/tests/updater/test_dbus_service_unit.py b/tests/updater/test_dbus_service_unit.py index ce9574fe..3f0fd33e 100644 --- a/tests/updater/test_dbus_service_unit.py +++ b/tests/updater/test_dbus_service_unit.py @@ -1,5 +1,6 @@ import asyncio import json +from contextlib import nullcontext from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -602,3 +603,77 @@ async def test_recover_lock_held_emits_error(self, svc): await svc._run_recover("klipper", hard=False) svc.error.emit.assert_called_once_with(("klipper", "another update is running")) svc._svc.recover.assert_not_called() + + +class TestBootReconcileRetry: + @pytest.mark.asyncio + @pytest.mark.parametrize("acquired", [True, False]) + async def test_retry_spawned_only_when_lock_busy(self, svc, acquired): + """The start script holds the lock on UI start; a busy lock must defer, not drop.""" + svc._svc.reconcile = AsyncMock(return_value=acquired) + with patch.object( + svc, "_spawn", MagicMock(side_effect=lambda coro, **_: coro.close()) + ) as spawn: + await svc._boot_reconcile() + if acquired: + spawn.assert_not_called() + else: + spawn.assert_called_once() + assert spawn.call_args.kwargs["name"] == "boot_reconcile_retry" + + @pytest.mark.asyncio + async def test_retry_polls_until_heal_runs(self, svc): + from updater import dbus_service + + svc._svc.reconcile = AsyncMock(side_effect=[False, False, True]) + sleep = AsyncMock() + with patch.object(dbus_service.asyncio, "sleep", sleep): + await svc._retry_reconcile() + assert svc._svc.reconcile.await_count == 3 + assert sleep.await_count == 3 + sleep.assert_awaited_with(dbus_service._RECONCILE_RETRY_S) + + @pytest.mark.asyncio + async def test_lock_holder_heals_before_work(self, svc): + """A batch overwrites the in-flight marker, so a pending heal must run first.""" + order: list[str] = [] + svc._svc.reconcile_if_pending = AsyncMock( + side_effect=lambda: order.append("heal") + ) + work = AsyncMock(side_effect=lambda: order.append("work")) + with patch("updater.dbus_service.process_lock", lambda: nullcontext(True)): + assert await svc._run_with_lock(work, "update_all", "updater") is True + assert order == ["heal", "work"] + + +class TestShutdown: + @pytest.mark.asyncio + async def test_drains_status_respawned_during_shutdown(self, svc): + """The pending_status respawn from _emit_status's finally must not outlive the loop.""" + started = asyncio.Event() + + async def blocking_check(**_kw): + started.set() + await asyncio.Event().wait() + + svc._svc.check_status = AsyncMock(side_effect=blocking_check) + task = svc._spawn(svc._emit_status(), name="status") + await started.wait() + svc._status_pending = True + await svc.shutdown() + assert task.cancelled() + assert svc._svc.check_status.await_count == 1 + assert svc._background_tasks == set() + + @pytest.mark.asyncio + async def test_spawn_after_shutdown_never_runs(self, svc): + ran = [] + + async def late(): + ran.append(True) + + await svc.shutdown() + task = svc._spawn(late(), name="late") + await asyncio.gather(task, return_exceptions=True) + assert task.cancelled() + assert ran == [] diff --git a/tests/updater/test_executor_unit.py b/tests/updater/test_executor_unit.py index 9b5c0bcc..7448f954 100644 --- a/tests/updater/test_executor_unit.py +++ b/tests/updater/test_executor_unit.py @@ -1133,6 +1133,7 @@ async def test_run_hook_passes_timeout(self, tmp_path, monkeypatch): monkeypatch.setattr(ex, "_HOOKS_DIR", tmp_path) (tmp_path / "comp.sh").write_text("#!/bin/bash\nexit 0\n") + (tmp_path / "comp.sh").chmod(0o755) with patch.object(ex, "_run", new=AsyncMock(return_value=(True, ""))) as run: await run_hook("comp", tmp_path, "newh", "prevh", timeout=600.0) assert run.await_args.kwargs["timeout"] == 600.0 @@ -1144,6 +1145,7 @@ async def test_run_hook_default_timeout(self, tmp_path, monkeypatch): monkeypatch.setattr(ex, "_HOOKS_DIR", tmp_path) (tmp_path / "comp.sh").write_text("#!/bin/bash\nexit 0\n") + (tmp_path / "comp.sh").chmod(0o755) with patch.object(ex, "_run", new=AsyncMock(return_value=(True, ""))) as run: await run_hook("comp", tmp_path, "n", "p") assert run.await_args.kwargs["timeout"] == 60.0 @@ -1157,6 +1159,21 @@ async def test_hook_path_traversal_rejected(self, tmp_path, monkeypatch): assert ok is False assert "escapes" in msg + @pytest.mark.asyncio + async def test_non_executable_hook_fails_cleanly(self, tmp_path, monkeypatch): + """A hook without the exec bit is a hook failure, not an unexpected error.""" + import updater.executor as ex + + monkeypatch.setattr(ex, "_HOOKS_DIR", tmp_path) + (tmp_path / "comp.sh").write_text("#!/bin/bash\nexit 0\n") + (tmp_path / "comp.sh").chmod(0o644) + with patch.object(ex, "_run", new=AsyncMock()) as run: + assert await run_hook("comp", tmp_path, "n", "p") == ( + False, + "hook not executable", + ) + run.assert_not_awaited() + class TestEnableService: """enable_service: validate name, build the sudo systemctl enable argv.""" diff --git a/tests/updater/test_service_unit.py b/tests/updater/test_service_unit.py index e2efe53b..9f5d3a9e 100644 --- a/tests/updater/test_service_unit.py +++ b/tests/updater/test_service_unit.py @@ -2090,6 +2090,29 @@ async def test_defers_when_process_lock_held(self, tmp_path): assert deferred is True mock_prov.assert_not_called() + @pytest.mark.asyncio + async def test_runs_pending_boot_heal_before_cloning(self, tmp_path): + # Batches overwrite the in-flight marker, so the deferred heal must go first. + comp = self._comp(tmp_path) + order: list[str] = [] + with ( + patch("updater.service.process_lock", lambda: nullcontext(True)), + patch.object( + UpdateService, + "_reconcile_locked", + side_effect=lambda: order.append("heal"), + ), + patch.object( + UpdateService, + "_provision_component", + side_effect=lambda c: order.append("clone") or True, + ), + ): + svc = UpdateService() + svc._components = [comp] + await svc.provision_missing() + assert order == ["heal", "clone"] + class TestReclone: """_reclone_component: temp-clone + atomic swap as the deepest corruption rung.""" @@ -2318,10 +2341,44 @@ async def test_skips_when_process_lock_held(self, tmp_path): ): svc = UpdateService() svc._components = [comp] - await svc.reconcile() + assert await svc.reconcile() is False mock_hash.assert_not_called() mock_repair.assert_not_called() + @pytest.mark.asyncio + async def test_runs_once_per_process(self): + with ( + patch("updater.service.process_lock", lambda: nullcontext(True)), + patch.object(UpdateService, "_reconcile_locked") as mock_heal, + ): + svc = UpdateService() + assert await svc.reconcile() is True + assert await svc.reconcile() is True + await svc.reconcile_if_pending() + mock_heal.assert_awaited_once() + + @pytest.mark.asyncio + async def test_busy_lock_leaves_heal_pending(self): + # A skipped boot heal must still run for the next lock holder, not be dropped. + with patch.object(UpdateService, "_reconcile_locked") as mock_heal: + svc = UpdateService() + with patch("updater.service.process_lock", lambda: nullcontext(False)): + assert await svc.reconcile() is False + mock_heal.assert_not_called() + await svc.reconcile_if_pending() + mock_heal.assert_awaited_once() + + @pytest.mark.asyncio + async def test_crashing_heal_does_not_rerun(self): + with patch.object( + UpdateService, "_reconcile_locked", side_effect=RuntimeError("boom") + ) as mock_heal: + svc = UpdateService() + with pytest.raises(RuntimeError): + await svc.reconcile_if_pending() + await svc.reconcile_if_pending() + mock_heal.assert_awaited_once() + class TestWriteStateDurability: """_write_state fsyncs file + parent dir (Major 4).""" diff --git a/updater/__main__.py b/updater/__main__.py index 281ec437..57406da6 100644 --- a/updater/__main__.py +++ b/updater/__main__.py @@ -79,6 +79,7 @@ async def _run_daemon() -> None: except asyncio.TimeoutError: pass _log.info("updater daemon shutting down") + await service.shutdown() def _watchdog_ping_interval() -> float: @@ -141,6 +142,7 @@ async def main() -> None: match args.command: case "update": with _cli_lock(): + await svc.reconcile_if_pending() if args.name is None: ok = await svc.update_all() else: @@ -153,6 +155,7 @@ async def main() -> None: _print_component_status(s, args.verbose) case "recover": with _cli_lock(): + await svc.reconcile_if_pending() if not await svc.recover(args.name, hard=args.hard): raise SystemExit(1) case "bless": diff --git a/updater/dbus_service.py b/updater/dbus_service.py index 46988bce..6d7b2427 100644 --- a/updater/dbus_service.py +++ b/updater/dbus_service.py @@ -19,6 +19,7 @@ _log = logging.getLogger(__name__) _STATUS_PATH = Path("/run/blockscreen/updater_status.json") _FETCH_RETRY_INTERVAL_S = 300.0 +_RECONCILE_RETRY_S = 5.0 class DbusProgressCallback: @@ -111,10 +112,13 @@ def __init__(self) -> None: self._provisioning: bool = self._boot_busy self._provisioned: bool = False self._background_tasks: set[asyncio.Task] = set() + self._closing: bool = False self._status_check_in_progress: bool = False self._status_pending: bool = False self._invalid_requests: int = 0 - self._reconcile_task = self._spawn(self._svc.reconcile(), name="boot_reconcile") + self._reconcile_task = self._spawn( + self._boot_reconcile(), name="boot_reconcile" + ) self._spawn(self._svc.background_prime_nrestarts(), name="boot_prime_nrestarts") self._spawn(self._periodic_status_check(), name="periodic_status_check") self._spawn(self._svc.supervise_ui(), name="supervise_ui") @@ -125,8 +129,33 @@ def _spawn(self, coro, *, name: str | None = None) -> asyncio.Task: task = asyncio.get_running_loop().create_task(coro, name=name) self._background_tasks.add(task) task.add_done_callback(self._task_done) + if self._closing: + task.cancel() return task + async def shutdown(self) -> None: + """Cancel background tasks, including late spawns, before the loop closes.""" + self._closing = True + while pending := [t for t in self._background_tasks if not t.done()]: + for task in pending: + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + + async def _boot_reconcile(self) -> None: + """Run the boot heal; retry in the background while the lock is held.""" + if not await self._svc.reconcile(): + # The start script takes this lock on every UI start; a cold boot races it. + _log.info("reconcile: another updater holds the lock - deferring boot heal") + self._spawn(self._retry_reconcile(), name="boot_reconcile_retry") + + async def _retry_reconcile(self) -> None: + """Poll until the lock frees and the deferred boot heal has run.""" + while True: + await asyncio.sleep(_RECONCILE_RETRY_S) + if await self._svc.reconcile(): + _log.info("reconcile: deferred boot heal done") + return + def _task_done(self, task: asyncio.Task) -> None: """Drop the task ref and log its exception now, not at some later GC.""" self._background_tasks.discard(task) @@ -292,6 +321,7 @@ async def _run_with_lock( self.error.emit((target, "another update is running")) return False ran = True + await self._svc.reconcile_if_pending() await work() except Exception as exc: # noqa: BLE001 _log.error("_run_%s failed: %s", label, exc, exc_info=True) diff --git a/updater/executor.py b/updater/executor.py index 05e57296..8c71e96c 100644 --- a/updater/executor.py +++ b/updater/executor.py @@ -966,6 +966,8 @@ async def run_hook( return (False, "hook path escapes hooks directory") if not hook.exists(): return (True, "no hook") + if not os.access(hook, os.X_OK): + return (False, "hook not executable") env = _make_clean_env() env.update( { diff --git a/updater/hooks/BlocksScreen.sh b/updater/hooks/BlocksScreen.sh index d94a670e..7ad19634 100755 --- a/updater/hooks/BlocksScreen.sh +++ b/updater/hooks/BlocksScreen.sh @@ -73,8 +73,12 @@ if ! git -C "$COMPONENT_PATH" diff --quiet "$PREV_HASH" "$NEW_HASH" \ echo "[hook:BlocksScreen] daemon-reload done (xorg service)" fi +# Same set as post-merge: only install-updater.sh deploys these, so any change must re-run it. if ! git -C "$COMPONENT_PATH" diff --quiet "$PREV_HASH" "$NEW_HASH" \ - -- scripts/install-updater.sh scripts/bs-apt-helper.sh 2>/dev/null; then + -- scripts/install-updater.sh scripts/bs-apt-helper.sh \ + scripts/BlocksScreen-updater.service scripts/BlocksScreen-bootstrap.service \ + scripts/com.blockscreen.Updater.conf scripts/com.blockscreen.Updater.service \ + 2>/dev/null; then echo "[hook:BlocksScreen] install files changed - setting deploy flag" _set_deploy_flag fi diff --git a/updater/service.py b/updater/service.py index fbd40859..607061d8 100644 --- a/updater/service.py +++ b/updater/service.py @@ -255,6 +255,7 @@ def __init__(self, callback: ProgressCallback | None = None) -> None: self._log = logging.getLogger("updater") self._nrestarts_samples: dict[str, list[tuple[float, int]]] = {} self._restart_pending_until = 0.0 + self._reconciled = False @property def daemon_restart_pending(self) -> bool: @@ -498,6 +499,7 @@ async def provision_missing( if not acquired: self._log.info("provision_missing: update in progress, deferring") return True + await self.reconcile_if_pending() if on_busy: on_busy(True) try: @@ -1388,15 +1390,23 @@ def _clear_fault_marker(self) -> None: except OSError: self._log.warning("failed to clear self-heal fault marker") - async def reconcile(self) -> None: - """Heal repos left damaged by a power loss mid-update, for every component.""" + async def reconcile(self) -> bool: + """Heal repos a power cut left damaged mid-update; False if the lock is busy.""" + if self._reconciled: + return True with process_lock() as acquired: if not acquired: - self._log.info( - "reconcile: another updater holds the lock - skipping boot heal" - ) - return - await self._reconcile_locked() + return False + await self.reconcile_if_pending() + return True + + async def reconcile_if_pending(self) -> None: + """Run the boot heal once per process; caller must hold the process lock.""" + if self._reconciled: + return + # Set first: a crashing heal must not wedge every later update behind it. + self._reconciled = True + await self._reconcile_locked() async def _revert_inflight(self) -> None: """Revert power-cut batches; failed reverts stay in the marker for retry.""" @@ -1892,7 +1902,7 @@ async def _stage_fetch_gate( if elapsed >= 30: ok, error = await git_fetch(component.path) if not ok: - self._log.error(error) + self._log.error("%s: fetch failed: %s", component.name, error) # connectivity-only fsck can miss it; pass the fetch error as hint. if not await git_has_corruption(component.path, hint=error): return (False, "network") From 7ecbb2d68c8e76926b9e58f08014ac66b6d6df78 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Thu, 1 Oct 2026 18:25:30 +0100 Subject: [PATCH 16/21] fix(updater): skip unattended provisioning when offline or after a failed install --- tests/updater/conftest.py | 2 - tests/updater/test_dbus_service_unit.py | 55 +++++------------------ tests/updater/test_executor_unit.py | 23 ++++++++++ tests/updater/test_service_unit.py | 60 ++++++++++++++++++++++++- updater/dbus_service.py | 19 +++----- updater/executor.py | 8 ++++ updater/service.py | 34 ++++++++++++-- 7 files changed, 137 insertions(+), 64 deletions(-) diff --git a/tests/updater/conftest.py b/tests/updater/conftest.py index cb01a306..2a571011 100644 --- a/tests/updater/conftest.py +++ b/tests/updater/conftest.py @@ -50,7 +50,6 @@ def svc(): ) mock_svc.recover = AsyncMock() mock_svc.has_fetch_failures = MagicMock(return_value=False) - mock_svc.needs_provision = MagicMock(return_value=False) mock_svc.provision_missing = AsyncMock(return_value=False) mock_svc.reconcile = AsyncMock(return_value=True) mock_svc.reconcile_if_pending = AsyncMock() @@ -68,7 +67,6 @@ def svc(): s = UpdaterDbusService.__new__(UpdaterDbusService) s._svc = mock_svc s._busy = False - s._boot_busy = False s._provisioning = False s._provisioned = False s._background_tasks = set() diff --git a/tests/updater/test_dbus_service_unit.py b/tests/updater/test_dbus_service_unit.py index 3f0fd33e..16b0a8cc 100644 --- a/tests/updater/test_dbus_service_unit.py +++ b/tests/updater/test_dbus_service_unit.py @@ -351,60 +351,34 @@ async def test_periodic_check_never_lengthens_a_short_poll_interval(self, svc): assert sleeps == [3.0, 42.0] -class TestBootProvisionBusy: - def _build(self, missing): +class TestBootNotBusy: + def test_idle_at_construction(self): + """Offline boots must not open on the install overlay: busy waits for a real clone.""" from updater import dbus_service - mock_svc = MagicMock() - mock_svc.needs_provision.return_value = missing with ( - patch.object(dbus_service, "UpdateService", return_value=mock_svc), + patch.object(dbus_service, "UpdateService", return_value=MagicMock()), patch.object( dbus_service.UpdaterDbusService, "_spawn", MagicMock(side_effect=lambda coro, **_: coro.close()), ), ): - return dbus_service.UpdaterDbusService() - - @pytest.mark.parametrize("missing", [True, False]) - def test_busy_at_construction_iff_component_missing(self, missing): - """Busy must be set before export so the UI's get_busy on connect sees the provision.""" - assert self._build(missing)._busy is missing - - @pytest.mark.asyncio - @pytest.mark.usefixtures("reconciled") - async def test_boot_busy_skips_initial_sleep_and_releases(self, svc): - """Missing component: provision runs at once (no 3 s sleep), then busy drops.""" - from updater import dbus_service - - svc._boot_busy = svc._busy = True - sleeps: list[float] = [] - - async def fake_sleep(delay): - sleeps.append(delay) - raise asyncio.CancelledError - - with ( - patch.object(dbus_service.asyncio, "sleep", fake_sleep), - pytest.raises(asyncio.CancelledError), - ): - await svc._periodic_status_check() - - assert sleeps == [svc._svc.poll_interval] - assert svc._boot_busy is False - assert svc._busy is False + built = dbus_service.UpdaterDbusService() + assert (built._busy, built._provisioning) == (False, False) @pytest.mark.usefixtures("reconciled") class TestBootProvision: @pytest.mark.asyncio @pytest.mark.parametrize("raised", [False, True]) - async def test_provisions_only_after_boot_reconcile(self, svc, raised): + async def test_provisions_only_after_boot_reconcile(self, svc, raised, monkeypatch): """Reconcile holds the process lock: provisioning first would always defer.""" + from updater import dbus_service + + monkeypatch.setattr(dbus_service, "_BOOT_DELAY_S", 0.0) gate = asyncio.get_running_loop().create_future() svc._reconcile_task = gate - svc._boot_busy = True # skip the initial 3 s sleep svc._svc.poll_interval = 3600.0 task = asyncio.create_task(svc._periodic_status_check()) await asyncio.sleep(0.05) @@ -426,12 +400,11 @@ async def test_provisions_only_after_boot_reconcile(self, svc, raised): async def _run_polls(self, svc, polls: int) -> list[float]: from updater import dbus_service - svc._boot_busy = True # skip the initial 3 s sleep sleeps: list[float] = [] async def fake_sleep(delay): sleeps.append(delay) - if len(sleeps) >= polls: + if len(sleeps) > polls: raise asyncio.CancelledError with ( @@ -439,6 +412,7 @@ async def fake_sleep(delay): pytest.raises(asyncio.CancelledError), ): await svc._periodic_status_check() + assert sleeps.pop(0) == dbus_service._BOOT_DELAY_S return sleeps @pytest.mark.asyncio @@ -470,11 +444,6 @@ def test_provision_busy_emits_provisioning_then_busy(self, svc): svc.busy_changed.emit.assert_called_once_with((True,)) assert svc._provisioning is True - def test_boot_release_clears_both(self, svc): - svc._boot_busy = svc._busy = svc._provisioning = True - svc._release_boot_busy() - assert (svc._busy, svc._provisioning) == (False, False) - @pytest.mark.asyncio async def test_get_provisioning_reports_flag(self, svc): svc._provisioning = True diff --git a/tests/updater/test_executor_unit.py b/tests/updater/test_executor_unit.py index 7448f954..4f88fc9a 100644 --- a/tests/updater/test_executor_unit.py +++ b/tests/updater/test_executor_unit.py @@ -34,6 +34,7 @@ git_has_corruption, git_is_dirty, git_pull, + git_remote_reachable, git_remote_url, git_repair, git_reset_to_hash, @@ -214,6 +215,28 @@ async def test_builds_clone_argv_with_branch(self, tmp_path): assert argv[-2:] == ["https://github.com/x/y", str(dest)] +class TestGitRemoteReachable: + @pytest.mark.asyncio + async def test_rejects_non_https_without_running(self): + with patch("updater.executor._run", new_callable=AsyncMock) as mock_run: + assert await git_remote_reachable("git@github.com:x/y") is False + mock_run.assert_not_awaited() + + @pytest.mark.asyncio + @pytest.mark.parametrize("ok", [True, False]) + async def test_short_bounded_ls_remote(self, ok): + with patch( + "updater.executor._run", new_callable=AsyncMock, return_value=(ok, "") + ) as mock_run: + assert await git_remote_reachable("https://github.com/x/y") is ok + assert mock_run.call_args[0][0][1:] == [ + "ls-remote", + "https://github.com/x/y", + "HEAD", + ] + assert mock_run.call_args.kwargs["timeout"] <= 20.0 + + class TestGitGetHash: @pytest.mark.asyncio async def test_success(self, tmp_path): diff --git a/tests/updater/test_service_unit.py b/tests/updater/test_service_unit.py index 9f5d3a9e..15b9cd4b 100644 --- a/tests/updater/test_service_unit.py +++ b/tests/updater/test_service_unit.py @@ -14,9 +14,11 @@ @pytest.fixture(autouse=True) def _isolate_inflight(tmp_path_factory, monkeypatch): - """Keep the in-flight marker out of the real cache for every test in this file.""" + """Keep the in-flight marker, state and history out of the real cache.""" marker = tmp_path_factory.mktemp("inflight") / "updater_inflight.json" monkeypatch.setattr(updater_service, "_INFLIGHT_PATH", marker) + monkeypatch.setattr(updater_service, "_STATE_PATH", marker.with_name("state.json")) + monkeypatch.setattr(updater_service, "_HISTORY_PATH", marker.with_name("h.jsonl")) class TestLoggingCallback: @@ -2012,6 +2014,13 @@ async def test_provision_enable_failure_is_best_effort(self, tmp_path): class TestProvisionMissing: """provision_missing clones absent opted-in components outside a user Update.""" + @pytest.fixture(autouse=True) + def reachable(self): + with patch( + "updater.service.git_remote_reachable", AsyncMock(return_value=True) + ) as probe: + yield probe + def _comp(self, tmp_path: Path, *, name: str = "Spoolman") -> ComponentConfig: return ComponentConfig( name=name, @@ -2022,6 +2031,55 @@ def _comp(self, tmp_path: Path, *, name: str = "Spoolman") -> ComponentConfig: install_if_missing=True, ) + @pytest.mark.asyncio + async def test_unreachable_remote_skips_without_busy(self, tmp_path, reachable): + # Offline devices: no overlay, no clone, no deferral re-poll. + reachable.return_value = False + on_busy = MagicMock() + with ( + patch("updater.service.process_lock", lambda: nullcontext(True)), + patch.object(UpdateService, "_provision_component") as mock_prov, + ): + svc = UpdateService() + svc._components = [self._comp(tmp_path)] + assert await svc.provision_missing(on_busy) is False + mock_prov.assert_not_called() + on_busy.assert_not_called() + + @pytest.mark.asyncio + async def test_failed_install_never_retried_unattended(self, tmp_path, reachable): + # A broken install must not re-run under the overlay on every boot. + on_busy = MagicMock() + with ( + patch("updater.service.process_lock", lambda: nullcontext(True)), + patch.object(UpdateService, "_provision_component") as mock_prov, + ): + svc = UpdateService() + svc._components = [self._comp(tmp_path)] + await svc._set_provision_failed("Spoolman", True) + assert await svc.provision_missing(on_busy) is False + mock_prov.assert_not_called() + on_busy.assert_not_called() + reachable.assert_not_awaited() + + @pytest.mark.asyncio + async def test_fail_sets_flag_and_success_clears_it(self, tmp_path): + comp = self._comp(tmp_path) + with patch("updater.service.git_clone", return_value=(False, "offline")): + svc = UpdateService() + assert await svc._provision_component(comp) is False + assert svc._read_state()["Spoolman"]["provision_failed"] is True + with ( + patch("updater.service.git_clone", return_value=(True, "")), + patch("updater.service.git_get_hash", return_value="a" * 40), + patch.object( + UpdateService, "_install_dependencies", return_value=(True, "") + ), + patch("updater.service.run_hook", return_value=(True, "")), + ): + assert await svc._provision_component(comp) is True + assert "provision_failed" not in svc._read_state()["Spoolman"] + @pytest.mark.asyncio async def test_provisions_absent_opted_in_component(self, tmp_path): comp = self._comp(tmp_path) # path does not exist diff --git a/updater/dbus_service.py b/updater/dbus_service.py index 6d7b2427..658b2c04 100644 --- a/updater/dbus_service.py +++ b/updater/dbus_service.py @@ -20,6 +20,7 @@ _STATUS_PATH = Path("/run/blockscreen/updater_status.json") _FETCH_RETRY_INTERVAL_S = 300.0 _RECONCILE_RETRY_S = 5.0 +_BOOT_DELAY_S = 3.0 class DbusProgressCallback: @@ -104,12 +105,11 @@ def provisioning_changed(self) -> tuple[bool]: raise NotImplementedError def __init__(self) -> None: - """Set busy before export so the UI's first get_busy sees a boot install.""" + """Start idle: busy rises only once a reachable install actually begins.""" super().__init__() self._svc = UpdateService(callback=DbusProgressCallback(self)) - self._boot_busy: bool = self._svc.needs_provision() - self._busy: bool = self._boot_busy - self._provisioning: bool = self._boot_busy + self._busy: bool = False + self._provisioning: bool = False self._provisioned: bool = False self._background_tasks: set[asyncio.Task] = set() self._closing: bool = False @@ -169,12 +169,6 @@ def _provision_busy(self, busy: bool) -> None: self._set_provisioning(busy) self._set_busy(busy) - def _release_boot_busy(self) -> None: - """Drop the state pre-set at boot.""" - if self._boot_busy: - self._boot_busy = False - self._provision_busy(False) - def _set_provisioning(self, provisioning: bool) -> None: if provisioning != self._provisioning: self._provisioning = provisioning @@ -242,8 +236,7 @@ async def _emit_status(self, force: bool = False) -> None: async def _periodic_status_check(self) -> None: """Provision once; emit status per poll, sooner on fetch failure or deferral.""" - if not self._boot_busy: - await asyncio.sleep(3.0) + await asyncio.sleep(_BOOT_DELAY_S) while True: try: if not self._provisioned: @@ -251,11 +244,9 @@ 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) - self._release_boot_busy() await self._emit_status() except Exception as exc: # noqa: BLE001 _log.error("periodic_check failed: %s", exc) - self._release_boot_busy() interval = self._svc.poll_interval if self._svc.has_fetch_failures(): interval = min(_FETCH_RETRY_INTERVAL_S, interval) diff --git a/updater/executor.py b/updater/executor.py index 8c71e96c..f60b3f31 100644 --- a/updater/executor.py +++ b/updater/executor.py @@ -452,6 +452,14 @@ async def git_clone( return await _run(cmd, timeout=300.0) +async def git_remote_reachable(url: str, timeout: float = 15.0) -> bool: + """True if the https remote answers ls-remote in time (cheap offline probe).""" + if not _GIT_URL_RE.match(url): + return False + ok, _ = await _run([GIT, "ls-remote", url, "HEAD"], timeout=timeout) + return ok + + async def git_reset_to_hash(path: Path | None, prev_hash: str = "") -> tuple[bool, str]: """Hard-reset repo at path directly to prev_hash (no fetch).""" if not path: diff --git a/updater/service.py b/updater/service.py index 607061d8..a3ef1167 100644 --- a/updater/service.py +++ b/updater/service.py @@ -43,6 +43,7 @@ git_has_corruption, git_pull, git_ref_hash, + git_remote_reachable, git_repair, git_reset_to_hash, git_tree_has_path, @@ -484,15 +485,38 @@ def _missing_provisions(self) -> list[ComponentConfig]: and (c.path is None or not c.path.exists()) ] - def needs_provision(self) -> bool: - """True if provision_missing() would clone something (cheap filesystem check).""" - return bool(self._missing_provisions()) + async def _unattended_provisions(self) -> list[ComponentConfig]: + """Missing components to install unprompted: no failed try, remote reachable.""" + state = await asyncio.to_thread(self._read_state) + todo: list[ComponentConfig] = [] + for c in self._missing_provisions(): + comp = state.get(c.name) + if isinstance(comp, dict) and comp.get("provision_failed"): + self._log.info("%s: last install failed - waiting for Update", c.name) + elif not await git_remote_reachable(c.url or ""): + # Offline devices must never sit behind the install overlay. + self._log.info("%s: remote unreachable - not installing", c.name) + else: + todo.append(c) + return todo + + async def _set_provision_failed(self, name: str, failed: bool) -> None: + """Persist the install outcome; a failure stops unattended retries.""" + + def mutate(state: dict) -> None: + comp = _ensure_comp(state, name) + if failed: + comp["provision_failed"] = True + else: + comp.pop("provision_failed", None) + + await self._mutate_state(mutate) async def provision_missing( self, on_busy: Callable[[bool], None] | None = None ) -> bool: """Clone absent install_if_missing components; True if deferred by a held lock.""" - missing = self._missing_provisions() + missing = await self._unattended_provisions() if not missing: return False with process_lock() as acquired: @@ -1709,6 +1733,7 @@ async def _fail_provision( ) -> bool: """Undo the partial install, log, and report failure.""" await self._undo_provision(component, hooked) + await self._set_provision_failed(component.name, True) self._history("install_failed", component.name, reason=reason) self._log.warning( "%s: provision failed (%s), partial clone removed", component.name, reason @@ -1788,6 +1813,7 @@ async def _provision_component(self, component: ComponentConfig) -> bool: if reason: return await self._fail_provision(component, reason, hooked) + await self._set_provision_failed(component.name, False) self._history("install_success", component.name, new_hash=new_hash[:12]) self._cb("on_component_done", component.name, True) return True From 9b801df8ba244010258f6fa8314f069a4dcf1e2e Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Fri, 2 Oct 2026 10:02:48 +0100 Subject: [PATCH 17/21] fix(updater): defer unattended updates while printing, bound offline provisioning retries --- BlocksScreen/lib/panels/mainWindow.py | 3 + .../panels/widgets/MainWindow/updatePage.py | 15 +++- BlocksScreen/lib/updater_worker.py | 21 +++++ tests/lib/test_updater_worker_unit.py | 29 +++++++ tests/updater/test_dbus_service_unit.py | 40 +++++++++ tests/updater/test_self_heal_unit.py | 13 +++ tests/updater/test_service_unit.py | 81 ++++++++++++++++++- tests/widgets/test_update_page_unit.py | 20 ++++- updater/dbus_service.py | 27 ++++++- updater/service.py | 56 ++++++++++--- 10 files changed, 283 insertions(+), 22 deletions(-) diff --git a/BlocksScreen/lib/panels/mainWindow.py b/BlocksScreen/lib/panels/mainWindow.py index c27a1965..91994cfe 100644 --- a/BlocksScreen/lib/panels/mainWindow.py +++ b/BlocksScreen/lib/panels/mainWindow.py @@ -300,6 +300,9 @@ def __init__(self): self.update_page.request_update.connect(self.updater_worker.trigger_update) self.update_page.request_status.connect(self.updater_worker.trigger_status) self.update_page.request_cancel.connect(self.updater_worker.trigger_cancel) + self.update_page.printing_changed.connect( + self.updater_worker.trigger_set_printing + ) self.update_page.update_available.connect(self.on_update_available) self.update_page.call_load_panel.connect(self.show_loadscreen) self.update_page.disable_popups.connect(self.popup_toggle) diff --git a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py index 2b2cca3f..5a8b0240 100644 --- a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py +++ b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py @@ -17,6 +17,7 @@ _log = logging.getLogger(__name__) _DESCRIBE_SUFFIX = re.compile(r"-(\d+)-g[0-9a-f]+$") +_SAFE_STATES = frozenset({"standby", "complete", "cancelled", "error", ""}) def _compact_version(describe: str) -> str: @@ -43,6 +44,9 @@ class UpdatePage(QtWidgets.QWidget): disable_popups: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal( bool, name="disable-popups" ) + printing_changed: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal( + bool, name="printing-changed" + ) _STEP_LABELS: typing.ClassVar[MappingProxyType[int, str]] = MappingProxyType( { @@ -103,7 +107,7 @@ def __init__(self) -> None: self._restart_grace_timer.timeout.connect(self._dismiss_after_restart_grace) self._stale_overlay_timer: QtCore.QTimer = QtCore.QTimer(self) self._stale_overlay_timer.setSingleShot(True) - self._stale_overlay_timer.setInterval(10000) + self._stale_overlay_timer.setInterval(60000) self._stale_overlay_timer.timeout.connect(self._dismiss_stale_overlay) self._update_confirm_popup: BasePopup | None = None self.show_loading(True) @@ -112,9 +116,10 @@ def _request_status_debounced(self) -> None: self._status_debounce.start(500) def set_printing_state(self, key: str, value: str) -> None: - """Cache the printer state so update safety checks can block mid-print updates.""" + """Cache the printer state so updates, ours and the daemon's, wait out a job.""" if key == "state": self._printing_state = value + self.printing_changed.emit(value not in _SAFE_STATES) def set_heater_target(self, name: str, prop: str, value: float) -> None: """Track heater targets; update is blocked if any heater is above 40 °C.""" @@ -136,6 +141,9 @@ def _on_busy_timeout(self) -> None: self._overlay_shown = False self.show_loading(False) self.call_load_panel.emit(False, "", False) + self._show_toast( + "Still working in the background - tap refresh to check status" + ) def showEvent(self, a0: QtGui.QShowEvent | None) -> None: """Rebuild cards and request a fresh status poll each time the page becomes visible.""" @@ -428,7 +436,6 @@ def _dismiss_after_restart_grace(self) -> None: @QtCore.pyqtSlot(name="on-update-all-clicked") def on_update_all_clicked(self) -> None: """Guard against updates during a print or with hot heaters; otherwise show confirm dialog.""" - _SAFE_STATES = {"standby", "complete", "cancelled", "error", ""} if self._printing_state not in _SAFE_STATES: self._show_toast(f"Printer {self._printing_state} - update deferred") return @@ -538,7 +545,7 @@ def handle_daemon_unavailable(self) -> None: self._cancel_btn.hide() self.show_loading(False) self._show_toast( - "Updater unavailable, restarting it automatically ...", + "Updater unavailable, retrying ...", success=False, ) self.update_all_btn.setEnabled(False) diff --git a/BlocksScreen/lib/updater_worker.py b/BlocksScreen/lib/updater_worker.py index 244e92b9..cd6c606f 100644 --- a/BlocksScreen/lib/updater_worker.py +++ b/BlocksScreen/lib/updater_worker.py @@ -76,6 +76,8 @@ def __init__(self) -> None: self._last_busy: bool = False self._last_provisioning: bool = False self._provisioning_signals: int = 0 + # None = unknown, so a fresh UI never clears the daemon's flag + self._printing: bool | None = None self._owner_task: asyncio.Task | None = None self._escalated: bool = False self._init_lock = asyncio.Lock() @@ -206,6 +208,7 @@ async def _connect(self) -> None: self._reconnect_attempt = 0 self._escalated = False self._daemon_owner = await self._name_owner() + await self._call_set_printing() if busy: self._busy_false_event.clear() @@ -445,6 +448,14 @@ def trigger_bless(self, name: str = "BlocksScreen") -> None: return self._submit(self._call_bless(name)) + def trigger_set_printing(self, printing: bool) -> None: + """Forward job state so the daemon defers unattended work; resent on reconnect.""" + if printing == self._printing: + return + self._printing = printing + if self._proxy is not None: + self._submit(self._call_set_printing()) + def shutdown(self) -> None: """Cancel all tasks and stop the event loop; close() runs in _run_loop after stop.""" self._shutting_down = True @@ -531,6 +542,16 @@ async def _call_bless(self, name: str) -> None: except sdbus.SdBusBaseError as exc: self._handle_proxy_error(exc, "bless_healthy") + async def _call_set_printing(self) -> None: + """Send the latest job state; daemons predating set_printing just log it.""" + if self._printing is None: + return + try: + async with asyncio.timeout(5): + await self._proxy.set_printing(self._printing) + except (sdbus.SdBusBaseError, TimeoutError) as exc: + _log.debug("set_printing failed: %s", exc) + async def _listen_status_ready(self) -> None: """Forward status_ready D-Bus signals to the Qt status_ready signal.""" async for json_str in self._proxy.status_ready: diff --git a/tests/lib/test_updater_worker_unit.py b/tests/lib/test_updater_worker_unit.py index ef4a7ece..b6fa6fb9 100644 --- a/tests/lib/test_updater_worker_unit.py +++ b/tests/lib/test_updater_worker_unit.py @@ -33,6 +33,7 @@ def _make_worker(): w._last_busy = False w._last_provisioning = False w._provisioning_signals = 0 + w._printing = None w._daemon_owner = "" w._owner_task = None w._escalated = False @@ -580,3 +581,31 @@ async def slow_poll(): worker._proxy.get_provisioning = slow_poll await worker._poll_provisioning(True) assert worker._last_provisioning is False + + +class TestSetPrinting: + def test_only_changes_are_sent(self, worker): + with patch( + "asyncio.run_coroutine_threadsafe", side_effect=lambda c, loop: c.close() + ) as mock_rctf: + worker.trigger_set_printing(True) + worker.trigger_set_printing(True) + mock_rctf.assert_called_once() + assert worker._printing is True + + @pytest.mark.asyncio + async def test_unknown_state_is_not_sent(self, worker): + worker._proxy.set_printing = AsyncMock() + await worker._call_set_printing() + worker._proxy.set_printing.assert_not_called() + + @pytest.mark.asyncio + async def test_old_daemon_without_method_is_ignored(self, worker): + import sdbus + + worker._printing = True + worker._proxy.set_printing = AsyncMock( + side_effect=sdbus.SdBusBaseError("unknown method") + ) + await worker._call_set_printing() + assert not worker._reconnecting diff --git a/tests/updater/test_dbus_service_unit.py b/tests/updater/test_dbus_service_unit.py index 16b0a8cc..e92798f6 100644 --- a/tests/updater/test_dbus_service_unit.py +++ b/tests/updater/test_dbus_service_unit.py @@ -457,6 +457,12 @@ async def test_cancel_ignored_while_provisioning(self, svc): class TestMethodReturnValues: + @pytest.mark.asyncio + async def test_set_printing_reaches_service(self, svc): + svc._svc.printing = False + await svc.set_printing(True) + assert svc._svc.printing is True + @pytest.mark.asyncio async def test_update_all_rejected_when_busy_returns_false(self, svc): """Return False when busy without calling underlying service.""" @@ -573,6 +579,16 @@ async def test_recover_lock_held_emits_error(self, svc): svc.error.emit.assert_called_once_with(("klipper", "another update is running")) svc._svc.recover.assert_not_called() + @pytest.mark.asyncio + async def test_lock_miss_keeps_busy_owned_by_install(self, svc): + """A user task losing the lock to an install must not drop its overlay.""" + svc._provision_busy(True) + with patch("updater.dbus_service.process_lock", self._held_lock()): + await svc._run_update_all() + assert svc._busy is True + svc._provision_busy(False) + assert svc._busy is False + class TestBootReconcileRetry: @pytest.mark.asyncio @@ -646,3 +662,27 @@ async def late(): await asyncio.gather(task, return_exceptions=True) assert task.cancelled() assert ran == [] + + @pytest.mark.asyncio + async def test_shielded_rollback_cannot_outlast_stop_timeout( + self, svc, monkeypatch + ): + """systemd SIGKILLs at 90s; shutdown must return first, even mid-rollback.""" + from updater import dbus_service + + monkeypatch.setattr(dbus_service, "_SHUTDOWN_DRAIN_S", 0.05) + release = asyncio.Event() + + async def stubborn(): + while not release.is_set(): + try: + await release.wait() + except asyncio.CancelledError: + continue + + task = svc._spawn(stubborn(), name="update_all") + await asyncio.sleep(0) + await asyncio.wait_for(svc.shutdown(), timeout=1.0) + assert not task.done() + release.set() + await task diff --git a/tests/updater/test_self_heal_unit.py b/tests/updater/test_self_heal_unit.py index 5e6bad26..64bf1927 100644 --- a/tests/updater/test_self_heal_unit.py +++ b/tests/updater/test_self_heal_unit.py @@ -564,6 +564,19 @@ async def run_test(): class TestForwardHeal: + def test_forward_heal_waits_while_printing(self, tmp_path): + async def run_test(): + svc = UpdateService() + svc._state_path = tmp_path / "state.json" + svc._write_state({"BlocksScreen": {"prev_hash": "old", "fast_attempt": 3}}) + svc.printing = True + with patch("updater.service.git_fetch") as m_fetch: + ok = await svc._forward_heal_once() + assert not ok + m_fetch.assert_not_called() + + asyncio.run(run_test()) + def test_forward_heal_skips_when_not_in_fallback(self, tmp_path): async def run_test(): svc = UpdateService() diff --git a/tests/updater/test_service_unit.py b/tests/updater/test_service_unit.py index 15b9cd4b..e3390b86 100644 --- a/tests/updater/test_service_unit.py +++ b/tests/updater/test_service_unit.py @@ -146,6 +146,32 @@ async def test_force_bypasses_ttl(self, tmp_path: Path): *_, skip_fetch = mock_check.call_args.args assert skip_fetch is False, "force=True must not skip the fetch" + @pytest.mark.asyncio + async def test_printing_skips_fetch_even_when_forced(self, tmp_path: Path): + fake_path = tmp_path / "klipper" + fake_path.mkdir() + component = ComponentConfig(name="klipper", kind="git", path=fake_path) + fake = ComponentStatus(name="klipper", commits_behind=0) + with ( + patch( + "updater.service.load_components", return_value=([component], 3600.0) + ), + patch("updater.service.check_git_status", return_value=fake) as mock_check, + ): + svc = UpdateService() + svc.printing = True + await svc.check_status(force=True) + *_, skip_fetch = mock_check.call_args.args + assert skip_fetch is True + + @pytest.mark.asyncio + async def test_printing_skips_apt_list_refresh(self): + with patch("updater.service.apt_update", AsyncMock()) as mock_update: + svc = UpdateService() + svc.printing = True + await svc._refresh_apt_lists(force=True) + mock_update.assert_not_called() + @pytest.mark.asyncio async def test_ttl_suppresses_fetch_without_force(self, tmp_path: Path): """Without force, a second call within the TTL window must skip the fetch.""" @@ -1878,6 +1904,22 @@ async def test_cancel_during_hook_disables_service(self, tmp_path): mock_stop.assert_called_once_with("newcomp.service") mock_rmtree.assert_called_once() + @pytest.mark.asyncio + async def test_failed_disable_is_logged(self, tmp_path): + """A missing sudoers rule must not leave an enabled unit on a deleted clone silently.""" + comp = self._comp(tmp_path, service="newcomp.service") + with ( + patch( + "updater.service.disable_service", return_value=(False, "sudo denied") + ), + patch("updater.service.shutil.rmtree") as mock_rmtree, + ): + svc = UpdateService(callback=MagicMock()) + svc._log = MagicMock() + await svc._undo_provision(comp, hooked=True) + mock_rmtree.assert_called_once() + svc._log.error.assert_called_once() + @pytest.mark.asyncio async def test_provision_succeeds_when_health_ready(self, tmp_path): comp = self._comp( @@ -2031,21 +2073,48 @@ def _comp(self, tmp_path: Path, *, name: str = "Spoolman") -> ComponentConfig: install_if_missing=True, ) + @pytest.mark.asyncio + async def test_printing_defers_without_probing(self, tmp_path, reachable): + on_busy = MagicMock() + with patch.object(UpdateService, "_provision_component") as mock_prov: + svc = UpdateService() + svc._components = [self._comp(tmp_path)] + svc.printing = True + assert await svc.provision_missing(on_busy) is True + reachable.assert_not_called() + mock_prov.assert_not_called() + on_busy.assert_not_called() + @pytest.mark.asyncio async def test_unreachable_remote_skips_without_busy(self, tmp_path, reachable): - # Offline devices: no overlay, no clone, no deferral re-poll. + # Offline devices: no overlay, no clone; re-poll for late Wi-Fi, then stop. reachable.return_value = False on_busy = MagicMock() + tries = updater_service._OFFLINE_PROVISION_TRIES with ( patch("updater.service.process_lock", lambda: nullcontext(True)), patch.object(UpdateService, "_provision_component") as mock_prov, ): svc = UpdateService() svc._components = [self._comp(tmp_path)] - assert await svc.provision_missing(on_busy) is False + retries = [await svc.provision_missing(on_busy) for _ in range(tries)] + assert retries == [True] * (tries - 1) + [False] mock_prov.assert_not_called() on_busy.assert_not_called() + @pytest.mark.asyncio + async def test_late_wifi_installs_on_retry(self, tmp_path, reachable): + reachable.side_effect = [False, True] + with ( + patch("updater.service.process_lock", lambda: nullcontext(True)), + patch.object(UpdateService, "_provision_component") as mock_prov, + ): + svc = UpdateService() + svc._components = [self._comp(tmp_path)] + assert await svc.provision_missing() is True + assert await svc.provision_missing() is False + mock_prov.assert_called_once() + @pytest.mark.asyncio async def test_failed_install_never_retried_unattended(self, tmp_path, reachable): # A broken install must not re-run under the overlay on every boot. @@ -3094,6 +3163,14 @@ async def test_honors_configured_apt_excludes(self): await svc.background_apt_upgrade() mock_up.assert_awaited_once_with(exclude=("^linux-image", "^firmware-")) + @pytest.mark.asyncio + async def test_printing_skips_upgrade(self): + svc = UpdateService() + svc.printing = True + with patch("updater.service.apt_update", AsyncMock()) as mock_update: + await svc.background_apt_upgrade() + mock_update.assert_not_called() + class TestReviewHardeningFixes: """Regressions for the 2026-07 pre-main review fixes.""" diff --git a/tests/widgets/test_update_page_unit.py b/tests/widgets/test_update_page_unit.py index 25b9cfcd..d191bae8 100644 --- a/tests/widgets/test_update_page_unit.py +++ b/tests/widgets/test_update_page_unit.py @@ -286,6 +286,16 @@ def test_does_not_emit_call_load_panel_on_normal_refresh(self, page, qtbot): page.handle_status_ready(_make_payload()) +class TestPrintingChanged: + def test_job_state_reaches_the_daemon(self, page, qtbot): + with qtbot.waitSignal(page.printing_changed, timeout=200) as blocker: + page.set_printing_state("state", "paused") + assert blocker.args == [True] + with qtbot.waitSignal(page.printing_changed, timeout=200) as blocker: + page.set_printing_state("state", "complete") + assert blocker.args == [False] + + class TestHandleBusyChanged: def test_true_shows_loading(self, page): page.show_loading = MagicMock() @@ -369,6 +379,14 @@ def test_false_stops_busy_timeout_timer(self, page): page.handle_busy_changed(False) assert not page._busy_timeout_timer.isActive() + def test_busy_timeout_explains_dropped_overlay(self, page): + page.show_loading = MagicMock() + page._show_toast = MagicMock() + page.handle_busy_changed(True) + page._on_busy_timeout() + assert page._busy is False + page._show_toast.assert_called_once() + class TestUpdateAllClicked: def test_emits_request_update_with_empty_string(self, page, qtbot): @@ -447,7 +465,7 @@ def test_daemon_unavailable_shows_toast(self, page, qtbot): page._show_toast.assert_called_once() args = page._show_toast.call_args[0] assert "unavailable" in args[0].lower() - assert "restart" in args[0].lower() + assert "retrying" in args[0].lower() def test_daemon_unavailable_disables_update_btn(self, page): page.show_loading = MagicMock() diff --git a/updater/dbus_service.py b/updater/dbus_service.py index 658b2c04..7b28e906 100644 --- a/updater/dbus_service.py +++ b/updater/dbus_service.py @@ -21,6 +21,7 @@ _FETCH_RETRY_INTERVAL_S = 300.0 _RECONCILE_RETRY_S = 5.0 _BOOT_DELAY_S = 3.0 +_SHUTDOWN_DRAIN_S = 60.0 # < systemd's 90s stop timeout class DbusProgressCallback: @@ -136,10 +137,21 @@ def _spawn(self, coro, *, name: str | None = None) -> asyncio.Task: async def shutdown(self) -> None: """Cancel background tasks, including late spawns, before the loop closes.""" self._closing = True + loop = asyncio.get_running_loop() + deadline = loop.time() + _SHUTDOWN_DRAIN_S while pending := [t for t in self._background_tasks if not t.done()]: + if (remaining := deadline - loop.time()) <= 0: + _log.warning( + "shutdown: %d task(s) still running after %.0fs; " + "boot heal reverts any in-flight update", + len(pending), + _SHUTDOWN_DRAIN_S, + ) + return for task in pending: task.cancel() - await asyncio.gather(*pending, return_exceptions=True) + # gather would wait out shielded rollbacks + await asyncio.wait(pending, timeout=remaining) async def _boot_reconcile(self) -> None: """Run the boot heal; retry in the background while the lock is held.""" @@ -289,6 +301,13 @@ async def recover(self, name: str, hard: bool) -> bool: self._spawn(self._run_recover(name, hard), name=f"recover_{name}") return True + @sdbus.dbus_method_async(input_signature="b") + async def set_printing(self, printing: bool) -> None: + """D-Bus method: the UI reports an active job; unattended work waits for it.""" + if printing != self._svc.printing: + _log.info("printing -> %s", printing) + self._svc.printing = printing + @sdbus.dbus_method_async(input_signature="ss", result_signature="b") async def bless_healthy(self, name: str, hash_val: str) -> bool: """D-Bus method: bless a component as healthy (known-good).""" @@ -303,7 +322,7 @@ async def _run_with_lock( label: str, target: str, ) -> bool: - """Run work() under the cross-process lock, always clearing busy; True if the lock was held.""" + """Run work() under the cross-process lock, then clear busy; True if the lock was held.""" ran = False try: with process_lock() as acquired: @@ -317,7 +336,9 @@ async def _run_with_lock( except Exception as exc: # noqa: BLE001 _log.error("_run_%s failed: %s", label, exc, exc_info=True) finally: - self._set_busy(busy=False) + # a running install owns busy + if ran or not self._provisioning: + self._set_busy(busy=False) return ran async def _run_update_all(self) -> None: diff --git a/updater/service.py b/updater/service.py index a3ef1167..0798e1b0 100644 --- a/updater/service.py +++ b/updater/service.py @@ -172,6 +172,7 @@ def reset(self) -> None: _RECOVERY_SETTLE_S = 90.0 _FORWARD_HEAL_BASE_S = 1800.0 _FORWARD_HEAL_JITTER_S = 300.0 +_OFFLINE_PROVISION_TRIES = 6 # ~30 min of 300s polls, for late Wi-Fi # Watched by BlocksScreen-deploy.path: install-updater.sh runs in its own cgroup. _DEPLOY_FLAG = Path.home() / ".config" / "blockscreen" / ".run-install-updater" @@ -257,6 +258,8 @@ def __init__(self, callback: ProgressCallback | None = None) -> None: self._nrestarts_samples: dict[str, list[tuple[float, int]]] = {} self._restart_pending_until = 0.0 self._reconciled = False + self._offline_provision_tries = 0 + self.printing = False @property def daemon_restart_pending(self) -> bool: @@ -285,7 +288,8 @@ async def _refresh_apt_lists(self, force: bool) -> None: ttl = _APT_LIST_FORCE_TTL_S if force else _APT_LIST_TTL_S # A held lock = an update is running, and it refreshes the lists itself. if ( - (now - self._apt_list_time) < ttl + self.printing + or (now - self._apt_list_time) < ttl or self._apt_backoff.cooling_down() or self._apt_lock.locked() ): @@ -321,9 +325,12 @@ async def _check_one(c: ComponentConfig) -> None: async with self._git_lock: last = self._fetch_times.get(c.name, float("-inf")) breaker = self._fetch_backoff.get(c.name) - skip_fetch = not force and ( - (now - last) < self._FETCH_TTL - or (breaker is not None and breaker.cooling_down()) + skip_fetch = self.printing or ( + not force + and ( + (now - last) < self._FETCH_TTL + or (breaker is not None and breaker.cooling_down()) + ) ) status = await check_git_status( c.name, c.path, c.branch, c.version, skip_fetch @@ -485,10 +492,11 @@ def _missing_provisions(self) -> list[ComponentConfig]: and (c.path is None or not c.path.exists()) ] - async def _unattended_provisions(self) -> list[ComponentConfig]: - """Missing components to install unprompted: no failed try, remote reachable.""" + async def _unattended_provisions(self) -> tuple[list[ComponentConfig], bool]: + """Missing components to install unprompted, and whether an offline one should retry.""" state = await asyncio.to_thread(self._read_state) todo: list[ComponentConfig] = [] + offline = False for c in self._missing_provisions(): comp = state.get(c.name) if isinstance(comp, dict) and comp.get("provision_failed"): @@ -496,9 +504,18 @@ async def _unattended_provisions(self) -> list[ComponentConfig]: elif not await git_remote_reachable(c.url or ""): # Offline devices must never sit behind the install overlay. self._log.info("%s: remote unreachable - not installing", c.name) + offline = True else: todo.append(c) - return todo + if offline: + self._offline_provision_tries += 1 + if self._offline_provision_tries >= _OFFLINE_PROVISION_TRIES: + self._log.info( + "remote still unreachable after %d tries - waiting for Update", + self._offline_provision_tries, + ) + offline = False + return todo, offline async def _set_provision_failed(self, name: str, failed: bool) -> None: """Persist the install outcome; a failure stops unattended retries.""" @@ -515,10 +532,13 @@ def mutate(state: dict) -> None: async def provision_missing( self, on_busy: Callable[[bool], None] | None = None ) -> bool: - """Clone absent install_if_missing components; True if deferred by a held lock.""" - missing = await self._unattended_provisions() + """Clone absent install_if_missing components; True to retry (lock, offline, printing).""" + if self.printing: + self._log.info("provision_missing: printer is printing, deferring") + return True + missing, retry = await self._unattended_provisions() if not missing: - return False + return retry with process_lock() as acquired: if not acquired: self._log.info("provision_missing: update in progress, deferring") @@ -533,7 +553,7 @@ async def provision_missing( finally: if on_busy: on_busy(False) - return False + return retry async def _preflight_fetch( self, sorted_components: list[ComponentConfig] @@ -1362,6 +1382,8 @@ async def _forward_heal_target( async def _forward_heal_once(self) -> bool: """One forward-heal pass: attempt the new origin/main tip if we are in fallback.""" + if self.printing: + return False state = await asyncio.to_thread(self._read_state) comp_state = state.get(_UI_COMPONENT, {}) if not isinstance(comp_state, dict): @@ -1725,7 +1747,14 @@ async def _remove_clone(self, component: ComponentConfig) -> None: async def _undo_provision(self, component: ComponentConfig, hooked: bool) -> None: """Drop the clone, first disabling a hook-enabled unit (else it crash-loops).""" if hooked and component.service: - await disable_service(component.service) + ok, err = await disable_service(component.service) + if not ok: + self._log.error( + "%s: could not disable %s (%s); unit stays enabled", + component.name, + component.service, + err, + ) await self._remove_clone(component) async def _fail_provision( @@ -2409,6 +2438,9 @@ async def _background_apt_upgrade_locked(self) -> None: if self._apt_backoff.cooling_down(): self._log.debug("apt cooling down; skipping background upgrade") return + if self.printing: + self._log.info("background apt upgrade skipped: printer is printing") + return self._log.info("background apt upgrade: starting") ok, err = await apt_update() if not ok: From 64a7b789c25c164f4149ec529c3db4b2f1c3e995 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Fri, 2 Oct 2026 11:21:31 +0100 Subject: [PATCH 18/21] fix(updater): clear printing when the UI leaves the bus, subscribe before seeding the owner watch, resume start-script rollback after self-heal saturation --- BlocksScreen/lib/updater_worker.py | 25 +++-- scripts/bs-common.sh | 3 +- scripts/install-updater.sh | 1 + tests/lib/test_updater_worker_unit.py | 137 +++++++++++++----------- tests/scripts/test_selfheal_engaged.py | 5 + tests/updater/conftest.py | 4 + tests/updater/test_dbus_service_unit.py | 69 ++++++++++++ tests/updater/test_service_unit.py | 8 ++ updater/dbus_service.py | 46 ++++++++ updater/executor.py | 2 +- updater/service.py | 2 +- 11 files changed, 226 insertions(+), 76 deletions(-) diff --git a/BlocksScreen/lib/updater_worker.py b/BlocksScreen/lib/updater_worker.py index cd6c606f..b878016d 100644 --- a/BlocksScreen/lib/updater_worker.py +++ b/BlocksScreen/lib/updater_worker.py @@ -6,7 +6,7 @@ import logging import threading import time -from contextlib import aclosing, suppress +from contextlib import closing, suppress from typing import TYPE_CHECKING, Any if TYPE_CHECKING: @@ -35,8 +35,9 @@ def _dbus_daemon(bus: Any) -> Any: _DAEMON_BUS_NAME = "com.blockscreen.Updater" _UPDATER_UNIT = "BlocksScreen-updater.service" +_BUS_DRIVER = "org.freedesktop.DBus" -# Reconnect attempts before asking systemd to start a unit it has given up on. +# 2 = ~20 s absent (5 s + 15 s retries) before asking systemd to start the unit. _ESCALATE_AFTER = 2 @@ -314,21 +315,30 @@ async def _watch_daemon_owner(self) -> None: resync = False # the first seed races updater_init's own connect while not self._shutting_down: try: - signals = _dbus_daemon(self._system_bus).name_owner_changed - async with aclosing(aiter(signals)) as stream: - # Seeded after subscribing so no change can slip through the gap. + changes: asyncio.Queue = asyncio.Queue() + # Raw match: sdbus signal iterators only subscribe on first __anext__. + slot = await self._system_bus.match_signal_async( + _BUS_DRIVER, + "/org/freedesktop/DBus", + _BUS_DRIVER, + "NameOwnerChanged", + changes.put_nowait, + ) + with closing(slot): owner = await self._name_owner() if resync and owner and owner != self._daemon_owner: await self._async_initialize(owner) else: self._daemon_owner = owner resync = True - async for name, _old, new_owner in stream: + while not self._shutting_down: + msg = await changes.get() + name, _old, new_owner = msg.get_contents() if name != _DAEMON_BUS_NAME or new_owner == self._daemon_owner: continue if not new_owner: self._daemon_owner = "" - # systemd never restarts a clean stop; the retry revives it. + # Retry bus-activates a stopped unit; only mask keeps it off. _log.error("updater daemon left the bus - awaiting restart") self.daemon_unavailable.emit() self._schedule_reconnect() @@ -342,7 +352,6 @@ async def _watch_daemon_owner(self) -> None: raise except Exception: # noqa: BLE001 _log.error("daemon owner watch failed - retrying in 10s", exc_info=True) - # Also covers a stream that ends without raising, which would else hot-spin. if not self._shutting_down: await asyncio.sleep(10.0) diff --git a/scripts/bs-common.sh b/scripts/bs-common.sh index e97a51a5..c6fe335a 100644 --- a/scripts/bs-common.sh +++ b/scripts/bs-common.sh @@ -168,10 +168,11 @@ bs_ensure_usb_max_current() { return 0 } -# True while a live updater daemon runs its UI recovery ladder (fast_attempt > 0). $1 = state file, $2 = python. +# True while a live updater daemon runs its UI recovery ladder (fast_attempt > 0, not saturated). $1 = state file, $2 = python. bs_selfheal_engaged() { local state="$1" py="$2" [ -f "$state" ] && [ -x "$py" ] || return 1 + [ -f "${state%/*}/selfheal_fault.json" ] && return 1 systemctl is-active --quiet BlocksScreen-updater.service 2>/dev/null || return 1 "$py" - "$state" 2>/dev/null <<'PY' import json, sys diff --git a/scripts/install-updater.sh b/scripts/install-updater.sh index a1582da6..255fcbd6 100755 --- a/scripts/install-updater.sh +++ b/scripts/install-updater.sh @@ -134,6 +134,7 @@ elif [[ "$(readlink -f "$_BS_SVC_DEST" 2>/dev/null)" != "$(readlink -f "$_BS_SVC sudo systemctl unmask BlocksScreen.service 2>/dev/null || true fi sudo systemctl daemon-reload +# Every run on purpose: a linked-but-disabled UI unit is a blank screen with no SSH recovery. sudo systemctl enable BlocksScreen.service 2>/dev/null || echo_warn "could not enable BlocksScreen.service" echo_ok "BlocksScreen.service is a symlink - hook no longer needs sudo cp" diff --git a/tests/lib/test_updater_worker_unit.py b/tests/lib/test_updater_worker_unit.py index b6fa6fb9..19b85597 100644 --- a/tests/lib/test_updater_worker_unit.py +++ b/tests/lib/test_updater_worker_unit.py @@ -42,30 +42,6 @@ def _make_worker(): return w -class _FakeDbus: - """Stand-in for FreedesktopDbus yielding a scripted NameOwnerChanged stream.""" - - def __init__(self, owner="", events=(), stop=None): - self._owner = owner - self._events = list(events) - self._stop = stop - - async def get_name_owner(self, service_name): - return self._owner - - @property - def name_owner_changed(self): - events, stop = self._events, self._stop - - async def _gen(): - for event in events: - yield event - if stop is not None: - stop() - - return _gen() - - def _dbus_module(fake): """Inject a fake sdbus_async.dbus_daemon (tests/network/conftest stubs the parent).""" mod = SimpleNamespace(FreedesktopDbus=lambda bus=None: fake) @@ -233,20 +209,49 @@ async def test_watchdog_emits_after_idle_limit(self, worker, qtbot): assert received == [True] +class _Msg: + """NameOwnerChanged message; a callable payload runs on read (stop or fail).""" + + def __init__(self, contents): + self._contents = contents + + def get_contents(self): + return self._contents() if callable(self._contents) else self._contents + + class TestDaemonOwnerWatch: """Crash recovery: NameOwnerChanged resync instead of the 6-minute busy watchdog.""" @staticmethod - def _patch_dbus(worker, owner="", events=()): - def _stop(): + def _subscribe(worker, *batches, owner=""): + """Each subscribe delivers the next batch before the seed; returns the slots.""" + slots = [] + pending = iter(batches) + + async def _match(*args): + for event in next(pending): + args[4](_Msg(event)) + slots.append(MagicMock()) + return slots[-1] + + worker._system_bus.match_signal_async = _match + return slots, _dbus_module( + MagicMock(get_name_owner=AsyncMock(return_value=owner)) + ) + + @staticmethod + def _stop(worker): + def _read(): worker._shutting_down = True + return ("org.other.Thing", "", "") - return _dbus_module(_FakeDbus(owner=owner, events=events, stop=_stop)) + return _read @pytest.mark.asyncio async def test_new_owner_triggers_resync(self, worker): worker._async_initialize = AsyncMock() - with self._patch_dbus(worker, "", [(_BUS, "", ":1.5")]): + _, dbus = self._subscribe(worker, [(_BUS, "", ":1.5"), self._stop(worker)]) + with dbus: await worker._watch_daemon_owner() # Owner passed through, not stored here: _async_initialize owns that field. worker._async_initialize.assert_awaited_once_with(":1.5") @@ -255,12 +260,15 @@ async def test_new_owner_triggers_resync(self, worker): async def test_owner_lost_emits_unavailable_and_schedules_reconnect( self, worker, qtbot ): - """systemd never restarts a clean stop, so the worker must retry on its own.""" + """A stopped unit is bus-activated again by the retry, so the worker retries.""" received = [] worker.daemon_unavailable.connect(lambda: received.append(True)) worker._async_initialize = AsyncMock() worker._schedule_reconnect = MagicMock() - with self._patch_dbus(worker, ":1.5", [(_BUS, ":1.5", "")]): + _, dbus = self._subscribe( + worker, [(_BUS, ":1.5", ""), self._stop(worker)], owner=":1.5" + ) + with dbus: await worker._watch_daemon_owner() assert received == [True] worker._async_initialize.assert_not_awaited() @@ -270,53 +278,50 @@ async def test_owner_lost_emits_unavailable_and_schedules_reconnect( async def test_other_names_and_repeat_owner_ignored(self, worker): worker._async_initialize = AsyncMock() events = [("org.other.Thing", "", ":1.9"), (_BUS, ":1.5", ":1.5")] - with self._patch_dbus(worker, ":1.5", events): + _, dbus = self._subscribe(worker, [*events, self._stop(worker)], owner=":1.5") + with dbus: await worker._watch_daemon_owner() worker._async_initialize.assert_not_awaited() @pytest.mark.asyncio - async def test_watch_survives_stream_failure(self, worker): - """Losing the watch must retry, not kill the fast recovery path.""" - worker._async_initialize = AsyncMock() + async def test_subscribes_before_seeding(self, worker): + """Seeding first would miss a restart landing between seed and subscribe.""" + slots, _ = self._subscribe(worker, [self._stop(worker)]) + seen = [] - class _Broken(_FakeDbus): - @property - def name_owner_changed(self): - raise RuntimeError("bus dropped") + async def _owner(_name): + seen.append(len(slots)) + return "" + + with _dbus_module(MagicMock(get_name_owner=_owner)): + await worker._watch_daemon_owner() + assert seen == [1] - fake = _Broken() + @pytest.mark.asyncio + async def test_watch_survives_subscribe_failure(self, worker): + """Losing the watch must retry, not kill the fast recovery path.""" + worker._system_bus.match_signal_async = AsyncMock( + side_effect=RuntimeError("bus dropped") + ) async def _sleep(_delay): worker._shutting_down = True - with _dbus_module(fake), patch("asyncio.sleep", _sleep): + with patch("asyncio.sleep", _sleep): await worker._watch_daemon_owner() # must return, not raise @pytest.mark.asyncio - async def test_stream_closed_when_body_raises(self, worker): - """Abandoning the generator without aclose leaks its match slot until GC.""" - closed = [] - - async def _gen(): - try: - yield (_BUS, "", ":1.9") - yield (_BUS, "", ":1.10") - finally: - closed.append(True) - - class _Leaky(_FakeDbus): - @property - def name_owner_changed(self): - return _gen() - + async def test_slot_closed_when_body_raises(self, worker): + """An unclosed match slot keeps queueing signals until GC.""" worker._async_initialize = AsyncMock(side_effect=RuntimeError("boom")) + slots, dbus = self._subscribe(worker, [(_BUS, "", ":1.9"), (_BUS, "", ":1.10")]) async def _sleep(_delay): worker._shutting_down = True - with _dbus_module(_Leaky()), patch("asyncio.sleep", _sleep): + with dbus, patch("asyncio.sleep", _sleep): await worker._watch_daemon_owner() - assert closed == [True] + slots[0].close.assert_called_once_with() worker._async_initialize.assert_awaited_once_with(":1.9") @pytest.mark.asyncio @@ -324,15 +329,17 @@ async def _sleep(_delay): async def test_reseed_after_gap_resyncs_new_owner(self, worker, reseed, resyncs): """A restart while the watch was down emits no signal: the re-seed must catch it.""" worker._async_initialize = AsyncMock() - fake = _FakeDbus() - fake.get_name_owner = AsyncMock(side_effect=[":1.5", reseed]) - sleeps = [] - async def _sleep(delay): - sleeps.append(delay) - worker._shutting_down = len(sleeps) >= 2 + def _drop(): + raise RuntimeError("bus dropped") + + _, dbus = self._subscribe(worker, [_drop], [self._stop(worker)]) + fake = MagicMock(get_name_owner=AsyncMock(side_effect=[":1.5", reseed])) + + async def _sleep(_delay): + pass - with _dbus_module(fake), patch("asyncio.sleep", _sleep): + with dbus, _dbus_module(fake), patch("asyncio.sleep", _sleep): await worker._watch_daemon_owner() if resyncs: worker._async_initialize.assert_awaited_once_with(reseed) diff --git a/tests/scripts/test_selfheal_engaged.py b/tests/scripts/test_selfheal_engaged.py index f820d5fa..2ed944c3 100644 --- a/tests/scripts/test_selfheal_engaged.py +++ b/tests/scripts/test_selfheal_engaged.py @@ -64,6 +64,11 @@ def test_dead_daemon_hands_rollback_back(tmp_path: Path) -> None: assert not _engaged(tmp_path, state, daemon_active=False) +def test_saturated_ladder_hands_rollback_back(tmp_path: Path) -> None: + (tmp_path / "selfheal_fault.json").write_text("{}") + assert not _engaged(tmp_path, {"BlocksScreen": {"fast_attempt": 3}}) + + def test_start_script_consults_helper_before_rolling_back() -> None: text = (_SCRIPTS / "BlocksScreen-start.sh").read_text() assert text.index("bs_selfheal_engaged") < text.index('reset --hard "$_last_good"') diff --git a/tests/updater/conftest.py b/tests/updater/conftest.py index 2a571011..ae2922bc 100644 --- a/tests/updater/conftest.py +++ b/tests/updater/conftest.py @@ -27,10 +27,13 @@ def mock_sdbus(): mock.DbusInterfaceCommonAsync = _FakeDbusBase mock.dbus_signal_async = lambda *a, **kw: lambda fn: fn mock.dbus_method_async = lambda *a, **kw: lambda fn: fn + mock.get_current_message.side_effect = LookupError with pytest.MonkeyPatch.context() as mp: for key in ("sdbus", "updater", "updater.dbus_service"): mp.delitem(sys.modules, key, raising=False) mp.setitem(sys.modules, "sdbus", mock) + mp.setitem(sys.modules, "sdbus.sd_bus_internals", mock.sd_bus_internals) + mp.setitem(sys.modules, "sdbus_async.dbus_daemon", MagicMock()) yield mock @@ -73,6 +76,7 @@ def svc(): s._closing = False s._status_check_in_progress = False s._status_pending = False + s._printing_watch = None s.busy_changed = MagicMock() s.provisioning_changed = MagicMock() s.status_ready = MagicMock() diff --git a/tests/updater/test_dbus_service_unit.py b/tests/updater/test_dbus_service_unit.py index e92798f6..02b582f4 100644 --- a/tests/updater/test_dbus_service_unit.py +++ b/tests/updater/test_dbus_service_unit.py @@ -462,6 +462,7 @@ async def test_set_printing_reaches_service(self, svc): svc._svc.printing = False await svc.set_printing(True) assert svc._svc.printing is True + assert svc._printing_watch is None @pytest.mark.asyncio async def test_update_all_rejected_when_busy_returns_false(self, svc): @@ -551,6 +552,74 @@ async def test_background_apt_skipped_when_daemon_restart_pending( assert svc._svc.background_apt_upgrade.called is apt_spawned +class TestPrintingWatch: + """The printing flag lives only as long as the caller's bus name.""" + + @pytest.fixture + def bus(self, svc): + bus = MagicMock() + bus.match_signal_async = AsyncMock(return_value=MagicMock()) + svc._dbus = MagicMock(attached_bus=bus) + svc._svc.printing = False + return bus + + @pytest.fixture + def has_owner(self): + with ( + patch("updater.dbus_service._caller", return_value=":1.5"), + patch("updater.dbus_service.FreedesktopDbus") as fd, + ): + fd.return_value.name_has_owner = AsyncMock(return_value=True) + yield fd.return_value.name_has_owner + + @staticmethod + async def _owner_changed(bus, name: str, new: str = "") -> None: + msg = MagicMock() + msg.get_contents.return_value = (name, ":1.5", new) + bus.match_signal_async.call_args.args[4](msg) + for _ in range(3): + await asyncio.sleep(0) + + @pytest.mark.asyncio + async def test_cleared_when_sender_leaves(self, svc, bus, has_owner): + await svc.set_printing(True) + await asyncio.sleep(0) + assert svc._svc.printing is True + await self._owner_changed(bus, ":1.5") + assert svc._svc.printing is False + assert svc._printing_watch is None + bus.match_signal_async.return_value.close.assert_called_once() + + @pytest.mark.asyncio + async def test_other_names_ignored(self, svc, bus, has_owner): + await svc.set_printing(True) + await asyncio.sleep(0) + await self._owner_changed(bus, ":1.9") + await self._owner_changed(bus, ":1.5", new=":1.6") + assert svc._svc.printing is True + assert not svc._printing_watch.done() + + @pytest.mark.asyncio + async def test_sender_gone_before_subscribe_clears(self, svc, bus, has_owner): + has_owner.return_value = False + await svc.set_printing(True) + for _ in range(3): + await asyncio.sleep(0) + assert svc._svc.printing is False + bus.match_signal_async.return_value.close.assert_called_once() + + @pytest.mark.asyncio + async def test_report_replaces_previous_watch(self, svc, bus, has_owner): + await svc.set_printing(True) + await asyncio.sleep(0) + first = svc._printing_watch + await svc.set_printing(False) + await asyncio.sleep(0) + assert first.cancelled() + assert svc._printing_watch is None + assert svc._svc.printing is False + + class TestLockHeldSurfacesError: def _held_lock(self): lock = MagicMock() diff --git a/tests/updater/test_service_unit.py b/tests/updater/test_service_unit.py index e3390b86..44723700 100644 --- a/tests/updater/test_service_unit.py +++ b/tests/updater/test_service_unit.py @@ -2085,6 +2085,14 @@ async def test_printing_defers_without_probing(self, tmp_path, reachable): mock_prov.assert_not_called() on_busy.assert_not_called() + @pytest.mark.asyncio + async def test_printing_with_nothing_missing_stops_polling(self, tmp_path): + (tmp_path / "Spoolman").mkdir() + svc = UpdateService() + svc._components = [self._comp(tmp_path)] + svc.printing = True + assert await svc.provision_missing() is False + @pytest.mark.asyncio async def test_unreachable_remote_skips_without_busy(self, tmp_path, reachable): # Offline devices: no overlay, no clone; re-poll for late Wi-Fi, then stop. diff --git a/updater/dbus_service.py b/updater/dbus_service.py index 7b28e906..222ba759 100644 --- a/updater/dbus_service.py +++ b/updater/dbus_service.py @@ -7,10 +7,13 @@ import json import logging from collections.abc import Awaitable, Callable +from contextlib import closing from functools import partial from pathlib import Path import sdbus +from sdbus.sd_bus_internals import SdBusMessage +from sdbus_async.dbus_daemon import FreedesktopDbus from updater.locking import process_lock from updater.models import ComponentStatus @@ -22,6 +25,15 @@ _RECONCILE_RETRY_S = 5.0 _BOOT_DELAY_S = 3.0 _SHUTDOWN_DRAIN_S = 60.0 # < systemd's 90s stop timeout +_BUS_DRIVER = "org.freedesktop.DBus" + + +def _caller() -> str: + """Unique bus name of the current D-Bus caller; empty outside a method call.""" + try: + return sdbus.get_current_message().sender or "" + except LookupError: + return "" class DbusProgressCallback: @@ -117,6 +129,7 @@ def __init__(self) -> None: self._status_check_in_progress: bool = False self._status_pending: bool = False self._invalid_requests: int = 0 + self._printing_watch: asyncio.Task | None = None self._reconcile_task = self._spawn( self._boot_reconcile(), name="boot_reconcile" ) @@ -304,10 +317,43 @@ async def recover(self, name: str, hard: bool) -> bool: @sdbus.dbus_method_async(input_signature="b") async def set_printing(self, printing: bool) -> None: """D-Bus method: the UI reports an active job; unattended work waits for it.""" + if self._printing_watch is not None: + self._printing_watch.cancel() + self._printing_watch = None + sender = _caller() + if printing and sender: + self._printing_watch = self._spawn( + self._release_printing_on_exit(sender), name="printing_watch" + ) if printing != self._svc.printing: _log.info("printing -> %s", printing) self._svc.printing = printing + async def _release_printing_on_exit(self, sender: str) -> None: + """Clear printing once its sender leaves the bus, like a logind inhibitor.""" + bus = self._dbus.attached_bus + left = asyncio.Event() + + def _on_owner_changed(msg: SdBusMessage) -> None: + name, _old, new = msg.get_contents() + if name == sender and not new: + left.set() + + slot = await bus.match_signal_async( + _BUS_DRIVER, + "/org/freedesktop/DBus", + _BUS_DRIVER, + "NameOwnerChanged", + _on_owner_changed, + ) + with closing(slot): + # Checked after subscribing so an exit in between is not missed. + if await FreedesktopDbus(bus).name_has_owner(sender): + await left.wait() + _log.warning("printing client %s left the bus - clearing printing", sender) + self._printing_watch = None + self._svc.printing = False + @sdbus.dbus_method_async(input_signature="ss", result_signature="b") async def bless_healthy(self, name: str, hash_val: str) -> bool: """D-Bus method: bless a component as healthy (known-good).""" diff --git a/updater/executor.py b/updater/executor.py index f60b3f31..7ea2d5c3 100644 --- a/updater/executor.py +++ b/updater/executor.py @@ -1085,7 +1085,7 @@ async def verify_updater_importable(component_path: Path | None) -> bool: async def disable_service(name: str | None) -> tuple[bool, str]: - """Stop and disable a systemd service.""" + """Stop and disable a systemd service (sudoers allows only Spoolman.service).""" if name is None: return (False, "service name is None") if not _SERVICE_RE.match(name): diff --git a/updater/service.py b/updater/service.py index 0798e1b0..de1c76ba 100644 --- a/updater/service.py +++ b/updater/service.py @@ -533,7 +533,7 @@ async def provision_missing( self, on_busy: Callable[[bool], None] | None = None ) -> bool: """Clone absent install_if_missing components; True to retry (lock, offline, printing).""" - if self.printing: + if self.printing and self._missing_provisions(): self._log.info("provision_missing: printer is printing, deferring") return True missing, retry = await self._unattended_provisions() From 0521fc22ac70e2c7a1a70be4b0c8350300c37caa Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Fri, 2 Oct 2026 12:35:08 +0100 Subject: [PATCH 19/21] fix(updater): retry set_printing so a lost call cannot leave the daemon on the wrong side of a job --- BlocksScreen/lib/updater_worker.py | 24 ++++++++++++------- tests/lib/conftest.py | 5 ++++ tests/lib/test_updater_worker_unit.py | 34 +++++++++++++++++++++++++-- 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/BlocksScreen/lib/updater_worker.py b/BlocksScreen/lib/updater_worker.py index b878016d..80afe615 100644 --- a/BlocksScreen/lib/updater_worker.py +++ b/BlocksScreen/lib/updater_worker.py @@ -209,7 +209,8 @@ async def _connect(self) -> None: self._reconnect_attempt = 0 self._escalated = False self._daemon_owner = await self._name_owner() - await self._call_set_printing() + # A task so its retries never hold _init_lock. + self._track_task(asyncio.create_task(self._call_set_printing())) if busy: self._busy_false_event.clear() @@ -552,14 +553,19 @@ async def _call_bless(self, name: str) -> None: self._handle_proxy_error(exc, "bless_healthy") async def _call_set_printing(self) -> None: - """Send the latest job state; daemons predating set_printing just log it.""" - if self._printing is None: - return - try: - async with asyncio.timeout(5): - await self._proxy.set_printing(self._printing) - except (sdbus.SdBusBaseError, TimeoutError) as exc: - _log.debug("set_printing failed: %s", exc) + """Send the current job state, retried so a lost call cannot leave it stale.""" + for _ in range(3): + if self._printing is None: + return + try: + async with asyncio.timeout(5): + await self._proxy.set_printing(self._printing) + return + except sdbus.dbus_exceptions.DbusUnknownMethodError: + return # daemon predates set_printing + except (sdbus.SdBusBaseError, TimeoutError) as exc: + _log.warning("set_printing failed: %s", exc) + await asyncio.sleep(5) async def _listen_status_ready(self) -> None: """Forward status_ready D-Bus signals to the Qt status_ready signal.""" diff --git a/tests/lib/conftest.py b/tests/lib/conftest.py index 09b2722a..a03baf26 100644 --- a/tests/lib/conftest.py +++ b/tests/lib/conftest.py @@ -29,11 +29,16 @@ class _SdBusBaseError(Exception): pass +class _DbusUnknownMethodError(_SdBusBaseError): + pass + + @pytest.fixture(scope="module", autouse=True) def mock_sdbus(): mock = MagicMock() mock.sd_bus_open_user = MagicMock(return_value=MagicMock()) mock.SdBusBaseError = _SdBusBaseError + mock.dbus_exceptions.DbusUnknownMethodError = _DbusUnknownMethodError with pytest.MonkeyPatch.context() as mp: for key in ( "sdbus", diff --git a/tests/lib/test_updater_worker_unit.py b/tests/lib/test_updater_worker_unit.py index 19b85597..7660bf97 100644 --- a/tests/lib/test_updater_worker_unit.py +++ b/tests/lib/test_updater_worker_unit.py @@ -607,12 +607,42 @@ async def test_unknown_state_is_not_sent(self, worker): worker._proxy.set_printing.assert_not_called() @pytest.mark.asyncio - async def test_old_daemon_without_method_is_ignored(self, worker): + async def test_old_daemon_without_method_is_not_retried(self, worker): import sdbus worker._printing = True worker._proxy.set_printing = AsyncMock( - side_effect=sdbus.SdBusBaseError("unknown method") + side_effect=sdbus.dbus_exceptions.DbusUnknownMethodError("unknown method") ) await worker._call_set_printing() + worker._proxy.set_printing.assert_awaited_once() + assert not worker._reconnecting + + @pytest.mark.asyncio + async def test_failed_send_is_retried_with_the_current_state(self, worker): + import sdbus + + sent = [] + + async def send(printing): + sent.append(printing) + if len(sent) == 1: + worker._printing = False + raise sdbus.SdBusBaseError("timeout") + + worker._printing = True + worker._proxy.set_printing = send + with patch("asyncio.sleep", new=AsyncMock()): + await worker._call_set_printing() + assert sent == [True, False] + + @pytest.mark.asyncio + async def test_gives_up_after_three_tries(self, worker): + import sdbus + + worker._printing = True + worker._proxy.set_printing = AsyncMock(side_effect=sdbus.SdBusBaseError("down")) + with patch("asyncio.sleep", new=AsyncMock()): + await worker._call_set_printing() + assert worker._proxy.set_printing.await_count == 3 assert not worker._reconnecting From d72b2e453228c0a59362cf3192ca233dd9d55bea Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Fri, 2 Oct 2026 14:59:51 +0100 Subject: [PATCH 20/21] fix(updater): gate unattended work on Klipper print_stats via klippy.sock, drop UI printing push, fail Spoolman hook before patching moonraker.conf --- BlocksScreen/lib/panels/mainWindow.py | 3 - .../panels/widgets/MainWindow/updatePage.py | 8 +- BlocksScreen/lib/updater_worker.py | 27 ------- tests/lib/conftest.py | 5 -- tests/lib/test_updater_worker_unit.py | 59 --------------- tests/updater/conftest.py | 13 ++-- tests/updater/test_cli_no_sdbus.py | 15 ++-- tests/updater/test_dbus_service_unit.py | 75 ------------------- tests/updater/test_executor_unit.py | 67 +++++++++++++++++ tests/updater/test_self_heal_unit.py | 4 +- tests/updater/test_service_unit.py | 27 ++++--- tests/widgets/test_update_page_unit.py | 26 +++---- updater/dbus_service.py | 53 ------------- updater/executor.py | 33 ++++++++ updater/hooks/Spoolman.sh | 17 +++-- updater/service.py | 19 ++--- 16 files changed, 170 insertions(+), 281 deletions(-) diff --git a/BlocksScreen/lib/panels/mainWindow.py b/BlocksScreen/lib/panels/mainWindow.py index 91994cfe..c27a1965 100644 --- a/BlocksScreen/lib/panels/mainWindow.py +++ b/BlocksScreen/lib/panels/mainWindow.py @@ -300,9 +300,6 @@ def __init__(self): self.update_page.request_update.connect(self.updater_worker.trigger_update) self.update_page.request_status.connect(self.updater_worker.trigger_status) self.update_page.request_cancel.connect(self.updater_worker.trigger_cancel) - self.update_page.printing_changed.connect( - self.updater_worker.trigger_set_printing - ) self.update_page.update_available.connect(self.on_update_available) self.update_page.call_load_panel.connect(self.show_loadscreen) self.update_page.disable_popups.connect(self.popup_toggle) diff --git a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py index 5a8b0240..ea8b8c4c 100644 --- a/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py +++ b/BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py @@ -17,7 +17,6 @@ _log = logging.getLogger(__name__) _DESCRIBE_SUFFIX = re.compile(r"-(\d+)-g[0-9a-f]+$") -_SAFE_STATES = frozenset({"standby", "complete", "cancelled", "error", ""}) def _compact_version(describe: str) -> str: @@ -44,9 +43,6 @@ class UpdatePage(QtWidgets.QWidget): disable_popups: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal( bool, name="disable-popups" ) - printing_changed: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal( - bool, name="printing-changed" - ) _STEP_LABELS: typing.ClassVar[MappingProxyType[int, str]] = MappingProxyType( { @@ -116,10 +112,9 @@ def _request_status_debounced(self) -> None: self._status_debounce.start(500) def set_printing_state(self, key: str, value: str) -> None: - """Cache the printer state so updates, ours and the daemon's, wait out a job.""" + """Cache the printer state so update safety checks can block mid-print updates.""" if key == "state": self._printing_state = value - self.printing_changed.emit(value not in _SAFE_STATES) def set_heater_target(self, name: str, prop: str, value: float) -> None: """Track heater targets; update is blocked if any heater is above 40 °C.""" @@ -436,6 +431,7 @@ def _dismiss_after_restart_grace(self) -> None: @QtCore.pyqtSlot(name="on-update-all-clicked") def on_update_all_clicked(self) -> None: """Guard against updates during a print or with hot heaters; otherwise show confirm dialog.""" + _SAFE_STATES = {"standby", "complete", "cancelled", "error", ""} if self._printing_state not in _SAFE_STATES: self._show_toast(f"Printer {self._printing_state} - update deferred") return diff --git a/BlocksScreen/lib/updater_worker.py b/BlocksScreen/lib/updater_worker.py index 80afe615..9091963e 100644 --- a/BlocksScreen/lib/updater_worker.py +++ b/BlocksScreen/lib/updater_worker.py @@ -77,8 +77,6 @@ def __init__(self) -> None: self._last_busy: bool = False self._last_provisioning: bool = False self._provisioning_signals: int = 0 - # None = unknown, so a fresh UI never clears the daemon's flag - self._printing: bool | None = None self._owner_task: asyncio.Task | None = None self._escalated: bool = False self._init_lock = asyncio.Lock() @@ -209,8 +207,6 @@ async def _connect(self) -> None: self._reconnect_attempt = 0 self._escalated = False self._daemon_owner = await self._name_owner() - # A task so its retries never hold _init_lock. - self._track_task(asyncio.create_task(self._call_set_printing())) if busy: self._busy_false_event.clear() @@ -458,14 +454,6 @@ def trigger_bless(self, name: str = "BlocksScreen") -> None: return self._submit(self._call_bless(name)) - def trigger_set_printing(self, printing: bool) -> None: - """Forward job state so the daemon defers unattended work; resent on reconnect.""" - if printing == self._printing: - return - self._printing = printing - if self._proxy is not None: - self._submit(self._call_set_printing()) - def shutdown(self) -> None: """Cancel all tasks and stop the event loop; close() runs in _run_loop after stop.""" self._shutting_down = True @@ -552,21 +540,6 @@ async def _call_bless(self, name: str) -> None: except sdbus.SdBusBaseError as exc: self._handle_proxy_error(exc, "bless_healthy") - async def _call_set_printing(self) -> None: - """Send the current job state, retried so a lost call cannot leave it stale.""" - for _ in range(3): - if self._printing is None: - return - try: - async with asyncio.timeout(5): - await self._proxy.set_printing(self._printing) - return - except sdbus.dbus_exceptions.DbusUnknownMethodError: - return # daemon predates set_printing - except (sdbus.SdBusBaseError, TimeoutError) as exc: - _log.warning("set_printing failed: %s", exc) - await asyncio.sleep(5) - async def _listen_status_ready(self) -> None: """Forward status_ready D-Bus signals to the Qt status_ready signal.""" async for json_str in self._proxy.status_ready: diff --git a/tests/lib/conftest.py b/tests/lib/conftest.py index a03baf26..09b2722a 100644 --- a/tests/lib/conftest.py +++ b/tests/lib/conftest.py @@ -29,16 +29,11 @@ class _SdBusBaseError(Exception): pass -class _DbusUnknownMethodError(_SdBusBaseError): - pass - - @pytest.fixture(scope="module", autouse=True) def mock_sdbus(): mock = MagicMock() mock.sd_bus_open_user = MagicMock(return_value=MagicMock()) mock.SdBusBaseError = _SdBusBaseError - mock.dbus_exceptions.DbusUnknownMethodError = _DbusUnknownMethodError with pytest.MonkeyPatch.context() as mp: for key in ( "sdbus", diff --git a/tests/lib/test_updater_worker_unit.py b/tests/lib/test_updater_worker_unit.py index 7660bf97..e68b8e69 100644 --- a/tests/lib/test_updater_worker_unit.py +++ b/tests/lib/test_updater_worker_unit.py @@ -33,7 +33,6 @@ def _make_worker(): w._last_busy = False w._last_provisioning = False w._provisioning_signals = 0 - w._printing = None w._daemon_owner = "" w._owner_task = None w._escalated = False @@ -588,61 +587,3 @@ async def slow_poll(): worker._proxy.get_provisioning = slow_poll await worker._poll_provisioning(True) assert worker._last_provisioning is False - - -class TestSetPrinting: - def test_only_changes_are_sent(self, worker): - with patch( - "asyncio.run_coroutine_threadsafe", side_effect=lambda c, loop: c.close() - ) as mock_rctf: - worker.trigger_set_printing(True) - worker.trigger_set_printing(True) - mock_rctf.assert_called_once() - assert worker._printing is True - - @pytest.mark.asyncio - async def test_unknown_state_is_not_sent(self, worker): - worker._proxy.set_printing = AsyncMock() - await worker._call_set_printing() - worker._proxy.set_printing.assert_not_called() - - @pytest.mark.asyncio - async def test_old_daemon_without_method_is_not_retried(self, worker): - import sdbus - - worker._printing = True - worker._proxy.set_printing = AsyncMock( - side_effect=sdbus.dbus_exceptions.DbusUnknownMethodError("unknown method") - ) - await worker._call_set_printing() - worker._proxy.set_printing.assert_awaited_once() - assert not worker._reconnecting - - @pytest.mark.asyncio - async def test_failed_send_is_retried_with_the_current_state(self, worker): - import sdbus - - sent = [] - - async def send(printing): - sent.append(printing) - if len(sent) == 1: - worker._printing = False - raise sdbus.SdBusBaseError("timeout") - - worker._printing = True - worker._proxy.set_printing = send - with patch("asyncio.sleep", new=AsyncMock()): - await worker._call_set_printing() - assert sent == [True, False] - - @pytest.mark.asyncio - async def test_gives_up_after_three_tries(self, worker): - import sdbus - - worker._printing = True - worker._proxy.set_printing = AsyncMock(side_effect=sdbus.SdBusBaseError("down")) - with patch("asyncio.sleep", new=AsyncMock()): - await worker._call_set_printing() - assert worker._proxy.set_printing.await_count == 3 - assert not worker._reconnecting diff --git a/tests/updater/conftest.py b/tests/updater/conftest.py index ae2922bc..2532ef84 100644 --- a/tests/updater/conftest.py +++ b/tests/updater/conftest.py @@ -6,7 +6,7 @@ import asyncio import sys -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -27,16 +27,20 @@ def mock_sdbus(): mock.DbusInterfaceCommonAsync = _FakeDbusBase mock.dbus_signal_async = lambda *a, **kw: lambda fn: fn mock.dbus_method_async = lambda *a, **kw: lambda fn: fn - mock.get_current_message.side_effect = LookupError with pytest.MonkeyPatch.context() as mp: for key in ("sdbus", "updater", "updater.dbus_service"): mp.delitem(sys.modules, key, raising=False) mp.setitem(sys.modules, "sdbus", mock) - mp.setitem(sys.modules, "sdbus.sd_bus_internals", mock.sd_bus_internals) - mp.setitem(sys.modules, "sdbus_async.dbus_daemon", MagicMock()) yield mock +@pytest.fixture(autouse=True) +def printing(): + """Klipper idle unless a test sets return_value; never dials a real klippy.sock.""" + with patch("updater.service.klipper_printing", AsyncMock(return_value=False)) as m: + yield m + + @pytest.fixture def svc(): """UpdaterDbusService with mocked UpdateService and signals.""" @@ -76,7 +80,6 @@ def svc(): s._closing = False s._status_check_in_progress = False s._status_pending = False - s._printing_watch = None s.busy_changed = MagicMock() s.provisioning_changed = MagicMock() s.status_ready = MagicMock() diff --git a/tests/updater/test_cli_no_sdbus.py b/tests/updater/test_cli_no_sdbus.py index 3fa18770..0a164b64 100644 --- a/tests/updater/test_cli_no_sdbus.py +++ b/tests/updater/test_cli_no_sdbus.py @@ -6,6 +6,7 @@ import builtins import fcntl import sys +from unittest.mock import patch import pytest @@ -19,11 +20,15 @@ def fake_import(name, *args, **kwargs): raise ModuleNotFoundError("No module named 'sdbus'") return real_import(name, *args, **kwargs) - for mod in [m for m in sys.modules if m == "updater" or m.startswith("updater.")]: - monkeypatch.delitem(sys.modules, mod, raising=False) - monkeypatch.delitem(sys.modules, "sdbus", raising=False) - monkeypatch.setattr(builtins, "__import__", fake_import) - yield + # patch.dict also drops the copies re-imported here, which delitem would leave behind. + with patch.dict(sys.modules): + for mod in [ + m for m in sys.modules if m == "updater" or m.startswith("updater.") + ]: + del sys.modules[mod] + sys.modules.pop("sdbus", None) + monkeypatch.setattr(builtins, "__import__", fake_import) + yield def test_cli_module_imports_without_sdbus(_no_sdbus): diff --git a/tests/updater/test_dbus_service_unit.py b/tests/updater/test_dbus_service_unit.py index 02b582f4..5db63514 100644 --- a/tests/updater/test_dbus_service_unit.py +++ b/tests/updater/test_dbus_service_unit.py @@ -457,13 +457,6 @@ async def test_cancel_ignored_while_provisioning(self, svc): class TestMethodReturnValues: - @pytest.mark.asyncio - async def test_set_printing_reaches_service(self, svc): - svc._svc.printing = False - await svc.set_printing(True) - assert svc._svc.printing is True - assert svc._printing_watch is None - @pytest.mark.asyncio async def test_update_all_rejected_when_busy_returns_false(self, svc): """Return False when busy without calling underlying service.""" @@ -552,74 +545,6 @@ async def test_background_apt_skipped_when_daemon_restart_pending( assert svc._svc.background_apt_upgrade.called is apt_spawned -class TestPrintingWatch: - """The printing flag lives only as long as the caller's bus name.""" - - @pytest.fixture - def bus(self, svc): - bus = MagicMock() - bus.match_signal_async = AsyncMock(return_value=MagicMock()) - svc._dbus = MagicMock(attached_bus=bus) - svc._svc.printing = False - return bus - - @pytest.fixture - def has_owner(self): - with ( - patch("updater.dbus_service._caller", return_value=":1.5"), - patch("updater.dbus_service.FreedesktopDbus") as fd, - ): - fd.return_value.name_has_owner = AsyncMock(return_value=True) - yield fd.return_value.name_has_owner - - @staticmethod - async def _owner_changed(bus, name: str, new: str = "") -> None: - msg = MagicMock() - msg.get_contents.return_value = (name, ":1.5", new) - bus.match_signal_async.call_args.args[4](msg) - for _ in range(3): - await asyncio.sleep(0) - - @pytest.mark.asyncio - async def test_cleared_when_sender_leaves(self, svc, bus, has_owner): - await svc.set_printing(True) - await asyncio.sleep(0) - assert svc._svc.printing is True - await self._owner_changed(bus, ":1.5") - assert svc._svc.printing is False - assert svc._printing_watch is None - bus.match_signal_async.return_value.close.assert_called_once() - - @pytest.mark.asyncio - async def test_other_names_ignored(self, svc, bus, has_owner): - await svc.set_printing(True) - await asyncio.sleep(0) - await self._owner_changed(bus, ":1.9") - await self._owner_changed(bus, ":1.5", new=":1.6") - assert svc._svc.printing is True - assert not svc._printing_watch.done() - - @pytest.mark.asyncio - async def test_sender_gone_before_subscribe_clears(self, svc, bus, has_owner): - has_owner.return_value = False - await svc.set_printing(True) - for _ in range(3): - await asyncio.sleep(0) - assert svc._svc.printing is False - bus.match_signal_async.return_value.close.assert_called_once() - - @pytest.mark.asyncio - async def test_report_replaces_previous_watch(self, svc, bus, has_owner): - await svc.set_printing(True) - await asyncio.sleep(0) - first = svc._printing_watch - await svc.set_printing(False) - await asyncio.sleep(0) - assert first.cancelled() - assert svc._printing_watch is None - assert svc._svc.printing is False - - class TestLockHeldSurfacesError: def _held_lock(self): lock = MagicMock() diff --git a/tests/updater/test_executor_unit.py b/tests/updater/test_executor_unit.py index 4f88fc9a..c5b250e8 100644 --- a/tests/updater/test_executor_unit.py +++ b/tests/updater/test_executor_unit.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json import os import shutil import time @@ -41,6 +42,7 @@ git_prune_extra_remotes, git_untracked_paths, enable_service, + klipper_printing, restart_service, restart_service_noblock, run_hook, @@ -1443,3 +1445,68 @@ async def test_polls_until_ready(self): ): assert await wait_for_http_ready("http://127.0.0.1:7912/x") is True assert probe.call_count == 2 + + +class TestKlipperPrinting: + @staticmethod + async def _serve(sock: Path, reply: bytes) -> asyncio.Server: + async def handle(reader, writer): + await reader.readuntil(b"\x03") + writer.write(reply) + await writer.drain() + await reader.read() + writer.close() + + return await asyncio.start_unix_server(handle, sock) + + @staticmethod + def _state(state: str) -> bytes: + status = {"print_stats": {"state": state}} + body = {"id": 1, "result": {"eventtime": 1.0, "status": status}} + return json.dumps(body).encode() + b"\x03" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("state", "active"), + [ + ("printing", True), + ("paused", True), + ("standby", False), + ("complete", False), + ("cancelled", False), + ("error", False), + ], + ) + async def test_state(self, tmp_path, state, active): + sock = tmp_path / "k.sock" + async with await self._serve(sock, self._state(state)): + assert await klipper_printing(sock) is active + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "reply", + [ + b"garbage\x03", + b'{"id": 1, "error": {"message": "Klippy not ready"}}\x03', + b"[]\x03", + b'{"id": 1', + ], + ) + async def test_bad_reply_is_idle(self, tmp_path, reply): + sock = tmp_path / "k.sock" + async with await self._serve(sock, reply): + assert await klipper_printing(sock) is False + + @pytest.mark.asyncio + async def test_missing_socket_is_idle(self, tmp_path): + assert await klipper_printing(tmp_path / "absent.sock") is False + + @pytest.mark.asyncio + async def test_unresponsive_klipper_times_out_idle(self, tmp_path): + async def handle(reader, writer): + await reader.read() + writer.close() + + sock = tmp_path / "k.sock" + async with await asyncio.start_unix_server(handle, sock): + assert await klipper_printing(sock, timeout=0.05) is False diff --git a/tests/updater/test_self_heal_unit.py b/tests/updater/test_self_heal_unit.py index 64bf1927..0f2061e0 100644 --- a/tests/updater/test_self_heal_unit.py +++ b/tests/updater/test_self_heal_unit.py @@ -564,12 +564,12 @@ async def run_test(): class TestForwardHeal: - def test_forward_heal_waits_while_printing(self, tmp_path): + def test_forward_heal_waits_while_printing(self, tmp_path, printing): async def run_test(): svc = UpdateService() svc._state_path = tmp_path / "state.json" svc._write_state({"BlocksScreen": {"prev_hash": "old", "fast_attempt": 3}}) - svc.printing = True + printing.return_value = True with patch("updater.service.git_fetch") as m_fetch: ok = await svc._forward_heal_once() assert not ok diff --git a/tests/updater/test_service_unit.py b/tests/updater/test_service_unit.py index 44723700..043769a8 100644 --- a/tests/updater/test_service_unit.py +++ b/tests/updater/test_service_unit.py @@ -147,7 +147,9 @@ async def test_force_bypasses_ttl(self, tmp_path: Path): assert skip_fetch is False, "force=True must not skip the fetch" @pytest.mark.asyncio - async def test_printing_skips_fetch_even_when_forced(self, tmp_path: Path): + async def test_printing_skips_fetch_even_when_forced( + self, tmp_path: Path, printing + ): fake_path = tmp_path / "klipper" fake_path.mkdir() component = ComponentConfig(name="klipper", kind="git", path=fake_path) @@ -159,7 +161,7 @@ async def test_printing_skips_fetch_even_when_forced(self, tmp_path: Path): patch("updater.service.check_git_status", return_value=fake) as mock_check, ): svc = UpdateService() - svc.printing = True + printing.return_value = True await svc.check_status(force=True) *_, skip_fetch = mock_check.call_args.args assert skip_fetch is True @@ -167,9 +169,7 @@ async def test_printing_skips_fetch_even_when_forced(self, tmp_path: Path): @pytest.mark.asyncio async def test_printing_skips_apt_list_refresh(self): with patch("updater.service.apt_update", AsyncMock()) as mock_update: - svc = UpdateService() - svc.printing = True - await svc._refresh_apt_lists(force=True) + await UpdateService()._refresh_apt_lists(force=True, printing=True) mock_update.assert_not_called() @pytest.mark.asyncio @@ -369,7 +369,7 @@ async def test_emits_step_progress_in_order(self, tmp_path): call("klipper", 2, 4), call("klipper", 3, 4), call("klipper", 4, 4), - call("BlocksScreen", 4, 4), # restart_ui: UI holds its overlay + call("BlocksScreen", 4, 4), ] @pytest.mark.asyncio @@ -2074,24 +2074,27 @@ def _comp(self, tmp_path: Path, *, name: str = "Spoolman") -> ComponentConfig: ) @pytest.mark.asyncio - async def test_printing_defers_without_probing(self, tmp_path, reachable): + async def test_printing_defers_without_probing(self, tmp_path, reachable, printing): on_busy = MagicMock() with patch.object(UpdateService, "_provision_component") as mock_prov: svc = UpdateService() svc._components = [self._comp(tmp_path)] - svc.printing = True + printing.return_value = True assert await svc.provision_missing(on_busy) is True reachable.assert_not_called() mock_prov.assert_not_called() on_busy.assert_not_called() @pytest.mark.asyncio - async def test_printing_with_nothing_missing_stops_polling(self, tmp_path): + async def test_printing_with_nothing_missing_stops_polling( + self, tmp_path, printing + ): (tmp_path / "Spoolman").mkdir() svc = UpdateService() svc._components = [self._comp(tmp_path)] - svc.printing = True + printing.return_value = True assert await svc.provision_missing() is False + printing.assert_not_called() @pytest.mark.asyncio async def test_unreachable_remote_skips_without_busy(self, tmp_path, reachable): @@ -3172,9 +3175,9 @@ async def test_honors_configured_apt_excludes(self): mock_up.assert_awaited_once_with(exclude=("^linux-image", "^firmware-")) @pytest.mark.asyncio - async def test_printing_skips_upgrade(self): + async def test_printing_skips_upgrade(self, printing): svc = UpdateService() - svc.printing = True + printing.return_value = True with patch("updater.service.apt_update", AsyncMock()) as mock_update: await svc.background_apt_upgrade() mock_update.assert_not_called() diff --git a/tests/widgets/test_update_page_unit.py b/tests/widgets/test_update_page_unit.py index d191bae8..15292bda 100644 --- a/tests/widgets/test_update_page_unit.py +++ b/tests/widgets/test_update_page_unit.py @@ -11,8 +11,12 @@ def page(qapp): """UpdatePage instance with all heavy UI deps mocked.""" patches = [ - patch("BlocksScreen.lib.panels.widgets.MainWindow.updatePage.LoadingOverlayWidget"), - patch("BlocksScreen.lib.panels.widgets.MainWindow.updatePage.BlocksCustomButton"), + patch( + "BlocksScreen.lib.panels.widgets.MainWindow.updatePage.LoadingOverlayWidget" + ), + patch( + "BlocksScreen.lib.panels.widgets.MainWindow.updatePage.BlocksCustomButton" + ), patch("BlocksScreen.lib.panels.widgets.MainWindow.updatePage.IconButton"), ] for p in patches: @@ -286,16 +290,6 @@ def test_does_not_emit_call_load_panel_on_normal_refresh(self, page, qtbot): page.handle_status_ready(_make_payload()) -class TestPrintingChanged: - def test_job_state_reaches_the_daemon(self, page, qtbot): - with qtbot.waitSignal(page.printing_changed, timeout=200) as blocker: - page.set_printing_state("state", "paused") - assert blocker.args == [True] - with qtbot.waitSignal(page.printing_changed, timeout=200) as blocker: - page.set_printing_state("state", "complete") - assert blocker.args == [False] - - class TestHandleBusyChanged: def test_true_shows_loading(self, page): page.show_loading = MagicMock() @@ -447,13 +441,13 @@ class TestHandleStepComplete: def test_emits_call_load_panel_with_step_message(self, page, qtbot): with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: page.handle_step_complete("klipper", 1, 4) - assert blocker.args == [True, "klipper: fetching",False] + assert blocker.args == [True, "klipper: fetching", False] page._progress_label.setText.assert_called_with("Step 1/4") def test_unknown_steps_falls_back_to_working(self, page, qtbot): with qtbot.waitSignal(page.call_load_panel, timeout=200) as blocker: page.handle_step_complete("moonraker", 99, 4) - assert blocker.args == [True, "moonraker: working",False] + assert blocker.args == [True, "moonraker: working", False] page._progress_label.setText.assert_called_with("Step 99/4") @@ -532,7 +526,9 @@ def test_bad_payload_keeps_statuses_and_toasts(self, page): class TestConfirmPopupCleanup: def test_second_confirm_deletes_previous_popup(self, page): - with patch("BlocksScreen.lib.panels.widgets.MainWindow.updatePage.BasePopup") as popup_cls: + with patch( + "BlocksScreen.lib.panels.widgets.MainWindow.updatePage.BasePopup" + ) as popup_cls: first = MagicMock() second = MagicMock() popup_cls.side_effect = [first, second] diff --git a/updater/dbus_service.py b/updater/dbus_service.py index 222ba759..7b8071e5 100644 --- a/updater/dbus_service.py +++ b/updater/dbus_service.py @@ -7,13 +7,10 @@ import json import logging from collections.abc import Awaitable, Callable -from contextlib import closing from functools import partial from pathlib import Path import sdbus -from sdbus.sd_bus_internals import SdBusMessage -from sdbus_async.dbus_daemon import FreedesktopDbus from updater.locking import process_lock from updater.models import ComponentStatus @@ -25,15 +22,6 @@ _RECONCILE_RETRY_S = 5.0 _BOOT_DELAY_S = 3.0 _SHUTDOWN_DRAIN_S = 60.0 # < systemd's 90s stop timeout -_BUS_DRIVER = "org.freedesktop.DBus" - - -def _caller() -> str: - """Unique bus name of the current D-Bus caller; empty outside a method call.""" - try: - return sdbus.get_current_message().sender or "" - except LookupError: - return "" class DbusProgressCallback: @@ -129,7 +117,6 @@ def __init__(self) -> None: self._status_check_in_progress: bool = False self._status_pending: bool = False self._invalid_requests: int = 0 - self._printing_watch: asyncio.Task | None = None self._reconcile_task = self._spawn( self._boot_reconcile(), name="boot_reconcile" ) @@ -314,46 +301,6 @@ async def recover(self, name: str, hard: bool) -> bool: self._spawn(self._run_recover(name, hard), name=f"recover_{name}") return True - @sdbus.dbus_method_async(input_signature="b") - async def set_printing(self, printing: bool) -> None: - """D-Bus method: the UI reports an active job; unattended work waits for it.""" - if self._printing_watch is not None: - self._printing_watch.cancel() - self._printing_watch = None - sender = _caller() - if printing and sender: - self._printing_watch = self._spawn( - self._release_printing_on_exit(sender), name="printing_watch" - ) - if printing != self._svc.printing: - _log.info("printing -> %s", printing) - self._svc.printing = printing - - async def _release_printing_on_exit(self, sender: str) -> None: - """Clear printing once its sender leaves the bus, like a logind inhibitor.""" - bus = self._dbus.attached_bus - left = asyncio.Event() - - def _on_owner_changed(msg: SdBusMessage) -> None: - name, _old, new = msg.get_contents() - if name == sender and not new: - left.set() - - slot = await bus.match_signal_async( - _BUS_DRIVER, - "/org/freedesktop/DBus", - _BUS_DRIVER, - "NameOwnerChanged", - _on_owner_changed, - ) - with closing(slot): - # Checked after subscribing so an exit in between is not missed. - if await FreedesktopDbus(bus).name_has_owner(sender): - await left.wait() - _log.warning("printing client %s left the bus - clearing printing", sender) - self._printing_watch = None - self._svc.printing = False - @sdbus.dbus_method_async(input_signature="ss", result_signature="b") async def bless_healthy(self, name: str, hash_val: str) -> bool: """D-Bus method: bless a component as healthy (known-good).""" diff --git a/updater/executor.py b/updater/executor.py index 7ea2d5c3..32d5f09f 100644 --- a/updater/executor.py +++ b/updater/executor.py @@ -34,6 +34,7 @@ DPKG = "/usr/bin/dpkg" # Root-owned fixed-argv apt wrapper (owns the -o opts); installed pre-restart. APT_HELPER = Path("/usr/local/sbin/bs-apt-helper") +KLIPPY_SOCK = Path("~/printer_data/comms/klippy.sock").expanduser() _SERVICE_RE = re.compile(r"^[a-zA-Z0-9@:._-]+\.service$") _GIT_SHA_RE = re.compile(r"^[a-f0-9]{7,40}$") @@ -1043,6 +1044,38 @@ def _http_probe(url: str) -> bool: conn.close() +async def klipper_printing(sock: Path = KLIPPY_SOCK, timeout: float = 2.0) -> bool: + """True while Klipper reports a printing or paused job; unreachable means idle.""" + req = { + "id": 1, + "method": "objects/query", + "params": {"objects": {"print_stats": ["state"]}}, + } + try: + async with asyncio.timeout(timeout): + reader, writer = await asyncio.open_unix_connection(sock) + try: + writer.write(json.dumps(req).encode() + b"\x03") + await writer.drain() + reply = json.loads((await reader.readuntil(b"\x03"))[:-1]) + finally: + writer.close() + await writer.wait_closed() + state = reply["result"]["status"]["print_stats"]["state"] + except ( + OSError, + TimeoutError, + EOFError, + ValueError, + LookupError, + TypeError, + asyncio.LimitOverrunError, + ) as exc: + logger.debug("klipper print state unavailable: %r", exc) + return False + return state in ("printing", "paused") + + async def wait_for_http_ready( url: str, timeout: float = 120.0, *, service: str | None = None ) -> bool: diff --git a/updater/hooks/Spoolman.sh b/updater/hooks/Spoolman.sh index ef4d83ec..1fdeade9 100755 --- a/updater/hooks/Spoolman.sh +++ b/updater/hooks/Spoolman.sh @@ -38,12 +38,19 @@ if ! systemctl is-active --quiet Spoolman.service 2>/dev/null; then } fi -# Moonraker gives up on a dead Spoolman at startup, so let the API answer before restarting it. -for _i in $(seq 30); do - curl -sf -m 2 http://localhost:7912/api/v1/health 2>/dev/null | grep -q healthy && break - [ "$_i" -eq 30 ] && echo "[hook:Spoolman] WARN: API still unhealthy after 30s - moonraker may not connect" - sleep 1 +# Fail before touching moonraker.conf: a failed install is rolled back, but the conf edit would not be. +_healthy=false +for _i in $(seq 60); do + if curl -sf -m 2 http://localhost:7912/api/v1/health 2>/dev/null | grep -q healthy; then + _healthy=true + break + fi + sleep 2 done +if ! $_healthy; then + echo "[hook:Spoolman] API unhealthy after 120s - failing so the install rolls back" + exit 1 +fi # The venv only exists as of this hook, so patch moonraker here: any earlier caller saw no venv and skipped. _home=$(dirname "$COMPONENT_PATH") diff --git a/updater/service.py b/updater/service.py index de1c76ba..0038af19 100644 --- a/updater/service.py +++ b/updater/service.py @@ -50,6 +50,7 @@ git_untracked_paths, is_git_repo, is_service_active, + klipper_printing, restart_service, restart_service_noblock, run_hook, @@ -259,7 +260,6 @@ def __init__(self, callback: ProgressCallback | None = None) -> None: self._restart_pending_until = 0.0 self._reconciled = False self._offline_provision_tries = 0 - self.printing = False @property def daemon_restart_pending(self) -> bool: @@ -282,13 +282,13 @@ def component_stubs(self) -> list[tuple[str, str]]: """Return (name, kind) pairs for all registered components.""" return [(c.name, c.kind) for c in self._components] - async def _refresh_apt_lists(self, force: bool) -> None: + async def _refresh_apt_lists(self, force: bool, printing: bool) -> None: """Run apt-get update: the upgradable count reads local lists and is stale without it.""" now = time.monotonic() ttl = _APT_LIST_FORCE_TTL_S if force else _APT_LIST_TTL_S # A held lock = an update is running, and it refreshes the lists itself. if ( - self.printing + printing or (now - self._apt_list_time) < ttl or self._apt_backoff.cooling_down() or self._apt_lock.locked() @@ -308,11 +308,12 @@ def has_fetch_failures(self) -> bool: async def check_status(self, force: bool = False) -> dict[str, ComponentStatus]: """Concurrently check status of all components.""" results: dict[str, ComponentStatus] = {} + printing = await klipper_printing() async def _check_one(c: ComponentConfig) -> None: """Fetch and record one component's status into the results dict.""" if c.kind == "apt": - await self._refresh_apt_lists(force) + await self._refresh_apt_lists(force, printing) status = await check_apt_status( cache_ttl_seconds=0 if force else 86_400, exclude=c.apt_exclude ) @@ -325,7 +326,7 @@ async def _check_one(c: ComponentConfig) -> None: async with self._git_lock: last = self._fetch_times.get(c.name, float("-inf")) breaker = self._fetch_backoff.get(c.name) - skip_fetch = self.printing or ( + skip_fetch = printing or ( not force and ( (now - last) < self._FETCH_TTL @@ -533,7 +534,7 @@ async def provision_missing( self, on_busy: Callable[[bool], None] | None = None ) -> bool: """Clone absent install_if_missing components; True to retry (lock, offline, printing).""" - if self.printing and self._missing_provisions(): + if self._missing_provisions() and await klipper_printing(): self._log.info("provision_missing: printer is printing, deferring") return True missing, retry = await self._unattended_provisions() @@ -1382,8 +1383,6 @@ async def _forward_heal_target( async def _forward_heal_once(self) -> bool: """One forward-heal pass: attempt the new origin/main tip if we are in fallback.""" - if self.printing: - return False state = await asyncio.to_thread(self._read_state) comp_state = state.get(_UI_COMPONENT, {}) if not isinstance(comp_state, dict): @@ -1391,6 +1390,8 @@ async def _forward_heal_once(self) -> bool: raw = comp_state.get("fast_attempt", 0) if not (isinstance(raw, int) and not isinstance(raw, bool)) or raw < 2: return False + if await klipper_printing(): + return False target = await self._forward_heal_target(comp_state) if target is None: return False @@ -2438,7 +2439,7 @@ async def _background_apt_upgrade_locked(self) -> None: if self._apt_backoff.cooling_down(): self._log.debug("apt cooling down; skipping background upgrade") return - if self.printing: + if await klipper_printing(): self._log.info("background apt upgrade skipped: printer is printing") return self._log.info("background apt upgrade: starting") From 27203ffd5a7b8c3a56806919cd32c145f119f6bf Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Fri, 2 Oct 2026 15:32:18 +0100 Subject: [PATCH 21/21] fix(ui): paint the splash on the X root at X start and before SIGTERM so UI restarts show no black gap --- BlocksScreen/BlocksScreen.py | 6 +----- scripts/BlocksScreen.service | 7 +++---- scripts/bs-pre-stop.py | 8 +------- scripts/bs-splash.py | 18 ++---------------- scripts/bs-xorg-init.sh | 11 +++++------ 5 files changed, 12 insertions(+), 38 deletions(-) diff --git a/BlocksScreen/BlocksScreen.py b/BlocksScreen/BlocksScreen.py index ec8e5dd0..35e6ca87 100644 --- a/BlocksScreen/BlocksScreen.py +++ b/BlocksScreen/BlocksScreen.py @@ -68,11 +68,7 @@ def notify(self, a0: QtCore.QObject, a1: QtCore.QEvent) -> bool: # type: ignore def _write_splash_to_fb0() -> None: - """Write precomputed splash to fb0 while KD_GRAPHICS is still active. - - Called from SIGTERM handler so fb0 already shows the splash before X exits, - eliminating the brief black frame between X shutdown and ExecStopPost. - """ + """Write the precomputed splash to fb0 on SIGTERM, before window teardown.""" try: if _SPLASH_CACHE.exists(): _FB0.write_bytes(_SPLASH_CACHE.read_bytes()) diff --git a/scripts/BlocksScreen.service b/scripts/BlocksScreen.service index d09b755b..c4a8e107 100644 --- a/scripts/BlocksScreen.service +++ b/scripts/BlocksScreen.service @@ -12,9 +12,7 @@ After=BlocksScreen-updater.service [Service] Type=notify -# NotifyAccess=all: on the transitional fallback paths in BlocksScreen-start.sh -# (inline xinit, launch_BlocksScreen.sh) the Qt process is not the main PID, and -# its READY/WATCHDOG notifications would otherwise be rejected -> start timeout. +# Fallback start paths (inline xinit, launch_BlocksScreen.sh) notify from a non-main PID. NotifyAccess=all WatchdogSec=30s Restart=always @@ -28,8 +26,9 @@ Environment=DISPLAY=:0 Environment=XAUTHORITY=/home/blocks/.Xauthority ExecStartPre=-+/bin/bash /home/blocks/BlocksScreen/scripts/bs-deploy-check.sh ExecStart=/home/blocks/BlocksScreen/scripts/BlocksScreen-start.sh +# Root splash before SIGTERM: teardown destroys the windows long before the process exits. +ExecStop=-/usr/bin/feh --no-fehbg --bg-fill /home/blocks/.cache/blockscreen/splash.png ExecStop=-+/home/blocks/BlocksScreen/scripts/bs-pre-stop.py -ExecStopPost=-/usr/bin/feh --no-fehbg --bg-fill /home/blocks/.cache/blockscreen/splash.png [Install] WantedBy=multi-user.target diff --git a/scripts/bs-pre-stop.py b/scripts/bs-pre-stop.py index a6523bbc..782b95e6 100755 --- a/scripts/bs-pre-stop.py +++ b/scripts/bs-pre-stop.py @@ -1,11 +1,5 @@ #!/usr/bin/env python3 -"""ExecStop - write splash to fb0 before the Qt process is fully dead. - -X.Org stays alive (BlocksScreen-xorg.service is independent), so no VT switch -is needed. Writing the splash to fb0 is a best-effort hint; in KMS mode the -vc4 driver may or may not honour fb0 writes while X holds DRM master. -ExecStopPost (feh --bg-fill) repaints the splash after the process is fully gone. -""" +"""ExecStop: best-effort fb0 splash; vc4 may ignore it while X holds DRM master.""" import os import sys diff --git a/scripts/bs-splash.py b/scripts/bs-splash.py index 00b2e0d9..e3903d1c 100755 --- a/scripts/bs-splash.py +++ b/scripts/bs-splash.py @@ -1,13 +1,5 @@ #!/usr/bin/env python3 -"""Write the BLOCKS logo splash to /dev/fb0 and cache as raw bytes. - -Two modes: - default - write to /dev/fb0 and save cache (no VT switch; X11 activates tty7 itself) - --precompute - render and save cache only (no fb0 write) - -The raw cache is consumed by bs-pre-stop.py (ExecStop) and bs-splash-holder.py -(tty8 boot splash); the PNG by feh in ExecStopPost. -""" +"""Render the splash to /dev/fb0, splash.raw (fb0 writers) and splash.png (X root).""" import argparse import os @@ -81,7 +73,6 @@ def _render(w: int, h: int, Image, ImageDraw, ImageFont) -> Any: bg.paste(logo, (x, logo_y), logo) text_y = logo_y + lh + 24 else: - # Fallback: no logo - draw a placeholder card card_w, card_h = 500, 160 cx, cy = (w - card_w) // 2, (h - card_h) // 2 draw.rectangle( @@ -179,7 +170,6 @@ def main() -> None: _log(f"Render error: {e}") return - # Save PNG for X11 root-window splash (feh --bg-fill in ExecStopPost) try: _CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) img.save(str(_CACHE_PATH.parent / "splash.png")) @@ -190,7 +180,6 @@ def main() -> None: if fb_data is None: return - # Save raw cache so bs-pre-stop.py / bs-splash-holder.py can write fb0 directly try: _CACHE_PATH.write_bytes(fb_data) except OSError as e: @@ -199,10 +188,7 @@ def main() -> None: if args.precompute: return - # Write logo to fb0 so fbcon on tty7 shows it immediately when X11 activates tty7. - # We do NOT switch VTs or set KD_GRAPHICS here - X11 does VT_ACTIVATE(7) itself - # at startup (that init step is not affected by -novtswitch), which keeps tty8 - # active with the splash visible until X11 is truly ready to take over the display. + # No VT switch here: X does VT_ACTIVATE(7) itself even with -novtswitch. try: with open("/dev/fb0", "wb") as fb: fb.write(fb_data) diff --git a/scripts/bs-xorg-init.sh b/scripts/bs-xorg-init.sh index 86a43210..44c0b323 100755 --- a/scripts/bs-xorg-init.sh +++ b/scripts/bs-xorg-init.sh @@ -1,11 +1,12 @@ #!/bin/bash -# X.Org permanent-session init: runs as xinit client, keeps X alive indefinitely. -# Display setup is done once here; BlocksScreen.service attaches separately via DISPLAY=:0. +# xinit client: one-time display setup, then holds X alive for BlocksScreen.service (DISPLAY=:0). # # Copyright (C) 2025 Hugo Costa # SPDX-License-Identifier: AGPL-3.0-or-later -xsetroot -solid '#141414' 2>/dev/null || true +# Root shows the splash for X's lifetime, so no gap between UI windows is ever blank. +feh --no-fehbg --bg-fill "$HOME/.cache/blockscreen/splash.png" 2>/dev/null \ + || xsetroot -solid '#141414' 2>/dev/null || true # 1×1 blank XBM - hides the X11 root-window cursor (Pi 5 SWcursor honours this) printf '%s\n' \ @@ -15,11 +16,9 @@ printf '%s\n' \ > /tmp/bs-blank.xbm 2>/dev/null || true xsetroot -cursor /tmp/bs-blank.xbm /tmp/bs-blank.xbm 2>/dev/null || true -# Force correct display mode (belt to 97-bs-resolution.conf's suspenders: -# EDID can fail on Pi 5, leaving X at a lower resolution that KMS upscales) +# Backs up 97-bs-resolution.conf: Pi 5 EDID can fail, leaving a low mode that KMS upscales. _out=$(xrandr 2>/dev/null | awk '/ connected/{print $1; exit}') _mode=$(xrandr 2>/dev/null | awk '/ connected/{f=1;next} f && /^[[:space:]]+[0-9]+x[0-9]+/{print $1; exit}') [ -n "$_out" ] && [ -n "$_mode" ] && xrandr --output "$_out" --mode "$_mode" 2>/dev/null || true -# Hold X alive indefinitely - BlocksScreen Qt process connects via DISPLAY=:0 exec sleep infinity