Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions BlocksScreen/lib/panels/mainWindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 48 additions & 5 deletions BlocksScreen/lib/panels/widgets/MainWindow/updatePage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
)
Expand All @@ -72,6 +79,8 @@ 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._provisioning: bool = False
self._elapsed_time_seconds: int = 0
self._elapsed_timer: QtCore.QTimer = QtCore.QTimer(self)
self._elapsed_timer.setSingleShot(False)
Expand Down Expand Up @@ -335,10 +344,11 @@ 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
self._overlay_shown = False
else:
_log.debug("status_ready: skipping loadscreen dismiss (busy=True)")
self.build_cards()
Expand All @@ -350,6 +360,14 @@ 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._restart_pending = False
self._elapsed_time_seconds = 0
self._elapsed_timer.start()
self._busy_timeout_timer.start()
Expand All @@ -358,17 +376,36 @@ 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()
self._progress_label.hide()
self._cancel_btn.hide()
self.update_all_btn.setEnabled(True)
if self._overlay_shown:
self._overlay_shown = False
self.call_load_panel.emit(False, "", False)
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:
# 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:
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."""
Expand Down Expand Up @@ -418,14 +455,20 @@ 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)
# Progress = daemon liveness; push the force-dismiss deadline out.
if self._busy_timeout_timer.isActive():
self._busy_timeout_timer.start()
self._overlay_shown = True
overlay_msg = f"{name}: {label}"
# 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}"
)
self._progress_label.setText(f"Step {step}/{total}")
self.call_load_panel.emit(True, overlay_msg, False)

Expand Down
9 changes: 9 additions & 0 deletions BlocksScreen/lib/updater_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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().
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions scripts/install-updater.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ..."
Expand Down
12 changes: 12 additions & 0 deletions tests/lib/test_updater_worker_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
3 changes: 3 additions & 0 deletions tests/updater/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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
Expand Down
86 changes: 86 additions & 0 deletions tests/updater/test_dbus_service_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,77 @@ 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 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):
Expand Down Expand Up @@ -422,6 +493,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):
Expand Down
4 changes: 3 additions & 1 deletion tests/updater/test_executor_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading