diff --git a/linux/src/main.cpp b/linux/src/main.cpp index 98caf62..ea90512 100644 --- a/linux/src/main.cpp +++ b/linux/src/main.cpp @@ -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) {}); + } stop_update_poll(); g_update.progress_dialog = nullptr; g_update.progress_bar = nullptr; diff --git a/racket/taskly/backend.rkt b/racket/taskly/backend.rkt index fba0d57..3066f58 100644 --- a/racket/taskly/backend.rkt +++ b/racket/taskly/backend.rkt @@ -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) diff --git a/racket/taskly/updater.rkt b/racket/taskly/updater.rkt index 0657a8c..ca18e33 100644 --- a/racket/taskly/updater.rkt +++ b/racket/taskly/updater.rkt @@ -73,6 +73,7 @@ set-update-error! set-update-candidate! update-worker-thread + cancel-download! perform-check! start-download! rollout-bucket) @@ -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 @@ -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 @@ -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 @@ -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)) @@ -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) @@ -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?) diff --git a/racket/tests/updater-test.rkt b/racket/tests/updater-test.rkt index fb86cea..4154894 100644 --- a/racket/tests/updater-test.rkt +++ b/racket/tests/updater-test.rkt @@ -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)) diff --git a/shared/spec/UPDATE.md b/shared/spec/UPDATE.md index 01e94c1..7806d3a 100644 --- a/shared/spec/UPDATE.md +++ b/shared/spec/UPDATE.md @@ -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 diff --git a/windows/MainWindow.Update.cpp b/windows/MainWindow.Update.cpp index 0392066..ce851a1 100644 --- a/windows/MainWindow.Update.cpp +++ b/windows/MainWindow.Update.cpp @@ -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); @@ -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 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 const& backend) { auto const dispatcher = DispatcherQueue(); diff --git a/windows/MainWindow.xaml b/windows/MainWindow.xaml index ffefa75..eca5d31 100644 --- a/windows/MainWindow.xaml +++ b/windows/MainWindow.xaml @@ -90,6 +90,9 @@ IsOpen="False" IsClosable="False" Severity="Informational"> + +