From c3e0de5db1605dec563680200d568c641e5bf23b Mon Sep 17 00:00:00 2001 From: jrtxio Date: Tue, 13 Oct 2026 00:33:38 +0800 Subject: [PATCH 1/5] distribution: download-update learns progress reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit copy-limited! (now exported) takes #:on-progress and #:progress-every — byte-count callbacks throttled so fast links do not flood UI pollers — and download-update threads them through, reporting against the signed artifact size so a progress bar reads real percentages. Every tray-era app (fulcrum, movebit) hand-rolled a byte-for-byte copy of this function just to drive a progress bar, because the first-party downloader was silent. The duplicated copies had already drifted on the redirect-following fix (#153) once. --- rivet/distribution/updater.rkt | 29 +++++++++++++++++++++++------ tests/distribution.rkt | 24 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/rivet/distribution/updater.rkt b/rivet/distribution/updater.rkt index 70580f9..4ee3490 100644 --- a/rivet/distribution/updater.rkt +++ b/rivet/distribution/updater.rkt @@ -17,6 +17,7 @@ fetch-update-manifest select-update download-update + copy-limited! verify-update-artifact! make-install-plan execute-install-plan! @@ -35,18 +36,28 @@ '(prepared installing installed restarting restarted checking healthy committing committed rollback-started rolled-back rollback-failed)) -(define (copy-limited! in out limit) +(define (copy-limited! in out limit + #:on-progress [on-progress void] + #:progress-every [progress-every 1]) + ;; progress-every: call on-progress at most once per this many bytes, so + ;; UI pollers are not flooded on fast links. The first and the final call + ;; always fire (a progress bar that skips 0% and 100% looks broken). (define buffer (make-bytes 65536)) - (let loop ([total 0]) + (let loop ([total 0] [last-reported 0]) (define count (read-bytes-avail! buffer in)) (cond - [(eof-object? count) total] + [(eof-object? count) + (unless (= last-reported total) (on-progress total)) + total] [else (define next (+ total count)) (when (> next limit) (error 'download-update "update exceeds configured download limit")) (write-bytes buffer out 0 count) - (loop next)]))) + (when (>= (- next last-reported) progress-every) + (on-progress next) + (set! last-reported next)) + (loop next last-reported)]))) ;; GitHub release assets — the dominant update origin — answer with a 302 ;; to their CDN, so every fetch must follow redirections or the updater @@ -125,7 +136,8 @@ "actual" actual)) path) -(define (download-update config candidate destination) +(define (download-update config candidate destination + #:on-progress [on-progress void]) (define artifact (update-candidate-artifact candidate)) (define maximum (updater-config-maximum-download-bytes config)) (when (> (update-artifact-size artifact) maximum) @@ -147,7 +159,12 @@ (call-with-output-file temporary #:exists 'truncate/replace #:mode 'binary - (lambda (out) (copy-limited! in out maximum)))) + (lambda (out) + (copy-limited! in out maximum + #:on-progress on-progress + #:progress-every + (max 1 (quotient (update-artifact-size artifact) + 100)))))) (lambda () (close-input-port in))) (verify-update-artifact! candidate temporary) (rename-file-or-directory temporary destination #t) diff --git a/tests/distribution.rkt b/tests/distribution.rkt index d558771..e1122fd 100644 --- a/tests/distribution.rkt +++ b/tests/distribution.rkt @@ -316,3 +316,27 @@ (check-equal? commits 2) (check-false (file-exists? journal-path))) (lambda () (delete-directory/files commit-root))) + +;; download-update progress: copy-limited! streams the payload, enforces the +;; byte limit, and reports progress without flooding the callback. +(let* ([payload (apply bytes (for/list ([i (in-range 300)]) (modulo i 256)))] + [in (open-input-bytes payload)] + [out (open-output-bytes)] + [reports '()]) + (define total + (copy-limited! in out (* 1024 1024) + #:on-progress (lambda (n) (set! reports (cons n reports))) + #:progress-every 100)) + (check-equal? total 300) + (check-equal? (get-output-bytes out) payload) + (check-equal? (car reports) 300) ; the final call fires + (check-true (<= (length reports) 5)) ; throttled, not one per chunk + (check-true (equal? reports (reverse reports)))) ; monotonically increasing + +(let ([in (open-input-bytes (make-bytes 10))] + [out (open-output-bytes)]) + ;; The limit is enforced before the offending chunk is written. + (check-exn exn:fail? + (lambda () + (copy-limited! in out 5))) + (check-equal? (bytes-length (get-output-bytes out)) 0)) From 9076491945843717cb4325c73077f95799eee7bb Mon Sep 17 00:00:00 2001 From: jrtxio Date: Tue, 13 Oct 2026 00:34:01 +0800 Subject: [PATCH 2/5] changelog: download-update progress reporting --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96bb705..a8a61b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- `download-update` reports download progress: an optional `#:on-progress` + callback receives the byte count as the payload streams (throttled via + `#:progress-every`, defaulting to one report per percent of the signed + artifact size, with the final count always firing), and `copy-limited!` + is exported as the reusable throttled-copy primitive. Both feed the + in-app updaters that were hand-rolling silent copies just to drive + progress bars (fulcrum, movebit). + - Add a Windows `rivet::system::TrayController` that owns tray window subclassing, localized native menu presentation, command dispatch, and automatic `TaskbarCreated` recovery. The reference Taskboard now exercises From 858f8903a8511c4ba88302ed201a72fdf39ab6d8 Mon Sep 17 00:00:00 2001 From: "ren.ji" Date: Tue, 13 Oct 2026 00:40:51 +0800 Subject: [PATCH 3/5] Harden download progress contract --- CHANGELOG.md | 10 ++++------ rivet/distribution/updater.rkt | 27 ++++++++++++++++++------- rivet/scribblings/rivet.scrbl | 10 ++++++++-- tests/distribution.rkt | 36 ++++++++++++++++++++++------------ 4 files changed, 55 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8a61b9..b65beaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,10 @@ ## Unreleased - `download-update` reports download progress: an optional `#:on-progress` - callback receives the byte count as the payload streams (throttled via - `#:progress-every`, defaulting to one report per percent of the signed - artifact size, with the final count always firing), and `copy-limited!` - is exported as the reusable throttled-copy primitive. Both feed the - in-app updaters that were hand-rolling silent copies just to drive - progress bars (fulcrum, movebit). + callback receives cumulative bytes from zero through the final downloaded + size. Intermediate reports are throttled to about one per percent of the + signed artifact size, allowing in-app updaters to drive progress bars + without copying Rivet's bounded, redirect-aware downloader. - Add a Windows `rivet::system::TrayController` that owns tray window subclassing, localized native menu presentation, command dispatch, and diff --git a/rivet/distribution/updater.rkt b/rivet/distribution/updater.rkt index 4ee3490..1208ff9 100644 --- a/rivet/distribution/updater.rkt +++ b/rivet/distribution/updater.rkt @@ -17,7 +17,6 @@ fetch-update-manifest select-update download-update - copy-limited! verify-update-artifact! make-install-plan execute-install-plan! @@ -37,12 +36,14 @@ committing committed rollback-started rolled-back rollback-failed)) (define (copy-limited! in out limit + #:who [who 'copy-limited!] #:on-progress [on-progress void] #:progress-every [progress-every 1]) - ;; progress-every: call on-progress at most once per this many bytes, so - ;; UI pollers are not flooded on fast links. The first and the final call - ;; always fire (a progress bar that skips 0% and 100% looks broken). + ;; Report the initial zero and the final byte count. Intermediate reports are + ;; separated by at least progress-every bytes so fast links do not flood UI + ;; pollers. This helper stays private; download-update is the public contract. (define buffer (make-bytes 65536)) + (on-progress 0) (let loop ([total 0] [last-reported 0]) (define count (read-bytes-avail! buffer in)) (cond @@ -52,13 +53,16 @@ [else (define next (+ total count)) (when (> next limit) - (error 'download-update "update exceeds configured download limit")) + (error who "response exceeds permitted byte limit")) (write-bytes buffer out 0 count) (when (>= (- next last-reported) progress-every) (on-progress next) (set! last-reported next)) (loop next last-reported)]))) +(module+ test + (provide copy-limited!)) + ;; GitHub release assets — the dominant update origin — answer with a 302 ;; to their CDN, so every fetch must follow redirections or the updater ;; verifies an empty body and fails. @@ -77,7 +81,7 @@ void (lambda () (define out (open-output-bytes)) - (copy-limited! in out maximum-bytes) + (copy-limited! in out maximum-bytes #:who 'fetch-update-manifest) (verify-signed-manifest (open-input-bytes (get-output-bytes out)) public-key #:key-id key-id)) @@ -138,6 +142,11 @@ (define (download-update config candidate destination #:on-progress [on-progress void]) + (unless (and (procedure? on-progress) + (procedure-arity-includes? on-progress 1)) + (raise-argument-error 'download-update + "procedure accepting one argument" + on-progress)) (define artifact (update-candidate-artifact candidate)) (define maximum (updater-config-maximum-download-bytes config)) (when (> (update-artifact-size artifact) maximum) @@ -160,7 +169,11 @@ #:exists 'truncate/replace #:mode 'binary (lambda (out) - (copy-limited! in out maximum + ;; Bound the stream to the signed size, not merely the looser + ;; configured ceiling, so progress never exceeds 100% before a + ;; malformed response is rejected. + (copy-limited! in out (update-artifact-size artifact) + #:who 'download-update #:on-progress on-progress #:progress-every (max 1 (quotient (update-artifact-size artifact) diff --git a/rivet/scribblings/rivet.scrbl b/rivet/scribblings/rivet.scrbl index 9a4cc90..16c4db6 100644 --- a/rivet/scribblings/rivet.scrbl +++ b/rivet/scribblings/rivet.scrbl @@ -441,10 +441,16 @@ Applies identity, channel, version, minimum-version, rollout, platform, and architecture policy.} @defproc[(download-update [config updater-config?] [candidate update-candidate?] - [destination path-string?]) + [destination path-string?] + [#:on-progress on-progress procedure? void]) path?]{ Downloads to a partial file, enforces the signed size and configured bound, -verifies SHA-256, and atomically moves the verified artifact into place.} +verifies SHA-256, and atomically moves the verified artifact into place. +@racket[on-progress] receives cumulative downloaded bytes, starting at zero +and ending at the downloaded size. Intermediate reports are throttled to about +one per percent of the signed artifact size. The callback runs synchronously +on the downloading thread and should quickly dispatch or record UI work. A +final byte count reports transfer progress, not successful verification.} @defproc[(execute-install-plan! [plan install-plan?] [#:health-check health-check (-> any/c) (lambda () #t)] [#:commit commit (-> any/c) void] diff --git a/tests/distribution.rkt b/tests/distribution.rkt index e1122fd..1a62ffd 100644 --- a/tests/distribution.rkt +++ b/tests/distribution.rkt @@ -6,7 +6,9 @@ rackunit racket/file racket/port - "../rivet/distribution.rkt") + "../rivet/distribution.rkt" + (rename-in (submod "../rivet/distribution/updater.rkt" test) + [copy-limited! copy-limited!/test])) (use-all-factories!) @@ -64,6 +66,13 @@ (select-update linux-config linux-manifest #:install-kind 'unknown))) +(check-exn exn:fail:contract? + (lambda () + (download-update linux-config + (update-candidate linux-manifest linux-zip) + "unused" + #:on-progress (lambda () (void))))) + (when private-key (define public-key (datum->pk-key (pk-key->datum private-key 'SubjectPublicKeyInfo) @@ -317,26 +326,27 @@ (check-false (file-exists? journal-path))) (lambda () (delete-directory/files commit-root))) -;; download-update progress: copy-limited! streams the payload, enforces the -;; byte limit, and reports progress without flooding the callback. -(let* ([payload (apply bytes (for/list ([i (in-range 300)]) (modulo i 256)))] +;; download-update's private copy path streams the payload, enforces the byte +;; limit, and reports a useful zero-to-final sequence without flooding the +;; callback. A payload larger than the copy buffer makes the cadence assertion +;; exercise multiple reads instead of passing vacuously on one read. +(let* ([payload (apply bytes (for/list ([i (in-range 200000)]) (modulo i 256)))] [in (open-input-bytes payload)] [out (open-output-bytes)] [reports '()]) (define total - (copy-limited! in out (* 1024 1024) - #:on-progress (lambda (n) (set! reports (cons n reports))) - #:progress-every 100)) - (check-equal? total 300) + (copy-limited!/test in out (* 1024 1024) + #:on-progress + (lambda (n) (set! reports (cons n reports))) + #:progress-every 100000)) + (check-equal? total 200000) (check-equal? (get-output-bytes out) payload) - (check-equal? (car reports) 300) ; the final call fires - (check-true (<= (length reports) 5)) ; throttled, not one per chunk - (check-true (equal? reports (reverse reports)))) ; monotonically increasing + (check-equal? (reverse reports) '(0 131072 200000))) (let ([in (open-input-bytes (make-bytes 10))] [out (open-output-bytes)]) ;; The limit is enforced before the offending chunk is written. - (check-exn exn:fail? + (check-exn #rx"permitted byte limit" (lambda () - (copy-limited! in out 5))) + (copy-limited!/test in out 5))) (check-equal? (bytes-length (get-output-bytes out)) 0)) From c8a021a93ee7b970ab34f4bcc672007dac480919 Mon Sep 17 00:00:00 2001 From: "ren.ji" Date: Tue, 13 Oct 2026 00:56:15 +0800 Subject: [PATCH 4/5] Clean partial downloads on cancellation --- CHANGELOG.md | 4 ++- rivet/distribution/updater.rkt | 65 +++++++++++++++++++--------------- tests/distribution.rkt | 20 ++++++++++- 3 files changed, 58 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b65beaa..24a17bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ callback receives cumulative bytes from zero through the final downloaded size. Intermediate reports are throttled to about one per percent of the signed artifact size, allowing in-app updaters to drive progress bars - without copying Rivet's bounded, redirect-aware downloader. + without copying Rivet's bounded, redirect-aware downloader. Interrupted or + cancelled downloads remove their partial file as reliably as ordinary + failures. - Add a Windows `rivet::system::TrayController` that owns tray window subclassing, localized native menu presentation, command dispatch, and diff --git a/rivet/distribution/updater.rkt b/rivet/distribution/updater.rkt index 1208ff9..e7bfa20 100644 --- a/rivet/distribution/updater.rkt +++ b/rivet/distribution/updater.rkt @@ -60,8 +60,16 @@ (set! last-reported next)) (loop next last-reported)]))) +(define (call-with-partial-cleanup temporary thunk) + (with-handlers ([exn? + (lambda (error) + (when (file-exists? temporary) (delete-file temporary)) + (raise error))]) + (thunk))) + (module+ test - (provide copy-limited!)) + (provide copy-limited! + call-with-partial-cleanup)) ;; GitHub release assets — the dominant update origin — answer with a 302 ;; to their CDN, so every fetch must follow redirections or the updater @@ -154,34 +162,33 @@ (make-parent-directory* destination) (define temporary (path-add-extension destination #".partial")) (when (file-exists? temporary) (delete-file temporary)) - (with-handlers ([exn:fail? - (lambda (e) - (when (file-exists? temporary) (delete-file temporary)) - (raise e))]) - (define in - (get-pure-port (string->url (update-artifact-url artifact)) - '("User-Agent: Rivet-Updater/1") - #:redirections update-fetch-redirections)) - (dynamic-wind - void - (lambda () - (call-with-output-file temporary - #:exists 'truncate/replace - #:mode 'binary - (lambda (out) - ;; Bound the stream to the signed size, not merely the looser - ;; configured ceiling, so progress never exceeds 100% before a - ;; malformed response is rejected. - (copy-limited! in out (update-artifact-size artifact) - #:who 'download-update - #:on-progress on-progress - #:progress-every - (max 1 (quotient (update-artifact-size artifact) - 100)))))) - (lambda () (close-input-port in))) - (verify-update-artifact! candidate temporary) - (rename-file-or-directory temporary destination #t) - destination)) + (call-with-partial-cleanup + temporary + (lambda () + (define in + (get-pure-port (string->url (update-artifact-url artifact)) + '("User-Agent: Rivet-Updater/1") + #:redirections update-fetch-redirections)) + (dynamic-wind + void + (lambda () + (call-with-output-file temporary + #:exists 'truncate/replace + #:mode 'binary + (lambda (out) + ;; Bound the stream to the signed size, not merely the looser + ;; configured ceiling, so progress never exceeds 100% before a + ;; malformed response is rejected. + (copy-limited! in out (update-artifact-size artifact) + #:who 'download-update + #:on-progress on-progress + #:progress-every + (max 1 (quotient (update-artifact-size artifact) + 100)))))) + (lambda () (close-input-port in))) + (verify-update-artifact! candidate temporary) + (rename-file-or-directory temporary destination #t) + destination))) (define (make-install-plan candidate downloaded-path #:backup-path [backup-path #f] diff --git a/tests/distribution.rkt b/tests/distribution.rkt index 1a62ffd..e89d547 100644 --- a/tests/distribution.rkt +++ b/tests/distribution.rkt @@ -8,7 +8,9 @@ racket/port "../rivet/distribution.rkt" (rename-in (submod "../rivet/distribution/updater.rkt" test) - [copy-limited! copy-limited!/test])) + [copy-limited! copy-limited!/test] + [call-with-partial-cleanup + call-with-partial-cleanup/test])) (use-all-factories!) @@ -350,3 +352,19 @@ (lambda () (copy-limited!/test in out 5))) (check-equal? (bytes-length (get-output-bytes out)) 0)) + +;; Cancellation uses exn:break rather than exn:fail. It must still remove the +;; partial download before the worker publishes its terminal state. +(define interrupted-partial + (make-temporary-file "rivet-interrupted-download-~a.partial")) +(check-exn + exn:break? + (lambda () + (call-with-partial-cleanup/test + interrupted-partial + (lambda () + (let/ec escape + (raise (exn:break "cancelled" + (current-continuation-marks) + escape))))))) +(check-false (file-exists? interrupted-partial)) From 4d7f4c952db35f406aae068953836c2fd652df3e Mon Sep 17 00:00:00 2001 From: "ren.ji" Date: Tue, 13 Oct 2026 00:58:15 +0800 Subject: [PATCH 5/5] Expose bounded progress cadence --- CHANGELOG.md | 8 ++++---- rivet/distribution/updater.rkt | 15 ++++++++++++--- rivet/scribblings/rivet.scrbl | 9 +++++++-- tests/distribution.rkt | 6 ++++++ 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24a17bd..272653c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,10 @@ - `download-update` reports download progress: an optional `#:on-progress` callback receives cumulative bytes from zero through the final downloaded size. Intermediate reports are throttled to about one per percent of the - signed artifact size, allowing in-app updaters to drive progress bars - without copying Rivet's bounded, redirect-aware downloader. Interrupted or - cancelled downloads remove their partial file as reliably as ordinary - failures. + signed artifact size by default, with an optional byte cadence for activity + watchdogs. In-app updaters can drive progress bars without copying Rivet's + bounded, redirect-aware downloader. Interrupted or cancelled downloads + remove their partial file as reliably as ordinary failures. - Add a Windows `rivet::system::TrayController` that owns tray window subclassing, localized native menu presentation, command dispatch, and diff --git a/rivet/distribution/updater.rkt b/rivet/distribution/updater.rkt index e7bfa20..949d37a 100644 --- a/rivet/distribution/updater.rkt +++ b/rivet/distribution/updater.rkt @@ -149,12 +149,18 @@ path) (define (download-update config candidate destination - #:on-progress [on-progress void]) + #:on-progress [on-progress void] + #:progress-every [configured-progress-every #f]) (unless (and (procedure? on-progress) (procedure-arity-includes? on-progress 1)) (raise-argument-error 'download-update "procedure accepting one argument" on-progress)) + (unless (or (not configured-progress-every) + (exact-positive-integer? configured-progress-every)) + (raise-argument-error 'download-update + "(or/c #f exact-positive-integer?)" + configured-progress-every)) (define artifact (update-candidate-artifact candidate)) (define maximum (updater-config-maximum-download-bytes config)) (when (> (update-artifact-size artifact) maximum) @@ -183,8 +189,11 @@ #:who 'download-update #:on-progress on-progress #:progress-every - (max 1 (quotient (update-artifact-size artifact) - 100)))))) + (or configured-progress-every + (max 1 + (quotient + (update-artifact-size artifact) + 100))))))) (lambda () (close-input-port in))) (verify-update-artifact! candidate temporary) (rename-file-or-directory temporary destination #t) diff --git a/rivet/scribblings/rivet.scrbl b/rivet/scribblings/rivet.scrbl index 16c4db6..3f99c4a 100644 --- a/rivet/scribblings/rivet.scrbl +++ b/rivet/scribblings/rivet.scrbl @@ -442,7 +442,9 @@ architecture policy.} @defproc[(download-update [config updater-config?] [candidate update-candidate?] [destination path-string?] - [#:on-progress on-progress procedure? void]) + [#:on-progress on-progress procedure? void] + [#:progress-every progress-every + (or/c #f exact-positive-integer?) #f]) path?]{ Downloads to a partial file, enforces the signed size and configured bound, verifies SHA-256, and atomically moves the verified artifact into place. @@ -450,7 +452,10 @@ verifies SHA-256, and atomically moves the verified artifact into place. and ending at the downloaded size. Intermediate reports are throttled to about one per percent of the signed artifact size. The callback runs synchronously on the downloading thread and should quickly dispatch or record UI work. A -final byte count reports transfer progress, not successful verification.} +final byte count reports transfer progress, not successful verification. +Pass @racket[progress-every] to select a byte cadence; @racket[#f] uses the +one-percent default. Small cadences are appropriate for an activity watchdog, +not direct UI rendering.} @defproc[(execute-install-plan! [plan install-plan?] [#:health-check health-check (-> any/c) (lambda () #t)] [#:commit commit (-> any/c) void] diff --git a/tests/distribution.rkt b/tests/distribution.rkt index e89d547..9de5a36 100644 --- a/tests/distribution.rkt +++ b/tests/distribution.rkt @@ -74,6 +74,12 @@ (update-candidate linux-manifest linux-zip) "unused" #:on-progress (lambda () (void))))) +(check-exn exn:fail:contract? + (lambda () + (download-update linux-config + (update-candidate linux-manifest linux-zip) + "unused" + #:progress-every 0))) (when private-key (define public-key