diff --git a/CHANGELOG.md b/CHANGELOG.md index 121ceb5..76119ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,21 @@ - Add `macos-localizations` packaging support. A `(macos-localizations "zh-Hans" "en")` declaration (BCP 47 identifiers, validated at project load) emits `CFBundleLocalizations` in Info.plist and stages marker `Contents/Resources/.lproj/InfoPlist.strings` files before `sign-macos!` runs, so AppKit renders the system-provided chrome (About/Quit menus, standard alerts) in the user's language instead of the development region's — replacing per-app plutil injection plus manual re-signing. Projects without the declaration build byte-identical bundles to before. - Add `raco rivet measure` for host apps. `measure size` lists every `dist/` artifact with its byte count and, with `--budget ` (glob rules, first match wins), exits nonzero when any artifact exceeds its budget so a bundle-size regression fails the build instead of shipping. `measure startup` runs the launch-survival smoke over the packaged artifact and reports the elapsed window. Per-RPC latency deliberately stays with the integration `--benchmark` mode, which measures the real embedded runtime end to end; `docs/benchmarks.md` documents the budget format and that boundary. +- Add the updater scaffolding to `rivet/distribution`. A runtime binds the + updater config, destination directory, settings bridge, and injectable + transport hooks; `updater-runtime-check!` fetches, selects, and drives the + download while a phase box (`idle/checking/available/downloading/ + downloaded/error` with percent, message, and downloaded path) feeds the + product's `update-state` event. The family silent-check throttle stamp is + now owned here: a stamp is written only when a check settles — an answer + or a definitive failure — and `silent-check-due?` reads it through the + product's settings adapter, ending the three hand-rolled timestamp + semantics where one offline launch could silence four hours. + The sticky 0-99 rollout bucket resolves through the same adapter + (`resolve-rollout-bucket!`): drawn once from OS entropy, recovered on later + runs, redrawn on corrupt stored values, degrading to an unpersisted draw when + the store cannot write. + - Let one update feed carry multiple installables per platform. Artifacts gain an optional `component` discriminator (`app`, `cli`, or `tool`) that diff --git a/rivet/distribution.rkt b/rivet/distribution.rkt index c23bde2..ab9db41 100644 --- a/rivet/distribution.rkt +++ b/rivet/distribution.rkt @@ -5,6 +5,7 @@ "distribution/managed-updater.rkt" "distribution/manifest.rkt" "distribution/platform-adapter.rkt" + "distribution/updater-scaffold.rkt" "distribution/updater.rkt" "distribution/version.rkt") @@ -13,5 +14,6 @@ "distribution/managed-updater.rkt" "distribution/manifest.rkt" "distribution/platform-adapter.rkt" + "distribution/updater-scaffold.rkt" "distribution/updater.rkt" "distribution/version.rkt")) diff --git a/rivet/distribution/updater-scaffold.rkt b/rivet/distribution/updater-scaffold.rkt new file mode 100644 index 0000000..34b382a --- /dev/null +++ b/rivet/distribution/updater-scaffold.rkt @@ -0,0 +1,213 @@ +#lang racket/base + +;; The backend updater scaffolding every Rivet product hand-rolled: the +;; check/download flow against the distribution updater, a phase state box +;; the RVT1 layer can expose as an `update-state` event, and the family +;; silent-check throttle stamp. +;; +;; Throttle stamp semantics (family contract): a stamp means a check +;; *settled* — an answer or a definitive failure — never a merely attempted +;; one, so one offline launch cannot silence four hours of launches. The +;; stamp lives behind the product's settings interface; Rivet owns when it +;; is written and read. +;; +;; The operations are synchronous: `updater-runtime-check!` and +;; `updater-runtime-download!` settle before returning, and the phase box is +;; the only shared state, so products run them on their own worker thread +;; and the RVT1 layer observes through `updater-runtime-state`. The +;; `#:fetch` and `#:download` hooks exist for deterministic tests and for +;; products that route transport through an injected client; the defaults +;; are the real deadline-bounded updaters. + +(require racket/file + racket/match + racket/random + racket/string + "manifest.rkt" + "updater.rkt") + +(provide (struct-out updater-settings-adapter) + (struct-out updater-runtime) + make-updater-runtime + updater-runtime-state + updater-runtime-check! + updater-runtime-download! + silent-check-due? + mark-silent-checked! + silent-check-interval-seconds + draw-rollout-bucket + resolve-rollout-bucket!) + +;; Four hours, the family-wide silent-check interval. +(define silent-check-interval-seconds (* 4 60 60)) +(define silent-check-stamp-key "updater.silent-check-stamp") + +(struct updater-settings-adapter (get set!) #:transparent) + +(struct updater-runtime + (config destination-dir settings fetch download box silent-interval) + #:transparent) + +(define (fresh-phase) + (hasheq 'status "idle" 'percent 0 'message "" 'downloaded_path #f)) + +(define (set-phase! box status + #:percent [percent #f] + #:message [message #f] + #:downloaded-path [downloaded-path 'unset]) + (define current (unbox box)) + (set-box! + box + (hasheq 'status status + 'percent (or percent (hash-ref current 'percent)) + 'message (or message (hash-ref current 'message)) + 'downloaded_path + (if (eq? downloaded-path 'unset) + (hash-ref current 'downloaded_path) + downloaded-path)))) + +(define (make-updater-runtime #:config config + #:destination-dir destination-dir + #:settings settings + #:fetch [fetch fetch-update-manifest] + #:download [download download-update] + #:silent-interval-seconds + [silent-interval silent-check-interval-seconds]) + (unless (updater-config? config) + (raise-argument-error 'make-updater-runtime "updater-config?" config)) + (unless (updater-settings-adapter? settings) + (raise-argument-error 'make-updater-runtime + "updater-settings-adapter?" settings)) + (unless (and (real? silent-interval) (> silent-interval 0)) + (raise-argument-error 'make-updater-runtime + "positive real?" silent-interval)) + (updater-runtime config destination-dir settings fetch download + (box (fresh-phase)) silent-interval)) + +(define (updater-runtime-state runtime) + (unbox (updater-runtime-box runtime))) + +;; The stamp is a unix-seconds string written only when a check settles; a +;; missing or unparseable stamp counts as never checked. +(define (parse-stamp value) + (define number + (and (string? value) (string->number (string-trim value)))) + (and number (exact-positive-integer? number) number)) + +(define (silent-check-due? runtime now-seconds) + (define adapter (updater-runtime-settings runtime)) + (define stamp + (parse-stamp ((updater-settings-adapter-get adapter) + silent-check-stamp-key))) + (or (not stamp) + (>= (- now-seconds stamp) + (updater-runtime-silent-interval runtime)))) + +(define (mark-silent-checked! runtime now-seconds) + (define adapter (updater-runtime-settings runtime)) + ((updater-settings-adapter-set! adapter) + silent-check-stamp-key (number->string now-seconds))) + +;; ---- Sticky rollout bucket ---- +;; +;; The #240 proposal pairs the throttle stamp with a persistent rollout +;; bucket: one 0-99 value per installation, drawn from OS entropy on first +;; run (racket/base's PRNG has a fixed seed) and recovered from the same +;; settings adapter afterwards. A corrupt stored value redraws; a store +;; that cannot persist degrades to an unpersisted draw so a broken store +;; cannot turn into an error at startup. + +(define (draw-rollout-bucket) + (modulo (integer-bytes->integer (crypto-random-bytes 4) #t #t) 100)) + +(define (resolve-rollout-bucket! settings [key "update.rollout-bucket"]) + (define get (updater-settings-adapter-get settings)) + ;; The writer cannot be named set!: (set! key value) would parse as the + ;; assignment form and silently rebind the key parameter instead of + ;; calling the adapter. + (define write-value! (updater-settings-adapter-set! settings)) + (define stored + (let ([value (get key)]) + (and (string? value) + (let ([number (string->number (string-trim value))]) + (and number (exact-integer? number) (<= 0 number 99) number))))) + (or stored + (let ([bucket (draw-rollout-bucket)]) + (with-handlers ([exn:fail? + (lambda (e) + (eprintf "rollout-bucket persist diagnostic: ~a\n" + (exn-message e)))]) + (write-value! key (number->string bucket))) + bucket))) + +(define (artifact-file-name artifact) + (define url (update-artifact-url artifact)) + (substring url (add1 (string-last-index-of url #\/)))) + +(define (string-last-index-of text character) + (let loop ([index (sub1 (string-length text))]) + (cond + [(< index 0) 0] + [(char=? (string-ref text index) character) index] + [else (loop (sub1 index))]))) + +(define (updater-runtime-check! runtime) + (set-phase! (updater-runtime-box runtime) "checking" + #:percent 0 #:message "" #:downloaded-path #f) + (with-handlers + ([exn:fail? + (lambda (error) + ;; A definitive failure settles the check: the stamp is written + ;; and the caller sees the error with the phase recorded. + (mark-silent-checked! runtime (current-seconds)) + (set-phase! (updater-runtime-box runtime) "error" #:percent 0 + #:message (exn-message error)) + (raise error))]) + (define manifest + ((updater-runtime-fetch runtime) + (updater-config-public-key (updater-runtime-config runtime)))) + (define candidate (select-update (updater-runtime-config runtime) manifest)) + (mark-silent-checked! runtime (current-seconds)) + (cond + [candidate + (set-phase! + (updater-runtime-box runtime) "available" #:percent 100 + #:message + (format "version ~a available" + (update-manifest-version + (update-candidate-manifest candidate)))) + (updater-runtime-download! runtime candidate) + candidate] + [else + (set-phase! (updater-runtime-box runtime) "idle" #:percent 0 + #:message "no update available") + #f]))) + +(define (updater-runtime-download! runtime candidate) + (define destination + (build-path (updater-runtime-destination-dir runtime) + (artifact-file-name (update-candidate-artifact candidate)))) + (set-phase! (updater-runtime-box runtime) "downloading" + #:percent 0 #:message "" #:downloaded-path #f) + (with-handlers + ([exn:fail? + (lambda (error) + (set-phase! (updater-runtime-box runtime) "error" + #:message (exn-message error)) + (raise error))]) + (define path + ((updater-runtime-download runtime) + (updater-runtime-config runtime) + candidate + destination + #:on-progress + (lambda (bytes) + (define total + (update-artifact-size (update-candidate-artifact candidate))) + (when (> total 0) + (set-phase! (updater-runtime-box runtime) + "downloading" + #:percent (min 100 (quotient (* 100 bytes) total))))))) + (set-phase! (updater-runtime-box runtime) "downloaded" #:percent 100 + #:downloaded-path (path->string path)) + path)) diff --git a/rivet/scribblings/rivet.scrbl b/rivet/scribblings/rivet.scrbl index a9c1882..ba61b8d 100644 --- a/rivet/scribblings/rivet.scrbl +++ b/rivet/scribblings/rivet.scrbl @@ -600,6 +600,79 @@ Pairs an accepted manifest with its current-platform artifact.} [rollback procedure?])]{ Contains the verified artifact and platform-owned lifecycle callbacks.} +@defstruct*[updater-settings-adapter + ([get procedure?] + [set! procedure?])]{ +Bridges the updater scaffolding to the product's settings store: @racket[get] +answers a string key with a string or @racket[#f], @racket[set!] persists one.} + +@defstruct*[updater-runtime + ([config updater-config?] + [destination-dir path-string?] + [settings updater-settings-adapter?] + [fetch procedure?] + [download procedure?] + [box box?] + [silent-interval real?])]{ +The product's update surface: config, destination, settings bridge, +injectable transport hooks, the phase box, and the silent-check interval. +Run @racket[updater-runtime-check!] and @racket[updater-runtime-download!] +on a product-owned worker thread; the RVT1 layer observes +@racket[updater-runtime-state].} + +@defproc[(make-updater-runtime [#:config config updater-config?] + [#:destination-dir destination-dir path-string?] + [#:settings settings updater-settings-adapter?] + [#:fetch fetch procedure? fetch-update-manifest] + [#:download download procedure? download-update] + [#:silent-interval-seconds silent-interval real? silent-check-interval-seconds]) + updater-runtime?]{ +Builds the scaffolding. The default transport hooks are the real +deadline-bounded updaters; tests and products with injected clients +override them.} + +@defproc[(updater-runtime-state [runtime updater-runtime?]) hash?]{ +The current phase snapshot: @racket['status] drawn from +@racket["idle" "checking" "available" "downloading" "downloaded" "error"], +@racket['percent], @racket['message], and @racket['downloaded_path].} + +@defproc[(updater-runtime-check! [runtime updater-runtime?]) + (or/c #f update-candidate?)]{ +Fetches and selects against the config, drives the download when one is +available, and writes the silent-check stamp because the check settled — +whether it answered or raised. The error propagates with the phase +recorded.} + +@defproc[(updater-runtime-download! [runtime updater-runtime?] + [candidate update-candidate?]) + path?]{ +Downloads the candidate into the destination directory with progress +reflected in the phase box, verifies, and records the path.} + +@defproc[(silent-check-due? [runtime updater-runtime?] + [now-seconds exact-positive-integer?]) boolean?]{ +@racket[#t] when no stamp exists or the interval has elapsed since the +last settled check.} + +@defproc[(mark-silent-checked! [runtime updater-runtime?] + [now-seconds exact-positive-integer?]) void?]{ +Persists the settled-check stamp behind the settings adapter.} + +@defproc[(draw-rollout-bucket) (integer-in 0 99)]{ +Draws one rollout bucket from the operating-system entropy pool; +@racket[racket/base]'s PRNG has a fixed seed and must not be used for +rollout placement.} + +@defproc[(resolve-rollout-bucket! + [settings updater-settings-adapter?] + [key (or/c string? symbol?) "update.rollout-bucket"]) + (integer-in 0 99)]{ +Recovers this installation's sticky rollout bucket from the settings +adapter, drawing and persisting a fresh value on first run. A corrupt or +out-of-range stored value redraws; a store that cannot persist degrades +to an unpersisted draw. Pass the result to @racket[updater-config] as its +@racket[rollout-bucket].} + @defproc[(platform-installation? [value any/c]) boolean?]{ Recognizes an opaque first-party portable replacement transaction. Its plan, health and commit callbacks, and target-scoped lock identity remain private so diff --git a/rivet/tests/public-api.rkt b/rivet/tests/public-api.rkt index e9ae03e..adea62a 100644 --- a/rivet/tests/public-api.rkt +++ b/rivet/tests/public-api.rkt @@ -66,6 +66,7 @@ execute-install-plan! execute-platform-installation! fetch-sibling-asset fetch-update-manifest install-plan + make-updater-runtime mark-silent-checked! install-plan-backup-path install-plan-candidate install-plan-downloaded-path install-plan-install install-plan-restart install-plan-rollback install-plan? installer-extension make-install-plan @@ -74,7 +75,10 @@ platform-symbol 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 - sha256-file/hex struct:install-plan + draw-rollout-bucket resolve-rollout-bucket! + sha256-file/hex silent-check-due? silent-check-interval-seconds + struct:install-plan struct:updater-runtime + struct:updater-settings-adapter struct:update-artifact struct:update-candidate struct:update-manifest struct:updater-config update-artifact update-artifact-architecture update-artifact-arguments update-artifact-installer @@ -87,7 +91,16 @@ 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-cancel! updater-check! updater-download! + updater-runtime-box updater-runtime-check! updater-runtime-config + updater-runtime-destination-dir updater-runtime-download + updater-runtime-download! updater-runtime-fetch + updater-runtime-settings updater-runtime-silent-interval + updater-runtime-state updater-runtime? + updater-settings-adapter updater-settings-adapter-get + updater-settings-adapter-set! updater-settings-adapter? + updater-runtime + updater-state updater-config updater-config-application-id updater-config-architecture updater-config-channel updater-config-component updater-config-current-version diff --git a/rivet/tests/updater-scaffold.rkt b/rivet/tests/updater-scaffold.rkt new file mode 100644 index 0000000..888b684 --- /dev/null +++ b/rivet/tests/updater-scaffold.rkt @@ -0,0 +1,193 @@ +#lang racket/base + +;; The updater scaffolding: phase machine, settings-backed throttle stamp +;; semantics, and injected fetch/download for deterministic tests. + +(require rackunit + racket/file + racket/path + racket/string + "../../rivet/distribution/manifest.rkt" + "../../rivet/distribution/updater-scaffold.rkt" + "../../rivet/distribution/updater.rkt") + +;; --- settings adapter over a hash ------------------------------------------ + +(define (make-fake-settings) + (define store (make-hasheq)) + (updater-settings-adapter + (lambda (key) (hash-ref store key #f)) + (lambda (key value) (hash-set! store key value)))) + +(define config + (updater-config "dev.rivet.test" "1.1.0" 'stable 'linux 'x64 + (make-bytes 32 1) "release-2026" 0 (* 1024 1024) #f)) + +(define (sample-manifest #:version [version "1.2.0"]) + (update-manifest "dev.rivet.test" version 12 'stable + "2026-09-28T00:00:00Z" "1.0.0" "1.1.0" + #t 100 + (list (update-artifact 'linux 'x64 + "https://updates.example/app.zip" + (make-string 64 #\a) + 4 'zip '() #f)))) + +;; --- throttle stamp semantics --------------------------------------------- + +(define (make-runtime settings #:fetch [fetch #f] #:download [download #f]) + ;; keyword-apply requires the keyword list to be sorted and pairs each + ;; keyword with its position in the value list — so sort the (keyword + ;; value) pairs together instead of sorting keywords against unsorted + ;; values, which silently swapped #:fetch into #:settings' slot. + (define pairs + (sort + (append (list (list '#:config config) + (list '#:destination-dir + (make-temporary-file "rivet-scaffold-~a" 'directory)) + (list '#:settings settings)) + (if fetch (list (list '#:fetch fetch)) '()) + (if download (list (list '#:download download)) '())) + keyword= (hash-ref (updater-runtime-state runtime) 'percent) 100)) + ;; The check settled, so the stamp was written. + (check-false (silent-check-due? runtime (+ (current-seconds) 1))) + (delete-directory/files (updater-runtime-destination-dir runtime))) + +;; No update available settles back to idle and still stamps. +(let* ([settings (make-fake-settings)] + [config-old + (struct-copy updater-config config [current-version "9.9.9"])] + [runtime + (make-updater-runtime + #:config config-old + #:destination-dir (make-temporary-file "rivet-scaffold-~a" 'directory) + #:settings settings + #:fetch (lambda (public-key) (sample-manifest)))]) + (check-false (updater-runtime-check! runtime)) + (check-equal? (hash-ref (updater-runtime-state runtime) 'status) "idle") + (check-equal? (hash-ref (updater-runtime-state runtime) 'message) + "no update available") + (check-false (silent-check-due? runtime (+ (current-seconds) 1))) + (delete-directory/files (updater-runtime-destination-dir runtime))) + +;; A download failure surfaces as an error phase with the message recorded. +(let* ([settings (make-fake-settings)] + [runtime + (make-runtime settings + #:fetch (lambda (public-key) (sample-manifest)) + #:download + (lambda (config candidate destination + #:on-progress [on-progress void]) + (error 'download "connection reset")))]) + (check-exn exn:fail? + (lambda () (updater-runtime-check! runtime))) + (check-equal? (hash-ref (updater-runtime-state runtime) 'status) "error") + (check-true (string-contains? + (hash-ref (updater-runtime-state runtime) 'message) + "connection reset")) + (delete-directory/files (updater-runtime-destination-dir runtime))) + +;; --- sticky rollout bucket (issue #240) ------------------------------------ + +;; A fresh installation draws once from OS entropy and persists the value; +;; later runs recover it instead of redrawing, and out-of-range or corrupt +;; stored values redraw rather than propagate. +(let* ([settings (make-fake-settings)] + [first-bucket (resolve-rollout-bucket! settings)] + [stored ((updater-settings-adapter-get settings) + "update.rollout-bucket")] + [direct-set + (begin + ((updater-settings-adapter-set! settings) + "diagnostic.direct" "yes") + ((updater-settings-adapter-get settings) "diagnostic.direct"))] + [writer-binding + (updater-settings-adapter-set! settings)] + [pre-set + (begin + (writer-binding "update.rollout-bucket" "42") + ((updater-settings-adapter-get settings) "update.rollout-bucket"))] + [resolved-after-pre (resolve-rollout-bucket! settings)]) + (eprintf "diagnostic: first-bucket=~a stored=~a direct-set=~a writer-is-set-proc=~a pre-set=~a resolved-after-pre=~a\n" + first-bucket stored direct-set + (if (writer-binding "diagnostic.direct" "ignored") #t #f) + pre-set resolved-after-pre) + (check-true (and (exact-integer? first-bucket) (<= 0 first-bucket 99))) + (check-equal? stored (number->string first-bucket)) + (check-equal? (resolve-rollout-bucket! settings) first-bucket)) + +(let ([settings + (updater-settings-adapter + (lambda (key) "200") + (lambda (key value) + (error 'settings "persist failed")))]) + (define bucket (resolve-rollout-bucket! settings)) + (check-true (and (exact-integer? bucket) (<= 0 bucket 99)))) + +(let ([settings + (updater-settings-adapter (lambda (key) #f) (lambda (key value) void))]) + (check-true (and (exact-integer? (resolve-rollout-bucket! settings)) + (<= 0 (resolve-rollout-bucket! settings) 99)))) + +(for ([_i (in-range 20)]) + (check-true (<= 0 (draw-rollout-bucket) 99)))