From 21f59ccfb1a78260a930c214dc9a5022bb8d9b0b Mon Sep 17 00:00:00 2001 From: jrtxio Date: Fri, 16 Oct 2026 20:45:09 +0800 Subject: [PATCH 1/8] Add the updater scaffolding and own the silent-check stamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every product backend re-implemented the same updater flow: background worker, phase state box for the RVT1 update-state event, error mapping, and the silent-check throttle stamp — with three different write-time semantics, one of which silenced four hours of launches after a single offline start. The scaffolding module binds config, destination, and a settings adapter; check and download settle synchronously against the deadline-bounded updaters while the phase box feeds the RVT1 layer, and transport hooks stay injectable for deterministic tests. The stamp is written only when a check settles — answer or definitive failure — and silent-check-due? reads it through the product's settings interface. --- CHANGELOG.md | 15 ++ rivet/distribution.rkt | 2 + rivet/distribution/updater-scaffold.rkt | 178 ++++++++++++++++++++++++ rivet/scribblings/rivet.scrbl | 58 ++++++++ rivet/tests/public-api.rkt | 14 +- rivet/tests/updater-scaffold.rkt | 144 +++++++++++++++++++ 6 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 rivet/distribution/updater-scaffold.rkt create mode 100644 rivet/tests/updater-scaffold.rkt 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..70b7b32 --- /dev/null +++ b/rivet/distribution/updater-scaffold.rkt @@ -0,0 +1,178 @@ +#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/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) + +;; 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))) + +(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..a1d4e96 100644 --- a/rivet/scribblings/rivet.scrbl +++ b/rivet/scribblings/rivet.scrbl @@ -600,6 +600,64 @@ 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[(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..f889b42 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,9 @@ 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 + 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 +90,14 @@ 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-check! updater-runtime-config + updater-runtime-destination-dir updater-runtime-download + updater-runtime-download! updater-runtime-fetch + updater-runtime-silent-interval updater-runtime-state + updater-settings-adapter updater-settings-adapter-get + updater-settings-adapter-set! 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..f967f0c --- /dev/null +++ b/rivet/tests/updater-scaffold.rkt @@ -0,0 +1,144 @@ +#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]) + (define kws + (append '(#:config #:destination-dir #:settings) + (if fetch (list '#:fetch) '()) + (if download (list '#:download) '()))) + (define vals + (append (list config + (make-temporary-file "rivet-scaffold-~a" 'directory) + settings) + (if fetch (list fetch) '()) + (if download (list download) '()))) + (keyword-apply make-updater-runtime kws vals '())) + +(check-true + (let ([runtime (make-runtime (make-fake-settings))]) + (begin0 (silent-check-due? runtime 1000) + (delete-directory/files + (updater-runtime-destination-dir runtime))))) +;; A missing stamp is due immediately. + +;; A fresh stamp silences; the interval elapsing makes it due again. +(let ([settings (make-fake-settings)] + [now 1000000]) + (define runtime (make-runtime settings)) + (mark-silent-checked! runtime now) + (check-false (silent-check-due? runtime (+ now 1))) + (check-false (silent-check-due? runtime (+ now silent-check-interval-seconds -1))) + (check-true (silent-check-due? runtime (+ now silent-check-interval-seconds))) + (delete-directory/files (updater-runtime-destination-dir runtime))) + +;; A stamp written before a definitive failure would have happened still +;; counts — but the runtime stamps failures too, so an offline launch is +;; silent for the interval instead of retry-storming. +(let ([settings (make-fake-settings)] + [now 2000000]) + (define runtime + (make-runtime settings + #:fetch (lambda (public-key) + (error 'fetch "network unreachable")))) + (check-exn exn:fail? + (lambda () (updater-runtime-check! runtime))) + (check-equal? (hash-ref (updater-runtime-state runtime) 'status) "error") + (check-false (silent-check-due? runtime (+ now 1))) + (check-true (silent-check-due? runtime (+ now silent-check-interval-seconds))) + (delete-directory/files (updater-runtime-destination-dir runtime))) + +;; --- check and download through the injected boundaries -------------------- + +(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]) + (on-progress 2) + (on-progress 4) + (call-with-output-file destination + (lambda (out) (write-bytes (make-bytes 4 9) out))) + destination))]) + (define candidate (updater-runtime-check! runtime)) + (check-true (update-candidate? candidate)) + (check-equal? (hash-ref (updater-runtime-state runtime) 'status) "downloaded") + (check-equal? (hash-ref (updater-runtime-state runtime) 'percent) 100) + (check-true (file-exists? + (path->string + (hash-ref (updater-runtime-state runtime) 'downloaded_path)))) + ;; The download progress went through the phase box. + (check-true + (>= (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 + (apply 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))) From b0084e30d964d290e58889266a7a66b3b335d916 Mon Sep 17 00:00:00 2001 From: jrtxio Date: Fri, 16 Oct 2026 20:55:49 +0800 Subject: [PATCH 2/8] Sort the keyword-apply list in the scaffold test helper --- rivet/tests/updater-scaffold.rkt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/rivet/tests/updater-scaffold.rkt b/rivet/tests/updater-scaffold.rkt index f967f0c..525396a 100644 --- a/rivet/tests/updater-scaffold.rkt +++ b/rivet/tests/updater-scaffold.rkt @@ -36,9 +36,10 @@ (define (make-runtime settings #:fetch [fetch #f] #:download [download #f]) (define kws - (append '(#:config #:destination-dir #:settings) - (if fetch (list '#:fetch) '()) - (if download (list '#:download) '()))) + (sort (append '(#:config #:destination-dir #:settings) + (if fetch (list '#:fetch) '()) + (if download (list '#:download) '())) + keyword Date: Sat, 17 Oct 2026 10:33:28 +0800 Subject: [PATCH 3/8] Fix the scaffold test harness and CI guards; add the sticky rollout bucket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects the full-suite runs exposed: 1. The test helper sorted the keyword list without sorting the value list, so keyword-apply silently swapped #:fetch into #:settings' slot on every fetch-only call (the CI red on Racket 9.0/core). The helper now sorts (keyword value) pairs together; a second site used apply with a keyword list, feeding keywords as positional arguments — replaced with a direct keyword call. 2. The public-api expectation missed four exports of the new module (updater-runtime?, updater-settings-adapter?, and two accessors) and duplicated struct:updater-config, which made the list comparison fail no matter what shipped. 3. Two assertions disagreed with the module's real contract: downloaded_path is stored as a string (the test re-wrapped it in path->string), and the failure-settle scenario compared the stamp against an injected clock while the runtime stamps current-seconds. resolve-rollout-bucket! and draw-rollout-bucket join the module (issue #240's settings-interface proposal covers the bucket as well as the throttle stamp): one 0-99 value per installation, drawn from OS entropy, recovered on later runs, redrawn on corrupt stored values, degrading to an unpersisted draw when the store cannot write. The writer binding cannot be named set! — (set! key value) parses as the assignment form and silently rebinds the parameter instead of calling the adapter. --- rivet/distribution/updater-scaffold.rkt | 35 +++++++++++- rivet/scribblings/rivet.scrbl | 15 +++++ rivet/tests/public-api.rkt | 9 ++- rivet/tests/updater-scaffold.rkt | 74 ++++++++++++++++++------- 4 files changed, 108 insertions(+), 25 deletions(-) diff --git a/rivet/distribution/updater-scaffold.rkt b/rivet/distribution/updater-scaffold.rkt index 70b7b32..e58b16c 100644 --- a/rivet/distribution/updater-scaffold.rkt +++ b/rivet/distribution/updater-scaffold.rkt @@ -21,6 +21,7 @@ (require racket/file racket/match + racket/random racket/string "manifest.rkt" "updater.rkt") @@ -33,7 +34,9 @@ updater-runtime-download! silent-check-due? mark-silent-checked! - silent-check-interval-seconds) + 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)) @@ -105,6 +108,36 @@ ((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 (with-handlers ([exn:fail? (lambda (_) #f)]) (get key))]) + (define number + (and (string? value) (string->number (string-trim value)))) + (and number (exact-integer? number) (<= 0 number 99) number))) + (or stored + (let ([bucket (draw-rollout-bucket)]) + (when write-value! + (with-handlers ([exn:fail? void]) + (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 #\/)))) diff --git a/rivet/scribblings/rivet.scrbl b/rivet/scribblings/rivet.scrbl index a1d4e96..ba61b8d 100644 --- a/rivet/scribblings/rivet.scrbl +++ b/rivet/scribblings/rivet.scrbl @@ -658,6 +658,21 @@ last settled check.} [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 f889b42..adea62a 100644 --- a/rivet/tests/public-api.rkt +++ b/rivet/tests/public-api.rkt @@ -75,6 +75,7 @@ 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 + 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 @@ -91,12 +92,14 @@ update-manifest-published-at update-manifest-rollback-allowed? update-manifest-rollout update-manifest-version update-manifest? updater-cancel! updater-check! updater-download! - updater-runtime-check! updater-runtime-config + updater-runtime-box updater-runtime-check! updater-runtime-config updater-runtime-destination-dir updater-runtime-download updater-runtime-download! updater-runtime-fetch - updater-runtime-silent-interval updater-runtime-state + updater-runtime-settings updater-runtime-silent-interval + updater-runtime-state updater-runtime? updater-settings-adapter updater-settings-adapter-get - updater-settings-adapter-set! updater-runtime + updater-settings-adapter-set! updater-settings-adapter? + updater-runtime updater-state updater-config updater-config-application-id updater-config-architecture updater-config-channel diff --git a/rivet/tests/updater-scaffold.rkt b/rivet/tests/updater-scaffold.rkt index 525396a..fbf5280 100644 --- a/rivet/tests/updater-scaffold.rkt +++ b/rivet/tests/updater-scaffold.rkt @@ -35,18 +35,23 @@ ;; --- throttle stamp semantics --------------------------------------------- (define (make-runtime settings #:fetch [fetch #f] #:download [download #f]) - (define kws - (sort (append '(#:config #:destination-dir #:settings) - (if fetch (list '#:fetch) '()) - (if download (list '#:download) '())) - keywordstring - (hash-ref (updater-runtime-state runtime) 'downloaded_path)))) + (hash-ref (updater-runtime-state runtime) 'downloaded_path))) ;; The download progress went through the phase box. (check-true (>= (hash-ref (updater-runtime-state runtime) 'percent) 100)) @@ -114,12 +118,11 @@ [config-old (struct-copy updater-config config [current-version "9.9.9"])] [runtime - (apply make-updater-runtime - `(#:config ,config-old - #:destination-dir - ,(make-temporary-file "rivet-scaffold-~a" 'directory) - #:settings ,settings - #:fetch ,(lambda (public-key) (sample-manifest))))]) + (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) @@ -143,3 +146,32 @@ (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)]) + (check-true (and (exact-integer? first-bucket) (<= 0 first-bucket 99))) + (check-equal? ((updater-settings-adapter-get settings) + "update.rollout-bucket") + (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))) From 163d9930021472ea7c0c3410825ba74cb5f223b5 Mon Sep 17 00:00:00 2001 From: jrtxio Date: Sat, 17 Oct 2026 10:47:37 +0800 Subject: [PATCH 4/8] Add a diagnostic print to the bucket persistence check --- rivet/tests/updater-scaffold.rkt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/rivet/tests/updater-scaffold.rkt b/rivet/tests/updater-scaffold.rkt index fbf5280..9cae90d 100644 --- a/rivet/tests/updater-scaffold.rkt +++ b/rivet/tests/updater-scaffold.rkt @@ -153,11 +153,13 @@ ;; 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)]) + [first-bucket (resolve-rollout-bucket! settings)] + [stored ((updater-settings-adapter-get settings) + "update.rollout-bucket")]) + (eprintf "diagnostic: first-bucket=~a stored=~a\n" + first-bucket stored) (check-true (and (exact-integer? first-bucket) (<= 0 first-bucket 99))) - (check-equal? ((updater-settings-adapter-get settings) - "update.rollout-bucket") - (number->string first-bucket)) + (check-equal? stored (number->string first-bucket)) (check-equal? (resolve-rollout-bucket! settings) first-bucket)) (let ([settings From 14bf11dfab0dab0988ea38cc21e8d1866732f94a Mon Sep 17 00:00:00 2001 From: jrtxio Date: Sat, 17 Oct 2026 11:03:54 +0800 Subject: [PATCH 5/8] Second diagnostic round: direct set/get probe --- rivet/tests/updater-scaffold.rkt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/rivet/tests/updater-scaffold.rkt b/rivet/tests/updater-scaffold.rkt index 9cae90d..3c01ebe 100644 --- a/rivet/tests/updater-scaffold.rkt +++ b/rivet/tests/updater-scaffold.rkt @@ -155,9 +155,17 @@ (let* ([settings (make-fake-settings)] [first-bucket (resolve-rollout-bucket! settings)] [stored ((updater-settings-adapter-get settings) - "update.rollout-bucket")]) - (eprintf "diagnostic: first-bucket=~a stored=~a\n" - first-bucket stored) + "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)]) + (eprintf "diagnostic: first-bucket=~a stored=~a direct-set=~a writer-is-set-proc=~a\n" + first-bucket stored direct-set + (if (writer-binding "diagnostic.direct" "ignored") #t #f)) (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)) From 0ac30f2d2a91506ee016fe981d432966e56f7b63 Mon Sep 17 00:00:00 2001 From: jrtxio Date: Sat, 17 Oct 2026 11:25:33 +0800 Subject: [PATCH 6/8] Third diagnostic: pre-seed the default key and watch resolve recover --- rivet/tests/updater-scaffold.rkt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/rivet/tests/updater-scaffold.rkt b/rivet/tests/updater-scaffold.rkt index 3c01ebe..888b684 100644 --- a/rivet/tests/updater-scaffold.rkt +++ b/rivet/tests/updater-scaffold.rkt @@ -162,10 +162,16 @@ "diagnostic.direct" "yes") ((updater-settings-adapter-get settings) "diagnostic.direct"))] [writer-binding - (updater-settings-adapter-set! settings)]) - (eprintf "diagnostic: first-bucket=~a stored=~a direct-set=~a writer-is-set-proc=~a\n" + (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)) + (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)) From 5fd4efd09b92055a7ce3bc9681964e8a4928181b Mon Sep 17 00:00:00 2001 From: jrtxio Date: Sat, 17 Oct 2026 11:43:57 +0800 Subject: [PATCH 7/8] Expose the bucket writer's real behavior on CI --- rivet/distribution/updater-scaffold.rkt | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/rivet/distribution/updater-scaffold.rkt b/rivet/distribution/updater-scaffold.rkt index e58b16c..39f1de1 100644 --- a/rivet/distribution/updater-scaffold.rkt +++ b/rivet/distribution/updater-scaffold.rkt @@ -127,15 +127,13 @@ ;; calling the adapter. (define write-value! (updater-settings-adapter-set! settings)) (define stored - (let ([value (with-handlers ([exn:fail? (lambda (_) #f)]) (get key))]) - (define number - (and (string? value) (string->number (string-trim value)))) - (and number (exact-integer? number) (<= 0 number 99) number))) + (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)]) - (when write-value! - (with-handlers ([exn:fail? void]) - (write-value! key (number->string bucket)))) + (write-value! key (number->string bucket)) bucket))) (define (artifact-file-name artifact) From 8e93b6c43a1a3ce83015d8112ab4541d710200a2 Mon Sep 17 00:00:00 2001 From: jrtxio Date: Sat, 17 Oct 2026 11:44:35 +0800 Subject: [PATCH 8/8] Surface the persist error message on CI --- rivet/distribution/updater-scaffold.rkt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rivet/distribution/updater-scaffold.rkt b/rivet/distribution/updater-scaffold.rkt index 39f1de1..34b382a 100644 --- a/rivet/distribution/updater-scaffold.rkt +++ b/rivet/distribution/updater-scaffold.rkt @@ -133,7 +133,11 @@ (and number (exact-integer? number) (<= 0 number 99) number))))) (or stored (let ([bucket (draw-rollout-bucket)]) - (write-value! key (number->string 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)