Skip to content

Check for updates from the desktop app, and decline while a job runs - #15

Open
owenpkent wants to merge 3 commits into
release-candidate-workflowfrom
in-app-update-check
Open

owenpkent wants to merge 3 commits into
release-candidate-workflowfrom
in-app-update-check

Conversation

@owenpkent

Copy link
Copy Markdown
Owner

Summary

The client from #13 had no way to reach it from the interface most people will use. This adds the check, the notification and the install sequence. The sequence is the interesting part.

Everything here works without a signing certificate or a published release, which is why it was the next thing buildable.

The sequence cannot be the usual one

Replacing a desktop app underneath itself is the standard pattern and it is not available here: the installer refuses maintenance while a transfer is in flight and never force-kills a running copy. So:

  • A running or paused job declines the update with the reason, rather than queueing behind it or forcing it through. A paused job counts — it is a partially written destination waiting to continue, and replacing the application under it is not a way to find out what happens.
  • The queue is checked again immediately before the installer is launched, because a job can be started while the bytes are arriving.
  • When nothing is running, the app closes itself so maintenance can proceed. That close is what makes the update possible, so it is announced and confirmed rather than arriving as a side effect.

Restraint in the check

One check, a couple of seconds after the window opens, and it says nothing unless there is something to say: an unrequested check reporting "you are up to date" is noise. A found release gets a line in the header rather than a dialog stealing attention from a running offload. Help, Check for updates now answers either way; Options, Check for updates on launch turns the automatic one off (on by default — a packaged copy that never learns a fix exists is the worse default for a tool people trust with original media).

Both the network call and the signature probe run on a pool thread, so an unreachable network delays no paint and a failure lands in the status bar, not a modal.

An ordering fault the tests found

Worth flagging because it was a real bug, not a test artefact. _run_check set self._release before clearing its own busy flag, leaving a window in which a release was visible and prepare() still refused. In the app that is an update which announces itself and then quietly does nothing, on a timing-dependent basis.

The flag is now cleared before the result is published, and test_a_visible_release_can_always_be_prepared asserts the invariant: anything able to see a release can also start the download.

Design notes for review

  • Signals carry versions, byte counts and digests, never the download URL. The window has no use for it, and a URL that never reaches the interface cannot be rendered into one.
  • The signals object is owned by the controller, not the QRunnable, for the same reason drives._ScanTask does it: a runnable's Python wrapper can be collected as soon as start() returns.
  • refusal() takes the queue's states rather than the controller, so the rule is a pure function and testable without a window.
  • Progress is throttled to roughly one signal per 256 KB but always emits the final block, so the bar finishes rather than stopping just short.
  • Every step is injectable, so the whole sequence including each refusal runs in tests with no network, certificate or installer.

Test plan

  • tests/test_gui_updates.py — 18 tests: the refusal for running and paused, idle and queued-only allowed, one active job among many still refusing; newer release reported, up-to-date reported separately, a second check refused while one runs, a raising check not escaping the pool thread, the ordering invariant, download/verify/ready with the streamed digest passed to both sides, a failed verification leaving no installer the window could run, progress throttled but finishing, and install refusing without a verified file
  • Full suite 828 passed, 12 skipped; ruff check src tests scripts build/windows clean
  • Real offloader-gui.exe launched and stayed up for 6s, so the launch-time check does not break startup
  • An actual app-to-app update needs two signed published releases; not possible yet

The command-line client existed with no way to reach it from the interface
most people will use. This adds the check, the notification and the install
sequence, and the sequence is the interesting part.

Replacing a desktop app underneath itself is the usual pattern, and it is not
available here: the installer refuses maintenance while a transfer is in
flight and never force-kills a running copy. So a running or paused job
declines the update with the reason rather than queueing behind it or forcing
it through. A paused job counts, because it is a partially written
destination waiting to continue, and replacing the application under it is
not a way to find out what happens. The queue is checked again immediately
before the installer is launched, since a job can be started while the bytes
are arriving. When nothing is running, the app closes itself so maintenance
can proceed; that close is what makes the update possible, so it is
announced rather than arriving as a side effect.

The automatic check runs once a couple of seconds after the window opens and
says nothing unless there is something to say, because an unrequested check
that reports being up to date is noise. Help offers the same check with an
answer either way, and Options turns the automatic one off. Both the network
call and the signature probe run on a pool thread, so an unreachable network
delays no paint and a failure lands in the status bar rather than a dialog.

Writing the tests found an ordering fault worth keeping fixed: the check set
the release before clearing its own busy flag, leaving a window in which a
release was visible and prepare() still refused. In the app that is an update
which announces itself and then quietly does nothing. The flag is now cleared
before the result is published, and a test asserts that anything able to see
a release can also start the download.

Every step is injectable, so the whole sequence including each refusal is
driven in tests without a network, a certificate or an installer.

