Skip to content

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

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

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

Conversation

@owenpkent

@owenpkent owenpkent commented Sep 21, 2026 •

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

Review follow-ups (2026-10-12)

  • check_feed() and the updater's find_release() were two implementations of the same thing. One remains, find_release(), raising FeedError (a subclass of UpdateError) for every feed problem. A JSON list is the releases collection, an object with tag_name is a single release, and any other object is an error body whose message is carried in the exception. The CLI exits 5 on a feed failure; the GUI's failed signal carries the message.

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.

Comment thread src/offloader/gui/updates.py
Comment thread src/offloader/gui/main_window.py
Comment thread src/offloader/gui/main_window.py Outdated
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.

@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.

Follow-up review of d2e4edc for failed checks, cancellation, announcement intent, and integration with the parent updater.

The three previously reported GUI lifecycle defects have targeted fixes: failure gets a failed signal, Cancel reaches the download callback and suppresses readiness, and a refused automatic check cannot downgrade a manual announcement. The feed-type guard introduces a separate incompatibility with the corrected release collection on #13, detailed inline.

Validation: all 34 updater-controller tests passed under headless Qt. A separate injected collection response reproduced FeedError from check_feed() and None from the compatibility check() wrapper. No installer or real download was executed.

Comment thread src/offloader/update.py Outdated

@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.

No new nonduplicate finding in this incremental diff. Previously private updater-verification concerns inherited from #13 remain outside the corrected GUI control flow.

Reviewed thread/controller ownership, feed outcomes, manual versus startup intent, cancellation, progress/state transitions and maintenance blocking around queued or active jobs. The earlier list-feed rejection, failure-reported-as-current, lost manual intent and cancellation findings are corrected in this head.

Validation: all 40 GUI updater-controller tests and seven added window/CLI outcome tests passed on the exact detached head. Network, signature and installer operations were injected. Current-head CI reports successful matrix/build/frozen checks; the diff check passes.

Limits: headless Qt without a signed published-release update, real UAC handoff or installation. Passing controller tests do not resolve the separate installer-verification issues already recorded privately.

Pinned scope: declared base 19c7be0735b097d76a4f047c6a9bf469b1d10f76, merge base 19c7be0735b097d76a4f047c6a9bf469b1d10f76, head dcae1f87aad9ff01f58a82831e885d033a9f3640. Existing discussion and current-head checks were reviewed; no threads were resolved and no formal approval or merge is being issued.

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