Skip to content
Closed
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
12 changes: 9 additions & 3 deletions linux/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1370,11 +1370,17 @@ void start_update_download() {
auto* label = gtk_label_new(tr("updateDownloading").c_str());
gtk_box_append(GTK_BOX(content), label);

// Canceling only detaches the progress UI — the backend keeps downloading
// (family parity: there is no cancel RPC).
// start_download is intentionally a start/cancel toggle while a transfer
// is active, so the existing generated client can stop the backend worker
// without a schema-only regeneration.
g_signal_connect(
dialog, "response",
G_CALLBACK(+[](GtkDialog* dialog, gint, gpointer) {
G_CALLBACK(+[](GtkDialog* dialog, gint response, gpointer) {
if (response == GTK_RESPONSE_CANCEL && g_state.api != nullptr &&
g_update.download_in_flight) {
g_state.api->start_download_async(
[](rivet_app::Result<void>) {});
}
stop_update_poll();
g_update.progress_dialog = nullptr;
g_update.progress_bar = nullptr;
Expand Down
9 changes: 7 additions & 2 deletions racket/taskly/backend.rkt
Original file line number Diff line number Diff line change
Expand Up @@ -271,11 +271,16 @@
(update-check->record (perform-check!))))

;; Runs on a backend worker thread; the host follows progress via
;; update_state. Never raises: failures surface through the state's phase.
;; update_state. A second call while a transfer is active is the cancellation
;; signal used by the generated clients (keeps the RPC schema backward
;; compatible while making the existing Cancel controls real).
(define-rpc (start_download : Void)
(with-handlers
([exn:fail? (lambda (e) (set-update-error! (exn-message e)))])
(start-download! (taskly-directory)))
(define worker (update-worker-thread))
(if (and worker (thread-running? worker))
(cancel-download!)
(start-download! (taskly-directory))))
(void))

(define-rpc (update_state : UpdateState)
Expand Down
46 changes: 40 additions & 6 deletions racket/taskly/updater.rkt
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
set-update-error!
set-update-candidate!
update-worker-thread
cancel-download!
perform-check!
start-download!
rollout-bucket)
Expand Down Expand Up @@ -134,6 +135,7 @@

(define candidate-box (box #f))
(define worker-thread-box (box #f))
(define cancel-requested-box (box #f))

;; Test seams: install a checked candidate without a live network check,
;; and reach the running worker so a test can break it the way the
Expand All @@ -144,6 +146,26 @@
(define (update-worker-thread)
(unbox worker-thread-box))

(define (cancel-download!)
(define worker (unbox worker-thread-box))
(cond
[(and worker (thread-running? worker))
(set-box! cancel-requested-box #t)
(break-thread worker)
;; Wait for termination before publishing idle. A very early cancel can
;; land before the worker enters its with-handlers body, so the caller
;; owns the final state normalization instead of relying solely on the
;; worker cleanup arm.
(sync (thread-dead-evt worker))
(set-box! worker-thread-box #f)
(state-set! 'phase "idle")
(state-set! 'percent 0)
(state-set! 'message #f)
(state-set! 'downloadedPath #f)
(set-box! cancel-requested-box #f)
#t]
[else #f]))

;; Download watchdog bounds. The copy loop waits on read-bytes-avail! with
;; no timeout, so a connection that stalls without RST/FIN parks the worker
;; thread forever — visibly "stuck at 100%" (all bytes had arrived; the
Expand All @@ -163,6 +185,7 @@

(define (reset-update-state!)
(set-box! candidate-box #f)
(set-box! cancel-requested-box #f)
(set-box! update-state
(hasheq 'phase "idle"
'percent 0
Expand Down Expand Up @@ -388,7 +411,7 @@
(appimage-artifact-urls candidate))
(define-values (self temporary) (appimage-download-target))
(define expected (fetch-sidecar-sha256! sidecar-url))
(with-handlers ([exn:fail?
(with-handlers ([(lambda (e) (or (exn:fail? e) (exn:break? e)))
(lambda (e)
(when (file-exists? temporary)
(delete-file temporary))
Expand Down Expand Up @@ -507,6 +530,7 @@
(define candidate (unbox candidate-box))
(unless candidate
(error 'start-download! "no update is available; run a check first"))
(set-box! cancel-requested-box #f)
(state-set! 'phase "downloading")
(state-set! 'percent 0)
(state-set! 'message #f)
Expand Down Expand Up @@ -544,11 +568,21 @@
(define temporary
(path-add-extension destination #".partial"))
(when (file-exists? temporary) (delete-file temporary))
(state-set! 'phase "error")
(state-set! 'message
(if (exn:break? e)
"update download stalled or timed out"
(exn-message e))))])
(define cancelled?
(and (exn:break? e) (unbox cancel-requested-box)))
(if cancelled?
(begin
(state-set! 'phase "idle")
(state-set! 'percent 0)
(state-set! 'message #f)
(state-set! 'downloadedPath #f))
(begin
(state-set! 'phase "error")
(state-set! 'message
(if (exn:break? e)
"update download stalled or timed out"
(exn-message e)))))
(set-box! cancel-requested-box #f))])
(define path
(cond
[(msi-flavor?)
Expand Down
66 changes: 66 additions & 0 deletions racket/tests/updater-test.rkt
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,69 @@
(reset-update-state!))
(kill-thread stall-server)
(tcp-close listener))


(test-case "user cancellation stops the worker, cleans partials, and permits retry"
(define data-dir (make-temporary-file "taskly-updater-cancel-data-~a" 'directory))
(define listener (tcp-listen 0 16 #f))
(define-values (_listen-host cancel-port _peer-host _peer-port)
(tcp-addresses listener #t))
(define stall-server
(thread
(lambda ()
;; Serve two stalled transfers so the test can prove a retry starts
;; after the first cancellation.
(for ([attempt (in-range 2)])
(define-values (cin cout) (tcp-accept listener))
(let drain ()
(define line (read-line cin 'return-linefeed))
(unless (or (eof-object? line) (equal? line "")) (drain)))
(display "HTTP/1.1 200 OK\r\nContent-Length: 1000\r\nConnection: close\r\n\r\n" cout)
(write-bytes (make-bytes 10 5) cout)
(flush-output cout)
;; Cancellation closes the client side; EOF releases this stalled
;; server so it can accept the retry.
(read-byte cin)
(close-input-port cin)
(close-output-port cout)))))
(define manifest
(update-manifest
app-identifier "9.9.9" 7 'stable
"2026-10-09T00:00:00Z" "0.0.0" #f #t 100
(list (update-artifact (platform-symbol) (architecture-symbol)
(format "http://127.0.0.1:~a/taskly-9.9.9~a"
cancel-port (installer-extension))
(make-string 64 #\0)
1000
'zip '() #f))))
(define candidate
(update-candidate manifest (first (update-manifest-artifacts manifest))))
(define temporary
(path-add-extension (destination-path data-dir candidate) #".partial"))
(parameterize ([current-taskly-home
(make-temporary-file "taskly-updater-cancel-home-~a" 'directory)])
(reset-update-state!)
(set-update-candidate! candidate)
(start-download! data-dir)
(wait-until (lambda () (file-exists? temporary)) "first partial")
(check-true (cancel-download!))
(check-equal? (hash-ref (update-state-snapshot) 'phase) "idle")
(check-false (hash-ref (update-state-snapshot) 'message))
(check-false (file-exists? temporary))

;; Candidate remains selected, so retry does not require another check.
;; The fixture server deliberately stays occupied by the first request;
;; for retry semantics we only need to prove a fresh worker can start
;; and be cancelled again (not that this fake server serves request #2).
(check-not-exn (lambda () (start-download! data-dir)))
(wait-until
(lambda ()
(define retry-worker (update-worker-thread))
(and retry-worker (thread-running? retry-worker)))
"the retry worker to start")
(check-true (cancel-download!))
(check-equal? (hash-ref (update-state-snapshot) 'phase) "idle")
(check-false (file-exists? temporary))
(reset-update-state!))
(kill-thread stall-server)
(tcp-close listener))
5 changes: 4 additions & 1 deletion shared/spec/UPDATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@
pattern — Ed25519 manifest verify and sha256 artifact verify live in
the backend; hosts never implement crypto). Hosts poll `update_state`
(phase: idle | checking | downloading | downloaded | error) and show
progress.
progress. For schema compatibility, `start_download` is a start/cancel
toggle while a transfer is active: a second call interrupts the worker,
removes partial data, returns to `idle`, and preserves the candidate
so the user can retry.
- **Download must be bounded and cancellable**: a stalled transfer
surfaces a retryable error after a finite timeout, and the progress UI
offers Cancel — cancelling stops the transfer and returns to the
Expand Down
46 changes: 46 additions & 0 deletions windows/MainWindow.Update.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ void MainWindow::StartDownload() {
// The full-width notice above the body — the status bar line alone was
// too easy to miss during a multi-minute download (0.2.1 feedback).
UpdateDownloadBar().Title(t("updateDownloading"));
UpdateDownloadCancel().Content(winrt::box_value(t("dialogCancel")));
UpdateDownloadProgress().IsIndeterminate(true);
UpdateDownloadBar().IsOpen(true);

Expand Down Expand Up @@ -379,6 +380,51 @@ void MainWindow::StartDownload() {
update_timer_.Start();
}

void MainWindow::OnCancelUpdateDownload(
winrt::Windows::Foundation::IInspectable const&,
Microsoft::UI::Xaml::RoutedEventArgs const&) {
if (!update_downloading_ || backend_ == nullptr || !backend_->running()) {
return;
}
auto const dispatcher = DispatcherQueue();
auto const weak = get_weak();
auto backend = backend_;
try {
rivet_app::API api(*backend);
// start_download is a start/cancel toggle while a transfer is active.
(void)api.start_download_async(
[dispatcher, weak](rivet_app::Result<void> result) {
std::string failure;
try {
result.get();
} catch (std::exception const& e) {
failure = e.what();
}
dispatcher.TryEnqueue(
[weak, failure = std::move(failure)] {
if (auto window = weak.get()) {
if (!failure.empty()) {
window->FailDownload(failure);
return;
}
if (window->update_timer_) {
window->update_timer_.Stop();
window->update_timer_ = nullptr;
}
window->update_downloading_ = false;
window->UpdateDownloadBar().IsOpen(false);
window->SetStatus(L"");
// Re-check immediately so cancellation returns to the
// available-update offer with the candidate still usable.
window->RunUpdateCheck(/*silent=*/false);
}
});
});
} catch (std::exception const& e) {
FailDownload(e.what());
}
}

void MainWindow::PollUpdateState(
std::shared_ptr<rivet::windows::Backend> const& backend) {
auto const dispatcher = DispatcherQueue();
Expand Down
3 changes: 3 additions & 0 deletions windows/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@
IsOpen="False"
IsClosable="False"
Severity="Informational">
<InfoBar.ActionButton>
<Button x:Name="UpdateDownloadCancel" Click="OnCancelUpdateDownload" />
</InfoBar.ActionButton>
<InfoBar.Content>
<ProgressBar
x:Name="UpdateDownloadProgress"
Expand Down
6 changes: 6 additions & 0 deletions windows/MainWindow.xaml.h
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ struct MainWindow : MainWindowT<MainWindow> {
void OnNewTaskKeyDown(winrt::Windows::Foundation::IInspectable const&,
Microsoft::UI::Xaml::Input::KeyRoutedEventArgs const&);

// Update download action wired from XAML; event handlers must be public
// because generated Connect() binds them through MainWindowT.
void OnCancelUpdateDownload(
winrt::Windows::Foundation::IInspectable const&,
Microsoft::UI::Xaml::RoutedEventArgs const&);

private:
enum class ViewKind { Today, Planned, All, Completed, List };

Expand Down
Loading