@owenpkent owenpkent left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed manual/automatic update checks, worker-to-GUI signaling, download progress, transfer refusal, and the install handoff. Three user-visible lifecycle defects are detailed inline: a failed check is reported as current, Cancel does not cancel, and the startup timer can suppress a manual result.

Validation: 18 targeted updater tests passed on this head and git diff --check passed. The injected raising-check case still emitted an uncaught worker traceback; a green test does not establish that the failed signal was delivered. Additional verification-path concerns inherited from #13 are included in the private updater notes rather than repeated publicly.

# refuses, which reads as an update that quietly does nothing.
self._busy = False
self._release = release
if release is None:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Distinguish a failed update check from a successful current-version result.

The real offloader.update.check() catches fetch/TLS/feed-parsing exceptions and returns None. This branch therefore emits upToDate when the feed was unreachable or malformed, and a manual check tells the user that this is the newest release without having obtained a usable response. Automatic failures also never reach the documented failure-status path.

Return a distinct failure outcome, or allow a typed check error to reach an exception handler here. _run_check() itself needs an except path as well: an injected raising check currently produces a QRunnable traceback and no failed signal. Cover failed fetch, invalid feed, and a genuinely up-to-date response as separate controller outcomes.

QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
if answer != QMessageBox.Yes:
return
self._update_progress = QProgressDialog(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Wire Cancel to cancellation, or remove the button.

The dialog exposes Cancel, but its canceled signal is never connected and UpdateController has no cancellation state. Clicking it only hides the dialog; downloading and verification continue, and _on_update_ready() subsequently offers installation. The user's explicit cancellation therefore does not stop or suppress the operation.

Add cooperative cancellation through download/prepare, clean up only the owned incomplete download, and suppress ready for the canceled request. If cancellation is deferred, remove the Cancel button so the control does not promise behavior it cannot provide. Add a headless test that clicks Cancel during a blocked download and checks the terminal signal.

Comment thread src/offloader/gui/main_window.py Outdated
"""`announce` reports "you are up to date"; the automatic check does
not, because an unasked-for check should only ever speak up when there
is something to say."""
self._announce_update = announce

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve an explicit check request when the launch timer fires.

_announce_update is overwritten before check_now() reports whether a new check actually started. If the user requests a manual check during the first 2.5 seconds and it is still running when the startup timer fires, the timer sets this flag to False, then the controller rejects the duplicate check. The original manual result is consequently handled as an automatic result, so the requested result/dialog disappears.

Associate announcement intent with the check that actually starts, or preserve an existing manual request when a duplicate call is refused. Add a controlled slow-check test that triggers the manual action first and the launch timer second, then asserts that the manual completion remains visible.

Three ways the update sequence told the user something that was not so.

update.check() never raises and returns None for a failed fetch, a TLS error
and an unparseable feed alike, and the controller read None as "you are the
newest release". So a manual check reported that on the strength of a failed
DNS lookup, and an automatic failure never reached the documented failure
path at all. check_feed() is the form with somewhere to put the difference:
None only when the feed answered and had nothing newer, FeedError otherwise.
check() is unchanged for callers that have nowhere to put it. _run_check also
had no except path, so an injected or future check that raised produced a
bare QRunnable traceback and no signal - the window waited on a result that
was never coming.

The progress dialog offered Cancel and nothing was connected to it. Clicking
it hid the dialog while the download and the signature check carried on, and
the app then offered to install what the user had just declined.
Cancellation is cooperative, because a blocking read loop on a pool thread
has nowhere else to hand control back: the progress callback raises. What it
guarantees is the part that was missing - the request is terminal, the
incomplete download is removed, verification does not run, and ready is not
emitted. Checked again after the download returns, so a file small enough to
arrive between two callbacks cannot sail past the cancel. Only the file the
release names is removed, since the download directory can be one the caller
owns. Closing the dialog for a result that has arrived disconnects first,
because QProgressDialog's own closeEvent emits canceled and that would be
reported back as the user asking to stop.

The announcement intent now belongs to the check that is running rather than
to the window's last call. It was stamped before check_now reported whether
a check had started, so a manual check requested during the first 2.5 seconds
was downgraded by the launch timer firing behind it: the duplicate was
refused, the intent was already overwritten, and the result the user asked
for was handled silently. A refused duplicate now raises the intent and never
lowers it, so it survives in both directions.

Sixteen tests. Failed fetch, invalid feed and a genuine up-to-date answer as
three separate outcomes at both the feed and the controller; a cancel during
a blocked download and one arriving after the bytes have, each asserting the
terminal signal, the absent installer and the removed file; a file beside it
that is not removed; the refused-duplicate race in both directions and at the
window; and the dialog's Cancel reaching the controller while closing it for
a result does not.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant