diff --git a/CHANGELOG.md b/CHANGELOG.md index 58e810d..85af9e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and payloads also reject trailing JSON values and fields outside their versioned schemas. +- Add an opaque managed updater for the check/select/download lifecycle shared + by downstream applications. It copies mutable configuration inputs and + provides deeply immutable, synchronized JSON-safe state, + one background worker, progress, explicit cancellation, monotonic manifest, + stall, and total deadlines, deterministic destinations, and terminal cleanup + while leaving installation and restart with the native host. - `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 diff --git a/docs/release-and-updates.md b/docs/release-and-updates.md index f7a2132..fbcf577 100644 --- a/docs/release-and-updates.md +++ b/docs/release-and-updates.md @@ -96,6 +96,26 @@ Embed only `update-public.der` (or its bytes) in the native host. Key rotation i Require `rivet/distribution`. `fetch-update-manifest` verifies before parsing, `select-update` applies channel/version/rollout/platform policy, and `download-update` enforces limits and hashes. On Linux, pass `(current-install-kind)` as `#:install-kind`: AppImage and portable installs only select their matching payload, while deb/rpm installations return `package-manager` and do not consume the AppImage feed. `prepare-platform-installation` turns a verified portable ZIP or Linux AppImage into Rivet's first-party atomic replacement; pass its result to `execute-platform-installation!`. The application supplies restart and health callbacks because it owns its process model and readiness signal. On Windows and macOS the default staged-payload verifier requires Authenticode or a deep strict code signature; development builds may inject an explicit verifier. +Applications that need an in-app progress UI should use `make-updater` instead +of rebuilding this orchestration. `updater-check!` performs the authenticated +check and selection, `updater-download!` starts one bounded background worker, +and `updater-state` returns an immutable JSON-safe polling snapshot. +`updater-cancel!` interrupts an active transfer. The managed worker uses +monotonic no-progress and total deadlines, cleans partial files on cancellation +or timeout, and rejects concurrent check/download operations. It deliberately +stops at a verified downloaded artifact: installation consent, elevation, +restart, and health policy remain with the native host and the platform +installation APIs below. + +Persist one rollout bucket from 0 through 99 per installation and pass it to +`make-updater`; generating a fresh value for every check would make staged +rollouts unstable. The managed API accepts only Rivet's supported Windows, +macOS, and Linux x64/ARM64 targets and fails closed on an unknown architecture. +Its polling phase vocabulary is stable: `idle`, `checking`, `available`, +`up-to-date`, `downloading`, `downloaded`, `cancelled`, and `error`. A failed +or cancelled transfer keeps the authenticated candidate for an explicit retry; +a new check clears it before fetching current policy. + Pass `#:journal-path` to atomically persist every destructive phase. On the next start, reconstruct the same installation and call `recover-platform-installation!`; it accepts only the exact same signed candidate and absolute download/target/backup paths. An interruption before health restores the prior payload. An interruption during the idempotent commit repeats backup cleanup instead of rolling a healthy application back. Portable execution and recovery share an exclusive lock derived from the diff --git a/rivet/distribution.rkt b/rivet/distribution.rkt index 7e6d42c..f818f84 100644 --- a/rivet/distribution.rkt +++ b/rivet/distribution.rkt @@ -1,12 +1,14 @@ #lang racket/base (require "distribution/crypto.rkt" + "distribution/managed-updater.rkt" "distribution/manifest.rkt" "distribution/platform-adapter.rkt" "distribution/updater.rkt" "distribution/version.rkt") (provide (all-from-out "distribution/crypto.rkt" + "distribution/managed-updater.rkt" "distribution/manifest.rkt" "distribution/platform-adapter.rkt" "distribution/updater.rkt" diff --git a/rivet/distribution/managed-updater.rkt b/rivet/distribution/managed-updater.rkt new file mode 100644 index 0000000..18ab96f --- /dev/null +++ b/rivet/distribution/managed-updater.rkt @@ -0,0 +1,465 @@ +#lang racket/base + +;; One bounded check/download state machine for native applications. The +;; platform host still owns installation and restart; this module owns the +;; authenticated network work and its observable lifecycle. + +(require racket/async-channel + racket/file + racket/match + racket/path + racket/string + "crypto.rkt" + "manifest.rkt" + "platform-adapter.rkt" + "updater.rkt" + "version.rkt") + +(provide updater? + make-updater + updater-state + updater-check! + updater-download! + updater-cancel! + updater-wait + current-update-architecture) + +(struct updater + (application-id current-version channel platform architecture install-kind + public-key resolved-key key-id manifest-url data-directory + rollout-bucket maximum-manifest-bytes maximum-download-bytes + check-timeout stall-timeout total-timeout + fetch-manifest download + lock snapshot candidate operation worker cancel-reason) + #:mutable) + +(define current-managed-fetch-manifest (make-parameter fetch-update-manifest)) +(define current-managed-download (make-parameter download-update)) + +(define (update-architecture-for machine) + (case machine + [(x86_64 amd64) 'x64] + [(aarch64 arm64) 'arm64] + [else + (raise-arguments-error 'current-update-architecture + "unsupported update architecture" + "architecture" machine)])) + +(define (current-update-architecture) + (update-architecture-for (system-type 'arch))) + +(define (positive-real? value) + (and (real? value) (positive? value))) + +(define (safe-component value) + (define cleaned (regexp-replace* #px"[^A-Za-z0-9._-]+" value "_")) + (if (string=? cleaned "") "application" cleaned)) + +(define (immutable-json-value value) + (cond + [(hash? value) + (for/hasheq ([(key item) (in-hash value)]) + (values key (immutable-json-value item)))] + [(list? value) (map immutable-json-value value)] + [(string? value) (string->immutable-string value)] + [else value])) + +(define (default-destination application-id data-directory candidate) + (define manifest (update-candidate-manifest candidate)) + (define artifact (update-candidate-artifact candidate)) + (build-path + data-directory + "updates" + (format "~a-~a-~a-~a.~a" + (safe-component application-id) + (update-manifest-version manifest) + (update-artifact-platform artifact) + (update-artifact-architecture artifact) + (update-artifact-installer artifact)))) + +(define (initial-state current-version) + (hasheq 'phase "idle" + 'percent 0 + 'message #f + 'downloadedPath #f + 'availableVersion #f + 'currentVersion (string->immutable-string current-version))) + +(define (make-updater #:application-id application-id + #:current-version current-version + #:channel [channel 'stable] + #:platform [platform (current-update-platform)] + #:architecture [architecture (current-update-architecture)] + #:install-kind [install-kind #f] + #:public-key public-key + #:key-id key-id + #:manifest-url manifest-url + #:data-directory data-directory + #:rollout-bucket rollout-bucket + #:maximum-manifest-bytes + [maximum-manifest-bytes (* 1024 1024)] + #:maximum-download-bytes + [maximum-download-bytes (* 512 1024 1024)] + #:check-timeout [check-timeout 30.0] + #:stall-timeout [stall-timeout 120.0] + #:total-timeout [total-timeout (* 30.0 60.0)]) + (unless (and (string? application-id) (not (string=? application-id ""))) + (raise-argument-error 'make-updater "non-empty string?" application-id)) + (unless (version? current-version) + (raise-argument-error 'make-updater "version?" current-version)) + (unless (valid-channel? channel) + (raise-argument-error 'make-updater "valid-channel?" channel)) + (unless (memq platform '(windows macos linux)) + (raise-argument-error + 'make-updater "(or/c 'windows 'macos 'linux)" platform)) + (unless (memq architecture '(x64 arm64)) + (raise-argument-error + 'make-updater "(or/c 'x64 'arm64)" architecture)) + (unless (or (not install-kind) + (memq install-kind '(portable appimage package-manager))) + (raise-argument-error + 'make-updater + "(or/c #f 'portable 'appimage 'package-manager)" + install-kind)) + (unless (and (string? key-id) (not (string=? key-id ""))) + (raise-argument-error 'make-updater "non-empty string?" key-id)) + (unless (and (string? manifest-url) + (regexp-match? #px"^https://" manifest-url)) + (raise-argument-error 'make-updater "HTTPS URL string" manifest-url)) + (unless (and (exact-integer? rollout-bucket) (<= 0 rollout-bucket 99)) + (raise-argument-error 'make-updater "integer from 0 through 99" + rollout-bucket)) + (for ([value (in-list (list maximum-manifest-bytes maximum-download-bytes))] + [name (in-list '(maximum-manifest-bytes maximum-download-bytes))]) + (unless (exact-positive-integer? value) + (raise-argument-error 'make-updater + (format "exact-positive-integer? for ~a" name) + value))) + (for ([value (in-list (list check-timeout stall-timeout total-timeout))] + [name (in-list '(check-timeout stall-timeout total-timeout))]) + (unless (positive-real? value) + (raise-argument-error 'make-updater + (format "positive real? for ~a" name) + value))) + (updater (string->immutable-string application-id) + (string->immutable-string current-version) + channel platform architecture + install-kind + (if (bytes? public-key) + (bytes->immutable-bytes public-key) + public-key) + #f + (string->immutable-string key-id) + (string->immutable-string manifest-url) + (path->complete-path data-directory) rollout-bucket + maximum-manifest-bytes maximum-download-bytes check-timeout + stall-timeout total-timeout + (current-managed-fetch-manifest) (current-managed-download) + (make-semaphore 1) (initial-state current-version) #f #f #f #f)) + +(define (with-updater-lock instance thunk) + (call-with-semaphore (updater-lock instance) thunk)) + +(define (state-set*/locked! instance . fields) + (set-updater-snapshot! + instance + (apply hash-set* + (updater-snapshot instance) + (for/list ([field (in-list fields)] [index (in-naturals)]) + (if (odd? index) (immutable-json-value field) field))))) + +(define (updater-state instance) + (unless (updater? instance) + (raise-argument-error 'updater-state "updater?" instance)) + (with-updater-lock instance (lambda () (updater-snapshot instance)))) + +(define (resolved-public-key instance) + (or (updater-resolved-key instance) + (let ([key (if (bytes? (updater-public-key instance)) + (bytes->ed25519-public-key (updater-public-key instance)) + (updater-public-key instance))]) + (set-updater-resolved-key! instance key) + key))) + +(define (call-with-timeout who seconds thunk) + (define result (make-async-channel)) + (define worker + (thread + (lambda () + (with-handlers ([exn? (lambda (error) + (async-channel-put result (cons 'error error)))]) + (async-channel-put result (cons 'value (thunk))))))) + (define (stop-worker!) + ;; The caller may itself be handling an asynchronous break. Disable + ;; further breaks until the owned child is gone so cancellation cannot + ;; strand a manifest request in the background. + (parameterize-break #f + (when (thread-running? worker) + (break-thread worker)) + (sync (thread-dead-evt worker)))) + (define outcome + (with-handlers ([exn? (lambda (error) + (stop-worker!) + (raise error))]) + (sync/timeout seconds result))) + (cond + [outcome + (match outcome + [(cons 'value value) value] + [(cons 'error error) (raise error)])] + [else + (stop-worker!) + (error who "operation exceeded its ~a-second deadline" seconds)])) + +(define (updater-config-for instance key) + (updater-config (updater-application-id instance) + (updater-current-version instance) + (updater-channel instance) + (updater-platform instance) + (updater-architecture instance) + key + (updater-key-id instance) + (updater-rollout-bucket instance) + (updater-maximum-download-bytes instance))) + +(define (available-result instance candidate) + (define manifest (update-candidate-manifest candidate)) + (define artifact (update-candidate-artifact candidate)) + (immutable-json-value + (hasheq 'status "available" + 'currentVersion (updater-current-version instance) + 'availableVersion (update-manifest-version manifest) + 'build (update-manifest-build manifest) + 'publishedAt (update-manifest-published-at manifest) + 'installer (symbol->string (update-artifact-installer artifact)) + 'sizeBytes (update-artifact-size artifact)))) + +(define (record-check-error! instance error) + (with-updater-lock + instance + (lambda () + (set-updater-operation! instance #f) + (state-set*/locked! instance + 'phase "error" + 'message (exn-message error))))) + +(define (updater-check! instance) + (unless (updater? instance) + (raise-argument-error 'updater-check! "updater?" instance)) + (with-updater-lock + instance + (lambda () + (when (updater-operation instance) + (error 'updater-check! "another updater operation is already running")) + (set-updater-operation! instance 'check) + (set-updater-candidate! instance #f) + (set-updater-snapshot! + instance + (hash-set* (initial-state (updater-current-version instance)) + 'phase "checking")))) + (with-handlers + ([exn:break? + (lambda (error) + (record-check-error! instance error) + (raise error))] + [exn:fail? + (lambda (error) + (record-check-error! instance error) + (immutable-json-value + (hasheq 'status "error" 'message (exn-message error))))]) + (define key (resolved-public-key instance)) + (define manifest + (call-with-timeout + 'updater-check! + (updater-check-timeout instance) + (lambda () + ((updater-fetch-manifest instance) + (updater-manifest-url instance) + key + #:key-id (updater-key-id instance) + #:maximum-bytes (updater-maximum-manifest-bytes instance))))) + (define config (updater-config-for instance key)) + (define candidate + (select-update config manifest + #:install-kind (updater-install-kind instance))) + (define result + (if candidate + (available-result instance candidate) + (immutable-json-value + (hasheq 'status "up-to-date" + 'currentVersion (updater-current-version instance))))) + (with-updater-lock + instance + (lambda () + (set-updater-operation! instance #f) + (set-updater-candidate! instance candidate) + (state-set*/locked! + instance + 'phase (if candidate "available" "up-to-date") + 'message #f + 'availableVersion + (and candidate + (update-manifest-version + (update-candidate-manifest candidate)))))) + result)) + +(define (candidate-destination instance candidate) + (path->complete-path + (default-destination (updater-application-id instance) + (updater-data-directory instance) + candidate))) + +(define (publish-download-failure! instance worker error) + (with-updater-lock + instance + (lambda () + (when (eq? worker (updater-worker instance)) + (define reason (updater-cancel-reason instance)) + (set-updater-worker! instance #f) + (set-updater-operation! instance #f) + (set-updater-cancel-reason! instance #f) + (state-set*/locked! + instance + 'phase (if (eq? reason 'cancelled) "cancelled" "error") + 'message + (case reason + [(cancelled) "update download cancelled"] + [(stalled) "update download stalled"] + [(timed-out) "update download timed out"] + [else (exn-message error)])))))) + +(define (start-watchdog! instance worker started last-activity) + (define interval + (max 0.01 (min 1.0 (/ (updater-stall-timeout instance) 2.0)))) + (thread + (lambda () + (let loop () + (unless (sync/timeout interval (thread-dead-evt worker)) + (define now (current-inexact-monotonic-milliseconds)) + (define stalled? + (> (- now (unbox last-activity)) + (* 1000.0 (updater-stall-timeout instance)))) + (define timed-out? + (> (- now started) + (* 1000.0 (updater-total-timeout instance)))) + (cond + [(or stalled? timed-out?) + (with-updater-lock + instance + (lambda () + (when (eq? worker (updater-worker instance)) + (unless (updater-cancel-reason instance) + (set-updater-cancel-reason! + instance + (if stalled? 'stalled 'timed-out)))))) + (break-thread worker)] + [else (loop)])))))) + +(define (updater-download! instance) + (unless (updater? instance) + (raise-argument-error 'updater-download! "updater?" instance)) + (define gate (make-semaphore 0)) + (define-values (last-activity worker) + (with-updater-lock + instance + (lambda () + (when (updater-operation instance) + (error 'updater-download! "another updater operation is already running")) + (define candidate (updater-candidate instance)) + (unless candidate + (error 'updater-download! "no update is available; run updater-check! first")) + (define key (resolved-public-key instance)) + (define config (updater-config-for instance key)) + (define destination (candidate-destination instance candidate)) + (define total + (update-artifact-size (update-candidate-artifact candidate))) + (define last-activity + (box (current-inexact-monotonic-milliseconds))) + (define worker #f) + (set! worker + (thread + (lambda () + (semaphore-wait gate) + (with-handlers + ([exn? + (lambda (error) + (publish-download-failure! instance worker error))]) + (define path + ((updater-download instance) + config candidate destination + #:on-progress + (lambda (downloaded) + (set-box! last-activity + (current-inexact-monotonic-milliseconds)) + (define percent + (if (zero? total) + 0 + (min 100 (quotient (* downloaded 100) total)))) + (with-updater-lock + instance + (lambda () + (when (eq? worker (updater-worker instance)) + (state-set*/locked! instance 'percent percent))))) + #:progress-every 1)) + (with-updater-lock + instance + (lambda () + (when (eq? worker (updater-worker instance)) + (set-updater-worker! instance #f) + (set-updater-operation! instance #f) + (set-updater-cancel-reason! instance #f) + (state-set*/locked! + instance + 'phase "downloaded" + 'percent 100 + 'message #f + 'downloadedPath (path->string path))))))))) + (set-updater-operation! instance 'download) + (set-updater-worker! instance worker) + (set-updater-cancel-reason! instance #f) + (state-set*/locked! instance + 'phase "downloading" + 'percent 0 + 'message #f + 'downloadedPath #f) + (values last-activity worker)))) + (define started (current-inexact-monotonic-milliseconds)) + (semaphore-post gate) + (start-watchdog! instance worker started last-activity) + (void)) + +(define (updater-cancel! instance) + (unless (updater? instance) + (raise-argument-error 'updater-cancel! "updater?" instance)) + (define worker + (with-updater-lock + instance + (lambda () + (define worker (updater-worker instance)) + (and worker + (thread-running? worker) + (begin + (set-updater-cancel-reason! instance 'cancelled) + worker))))) + (and worker (begin (break-thread worker) #t))) + +(define (updater-wait instance [timeout #f]) + (unless (updater? instance) + (raise-argument-error 'updater-wait "updater?" instance)) + (unless (or (not timeout) (and (real? timeout) (not (negative? timeout)))) + (raise-argument-error 'updater-wait "(or/c #f nonnegative-real?)" timeout)) + (define worker + (with-updater-lock instance (lambda () (updater-worker instance)))) + (cond + [(not worker) (updater-state instance)] + [(if timeout + (sync/timeout timeout (thread-dead-evt worker)) + (sync (thread-dead-evt worker))) + (updater-state instance)] + [else #f])) + +(module+ test-support + (provide current-managed-fetch-manifest + current-managed-download + default-destination + update-architecture-for)) diff --git a/rivet/scribblings/rivet.scrbl b/rivet/scribblings/rivet.scrbl index 01fe413..9eae130 100644 --- a/rivet/scribblings/rivet.scrbl +++ b/rivet/scribblings/rivet.scrbl @@ -464,6 +464,78 @@ 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.} + +@subsection{Managed Check and Download Lifecycle} + +@defproc[(current-update-architecture) (or/c 'x64 'arm64)]{ +Returns the architecture symbol used by Rivet release manifests.} + +@defproc[(make-updater + [#:application-id application-id string?] + [#:current-version current-version string?] + [#:channel channel (or/c 'stable 'beta 'dev) 'stable] + [#:platform platform + (or/c 'windows 'macos 'linux) (current-update-platform)] + [#:architecture architecture + (or/c 'x64 'arm64) (current-update-architecture)] + [#:install-kind install-kind + (or/c #f 'portable 'appimage 'package-manager) #f] + [#:public-key public-key any/c] + [#:key-id key-id string?] + [#:manifest-url manifest-url string?] + [#:data-directory data-directory path-string?] + [#:rollout-bucket rollout-bucket (integer-in 0 99)] + [#:maximum-manifest-bytes maximum-manifest-bytes + exact-positive-integer? (* 1024 1024)] + [#:maximum-download-bytes maximum-download-bytes + exact-positive-integer? (* 512 1024 1024)] + [#:check-timeout check-timeout (and/c real? positive?) 30.0] + [#:stall-timeout stall-timeout (and/c real? positive?) 120.0] + [#:total-timeout total-timeout + (and/c real? positive?) (* 30.0 60.0)]) + updater?]{ +Creates an opaque updater that centralizes signed-manifest checking, candidate +selection, bounded background download, progress, cancellation, and terminal +state. @racket[public-key] may be a parsed Ed25519 key or DER bytes, which are +decoded lazily during the first check. @racket[rollout-bucket] must be a stable +per-installation value from 0 through 99; do not choose a new random value on +each check. Unsupported platforms and architectures fail at construction +instead of silently selecting the wrong artifact. Installation and restart +remain owned by the native host or platform adapter.} + +@defproc[(updater-check! [instance updater?]) hash?]{ +Synchronously fetches and authenticates the manifest under +@racket[check-timeout], selects the matching candidate, and returns a JSON-safe +immutable result whose @racket['status] is @racket["available"], +@racket["up-to-date"], or @racket["error"].} + +@defproc[(updater-download! [instance updater?]) void?]{ +Starts the selected download on one background worker. A second check or +download is rejected while an operation is active. Byte activity is monitored +independently from UI percentage changes; exceeding the no-progress or total +deadline interrupts the worker, removes its partial file, and publishes an +error state.} + +@defproc[(updater-cancel! [instance updater?]) boolean?]{ +Requests cancellation of an active download. Returns @racket[#t] when a worker +was interrupted and @racket[#f] when no download was active.} + +@defproc[(updater-state [instance updater?]) hash?]{ +Returns an immutable, synchronized, JSON-safe snapshot with phase, percent, +message, downloaded path, available version, and current version fields. +Contained strings and JSON collections are immutable as well as the top-level +hash. The stable phase vocabulary is @racket["idle"], @racket["checking"], +@racket["available"], @racket["up-to-date"], @racket["downloading"], +@racket["downloaded"], @racket["cancelled"], and @racket["error"]. A failed +or cancelled download retains its authenticated candidate so an application +may explicitly retry; starting a new check resets it.} + +@defproc[(updater-wait [instance updater?] + [timeout (or/c #f (and/c real? (not/c negative?))) #f]) + (or/c #f hash?)]{ +Waits for the active download and returns its terminal snapshot. Returns +@racket[#f] when a finite timeout expires.} + @defproc[(execute-install-plan! [plan install-plan?] [#:health-check health-check (-> any/c) (lambda () #t)] [#:commit commit (-> any/c) void] diff --git a/tests/managed-updater.rkt b/tests/managed-updater.rkt new file mode 100644 index 0000000..f97be46 --- /dev/null +++ b/tests/managed-updater.rkt @@ -0,0 +1,190 @@ +#lang racket/base + +(require rackunit + racket/file + "../rivet/distribution.rkt" + (submod "../rivet/distribution/managed-updater.rkt" test-support)) + +(define artifact + (update-artifact 'windows 'x64 "https://updates.example/app.zip" + (make-string 64 #\a) 100 'zip '())) +(define manifest + (update-manifest "dev.rivet.managed-test" "2.0.0" 2 'stable + "2026-10-13T00:00:00Z" "0.0.0" "1.0.0" + #t 100 (list artifact))) + +(check-eq? (update-architecture-for 'x86_64) 'x64) +(check-eq? (update-architecture-for 'amd64) 'x64) +(check-eq? (update-architecture-for 'aarch64) 'arm64) +(check-exn #rx"unsupported update architecture" + (lambda () (update-architecture-for 'riscv64))) + +(define (fake-fetch _url _key + #:key-id [_key-id #f] + #:maximum-bytes [_maximum-bytes (* 1024 1024)]) + manifest) + +(define (make-test-updater root download + #:current-version [current-version "1.0.0"] + #:check-timeout [check-timeout 1.0] + #:stall-timeout [stall-timeout 1.0] + #:total-timeout [total-timeout 2.0] + #:fetch [fetch fake-fetch]) + (parameterize ([current-managed-fetch-manifest fetch] + [current-managed-download download]) + (make-updater + #:application-id "dev.rivet.managed-test" + #:current-version current-version + #:platform 'windows + #:architecture 'x64 + #:install-kind 'portable + #:public-key 'test-key + #:key-id "test-2026" + #:manifest-url "https://updates.example/update-stable.json" + #:data-directory root + #:rollout-bucket 17 + #:check-timeout check-timeout + #:stall-timeout stall-timeout + #:total-timeout total-timeout))) + +(define root (make-temporary-file "rivet-managed-updater-~a" 'directory)) +(dynamic-wind + void + (lambda () + (define (successful-download _config _candidate destination + #:on-progress on-progress + #:progress-every progress-every) + (check-equal? progress-every 1) + (for ([downloaded (in-list '(0 50 100))]) + (on-progress downloaded)) + (make-parent-directory* destination) + (call-with-output-file destination + #:exists 'truncate/replace + (lambda (out) (display "verified" out))) + destination) + (define successful (make-test-updater root successful-download)) + (define mutable-version (string-copy "1.0.0")) + (define copied-input + (make-test-updater root successful-download + #:current-version mutable-version)) + (string-set! mutable-version 0 #\9) + (check-equal? (hash-ref (updater-state copied-input) 'currentVersion) + "1.0.0") + (check-exn exn:fail:contract? + (lambda () + (make-updater + #:application-id "dev.rivet.invalid" + #:current-version "1.0.0" + #:platform 'freebsd + #:architecture 'x64 + #:public-key 'test-key + #:key-id "test-2026" + #:manifest-url "https://updates.example/update.json" + #:data-directory root + #:rollout-bucket 1))) + (check-exn exn:fail:contract? + (lambda () + (make-updater + #:application-id "dev.rivet.invalid" + #:current-version "1.0.0" + #:platform 'windows + #:architecture 'riscv64 + #:public-key 'test-key + #:key-id "test-2026" + #:manifest-url "https://updates.example/update.json" + #:data-directory root + #:rollout-bucket 1))) + (check-true (immutable? (updater-state successful))) + (check-true + (immutable? (hash-ref (updater-state successful) 'currentVersion))) + (define available (updater-check! successful)) + (check-true (immutable? (hash-ref available 'availableVersion))) + (check-true (immutable? (hash-ref available 'publishedAt))) + (check-equal? (hash-ref available 'status) "available") + (check-equal? (hash-ref (updater-state successful) 'phase) "available") + (check-equal? (hash-ref (updater-state successful) 'availableVersion) + "2.0.0") + (updater-download! successful) + (define completed (updater-wait successful 2.0)) + (check-not-false completed) + (check-equal? (hash-ref completed 'phase) "downloaded") + (check-equal? (hash-ref completed 'percent) 100) + (check-true (file-exists? (hash-ref completed 'downloadedPath))) + (check-false (updater-cancel! successful)) + + ;; Only one operation owns an updater. Cancellation interrupts the worker + ;; and produces a stable terminal state instead of leaking exn:break. + (define cancel-started (make-semaphore 0)) + (define (blocking-download _config _candidate _destination + #:on-progress on-progress + #:progress-every _progress-every) + (on-progress 0) + (semaphore-post cancel-started) + (sync never-evt)) + (define cancellable (make-test-updater root blocking-download)) + (check-equal? (hash-ref (updater-check! cancellable) 'status) "available") + (updater-download! cancellable) + (check-not-false (sync/timeout 2 cancel-started)) + (check-exn #rx"another updater operation" + (lambda () (updater-download! cancellable))) + (check-true (updater-cancel! cancellable)) + (define cancelled (updater-wait cancellable 2.0)) + (check-not-false cancelled) + (check-equal? (hash-ref cancelled 'phase) "cancelled") + (check-equal? (hash-ref cancelled 'message) + "update download cancelled") + + ;; A server that neither produces bytes nor closes cannot leave the state + ;; machine permanently in "downloading". + (define stalled + (make-test-updater root blocking-download + #:stall-timeout 0.05 + #:total-timeout 1.0)) + (check-equal? (hash-ref (updater-check! stalled) 'status) "available") + (updater-download! stalled) + (define timed-out (updater-wait stalled 2.0)) + (check-not-false timed-out) + (check-equal? (hash-ref timed-out 'phase) "error") + (check-equal? (hash-ref timed-out 'message) "update download stalled") + + ;; Manifest acquisition has its own deadline; download watchdogs do not + ;; help when a server stalls before selection begins. + (define (blocking-fetch _url _key + #:key-id [_key-id #f] + #:maximum-bytes [_maximum-bytes (* 1024 1024)]) + (sync never-evt)) + (define check-bounded + (make-test-updater root successful-download + #:check-timeout 0.05 + #:fetch blocking-fetch)) + (define check-error (updater-check! check-bounded)) + (check-equal? (hash-ref check-error 'status) "error") + (check-regexp-match #rx"deadline" (hash-ref check-error 'message)) + (check-equal? (hash-ref (updater-state check-bounded) 'phase) "error") + + ;; Breaking a synchronous check must also terminate its owned fetch + ;; thread; otherwise an abandoned network request survives the caller. + (define fetch-started (make-semaphore 0)) + (define fetch-stopped (make-semaphore 0)) + (define (interruptible-fetch _url _key + #:key-id [_key-id #f] + #:maximum-bytes [_maximum-bytes (* 1024 1024)]) + (dynamic-wind + (lambda () (semaphore-post fetch-started)) + (lambda () (sync never-evt)) + (lambda () (semaphore-post fetch-stopped)))) + (define interrupted + (make-test-updater root successful-download + #:check-timeout 10.0 + #:fetch interruptible-fetch)) + (define checking-thread + (thread + (lambda () + (with-handlers ([exn:break? void]) + (updater-check! interrupted))))) + (check-not-false (sync/timeout 2 fetch-started)) + (break-thread checking-thread) + (check-not-false (sync/timeout 2 (thread-dead-evt checking-thread))) + (check-not-false (sync/timeout 2 fetch-stopped)) + (check-equal? (hash-ref (updater-state interrupted) 'phase) "error")) + (lambda () (delete-directory/files root))) diff --git a/tests/public-api.rkt b/tests/public-api.rkt index 59b1313..1cdb6f9 100644 --- a/tests/public-api.rkt +++ b/tests/public-api.rkt @@ -64,13 +64,14 @@ (rivet/distribution (base64-string->bytes bytes->base64-string bytes->ed25519-public-key channel-accepts-version? current-install-kind current-update-platform - download-update ed25519-sign ed25519-verify execute-install-plan! + current-update-architecture download-update ed25519-sign ed25519-verify + execute-install-plan! execute-platform-installation! fetch-update-manifest install-plan install-plan-backup-path install-plan-candidate install-plan-downloaded-path install-plan-install install-plan-restart install-plan-rollback install-plan? make-install-plan manifest->payload-bytes manifest-schema-version payload-bytes->manifest - platform-installation? platform-installer-policy + make-updater platform-installation? platform-installer-policy prepare-platform-installation read-ed25519-private-key read-ed25519-public-key read-signed-manifest recover-install-plan! recover-platform-installation! select-update sha256-bytes sha256-file @@ -86,11 +87,13 @@ update-manifest-minimum-version update-manifest-previous-version update-manifest-published-at update-manifest-rollback-allowed? update-manifest-rollout update-manifest-version update-manifest? + updater-cancel! updater-check! updater-download! updater-state updater-config updater-config-application-id updater-config-architecture updater-config-channel updater-config-current-version updater-config-expected-key-id updater-config-maximum-download-bytes updater-config-platform updater-config-public-key updater-config-rollout-bucket updater-config? + updater-wait updater? valid-channel? verify-platform-payload! verify-signed-manifest verify-update-artifact! version-compare version<=? version=? version>? version? write-signed-manifest))