Skip to content
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<lang>.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 <file>` (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
Expand Down
2 changes: 2 additions & 0 deletions rivet/distribution.rkt
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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"))
213 changes: 213 additions & 0 deletions rivet/distribution/updater-scaffold.rkt
Original file line number Diff line number Diff line change
@@ -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))
73 changes: 73 additions & 0 deletions rivet/scribblings/rivet.scrbl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions rivet/tests/public-api.rkt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading