From 0d22f28f989b4a5850f1a6bed171a541fd18c2db Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 11 Mar 2026 20:10:32 -0700 Subject: [PATCH 1/6] Stale Download Handling --- .../application/update_controller.py | 16 +++- tests/unit/qt/test_update_controller.py | 88 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/synodic_client/application/update_controller.py b/synodic_client/application/update_controller.py index 7a79198..7dca2b3 100644 --- a/synodic_client/application/update_controller.py +++ b/synodic_client/application/update_controller.py @@ -236,7 +236,17 @@ def _extract_update_key(config: ResolvedConfig) -> tuple[object, ...]: ) def _reinitialize_updater(self, config: ResolvedConfig) -> None: - """Re-derive update settings and restart the updater and timer.""" + """Re-derive update settings and restart the updater and timer. + + Cancels any in-flight check/download task and clears cached + state so the new updater starts with a clean slate. + """ + if self._update_task is not None and not self._update_task.done(): + self._update_task.cancel() + self._update_task = None + self._pending_version = None + self._failed_version = None + update_cfg = resolve_update_config(config) self._client.initialize_updater(update_cfg) self._restart_auto_update_timer() @@ -414,6 +424,10 @@ def _apply_update(self, *, silent: bool = False) -> None: if self._client.updater is None: return + if self._pending_version is None: + self._report_error('No downloaded update to apply — please check for updates again.', silent=silent) + return + try: # Re-register the startup entry with the current exe path so # the registry value stays valid even if Velopack relocates diff --git a/tests/unit/qt/test_update_controller.py b/tests/unit/qt/test_update_controller.py index 33c92d3..04e39b0 100644 --- a/tests/unit/qt/test_update_controller.py +++ b/tests/unit/qt/test_update_controller.py @@ -347,6 +347,7 @@ class TestApplyUpdate: def test_apply_update_calls_client_and_quits() -> None: """_apply_update should call client.apply_update_on_exit and app.quit.""" ctrl, app, client, banner, model = _make_controller() + ctrl._pending_version = '2.0.0' ctrl._apply_update() client.apply_update_on_exit.assert_called_once_with(restart=True, silent=False) @@ -366,6 +367,7 @@ def test_apply_update_noop_without_updater() -> None: def test_apply_update_refreshes_startup_registry_when_frozen() -> None: """_apply_update should call sync_startup before quitting.""" ctrl, app, client, banner, model = _make_controller() + ctrl._pending_version = '2.0.0' with ( patch('synodic_client.application.update_controller.sync_startup') as mock_sync, @@ -497,3 +499,89 @@ def test_download_finished_syncs_via_store() -> None: mock_update.assert_called_once() assert len(spy.last_checked) == 1 + + +# --------------------------------------------------------------------------- +# Reinitialise updater — stale state cleared +# --------------------------------------------------------------------------- + + +class TestReinitializeUpdater: + """Verify _reinitialize_updater clears stale state.""" + + @staticmethod + def test_reinit_resets_state_and_cancels_task() -> None: + """Reinitialising should clear pending/failed versions and cancel in-flight tasks.""" + ctrl, _app, _client, _banner, _model = _make_controller() + ctrl._pending_version = '1.0.0' + ctrl._failed_version = '1.0.0' + fake_task = MagicMock() + fake_task.done.return_value = False + ctrl._update_task = fake_task + + new_config = _make_config(update_channel='dev') + with patch('synodic_client.application.update_controller.resolve_update_config') as mock_ucfg: + mock_ucfg.return_value = MagicMock( + auto_update_interval_minutes=0, + channel=MagicMock(name='DEVELOPMENT'), + repo_url='https://example.com', + ) + ctrl._reinitialize_updater(new_config) + + assert ctrl._pending_version is None # pyrefly: ignore + assert ctrl._failed_version is None # pyrefly: ignore + fake_task.cancel.assert_called_once() + + @staticmethod + def test_reinit_then_check_redownloads_same_version() -> None: + """After reinit, a check for the same version should download instead of showing ready.""" + ctrl, _app, _client, _banner, _model = _make_controller(auto_apply=False) + ctrl._pending_version = '2.0.0' + + new_config = _make_config(update_channel='dev') + with patch('synodic_client.application.update_controller.resolve_update_config') as mock_ucfg: + mock_ucfg.return_value = MagicMock( + auto_update_interval_minutes=0, + channel=MagicMock(name='DEVELOPMENT'), + repo_url='https://example.com', + ) + ctrl._reinitialize_updater(new_config) + + result = UpdateInfo(available=True, current_version=Version('1.0.0'), latest_version=Version('2.0.0')) + with patch.object(ctrl, '_start_download') as mock_dl: + ctrl._on_check_finished(result, silent=True) + + mock_dl.assert_called_once_with('2.0.0', silent=True) + + +# --------------------------------------------------------------------------- +# Apply without pending download — guard +# --------------------------------------------------------------------------- + + +class TestApplyGuard: + """Verify _apply_update rejects apply when no download is pending.""" + + @staticmethod + def test_apply_without_pending_shows_error() -> None: + """Applying with no pending version should show an error, not crash.""" + ctrl, app, client, banner, model = _make_controller() + ctrl._pending_version = None + + ctrl._apply_update(silent=False) + + assert banner.state.name == 'ERROR' + client.apply_update_on_exit.assert_not_called() + app.quit.assert_not_called() + + @staticmethod + def test_apply_without_pending_silent_logs_only() -> None: + """Applying silently with no pending version should not show the banner.""" + ctrl, app, client, banner, model = _make_controller() + ctrl._pending_version = None + + ctrl._apply_update(silent=True) + + assert banner.state.name == 'HIDDEN' + client.apply_update_on_exit.assert_not_called() + app.quit.assert_not_called() From 8429e6593a1c737eb063751d876b97bdb2edb488 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 11 Mar 2026 21:50:50 -0700 Subject: [PATCH 2/6] CLI Interface Improvement --- AGENTS.md | 31 +++++++++++++++---------- pyproject.toml | 17 ++++---------- synodic_client/application/data.py | 7 ++++++ synodic_client/application/qt.py | 27 +++++++++++++++++++++- tool/scripts/dev.py | 37 ------------------------------ 5 files changed, 56 insertions(+), 63 deletions(-) delete mode 100644 tool/scripts/dev.py diff --git a/AGENTS.md b/AGENTS.md index 2594b24..7895d0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,20 +1,27 @@ # AGENTS.md -This repository doesn't contain any agent specific instructions other than its [README.md](README.md), required development documentation, and its linked resources. +An application frontend for [porringer](https://www.github.com/synodic/porringer) that manages and downloads package managers and their dependents. -## Logging +We use [PDM](https://pdm-project.org/en/latest/) as our build system and package manager. All commands below are `pdm