Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion docs/release-and-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,20 @@ 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,
rollouts unstable. `resolve-rollout-bucket!` recovers that sticky value from
the host's settings store (drawing and persisting a fresh one on first run)
and `draw-rollout-bucket` draws from OS entropy, since `racket/base`'s PRNG
has a fixed seed.

Hosts that poll for updates silently should stop hand-rolling timestamps:
provide `#:settings-get`/`#:settings-set!` to `make-updater` and call
`updater-silent-check!` instead of `updater-check!` on the background path.
The throttle stamp lives behind that settings interface under
`silent-check-stamp-key`, and "checked" means the check *settled* — an
answer or a definitive failure. A throttled check never touches the network;
a settled check always stamps before returning, so an outage cannot be
retried in a tight loop; and an offline launch that never settles (external
interruption) does not buy four silent hours without an actual answer. 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
Expand Down
132 changes: 128 additions & 4 deletions rivet/distribution/managed-updater.rkt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

(require racket/async-channel
racket/file
racket/random
racket/match
racket/path
racket/string
Expand All @@ -20,6 +21,11 @@
make-updater
updater-state
updater-check!
updater-silent-check-due?
updater-silent-check!
silent-check-stamp-key
draw-rollout-bucket
resolve-rollout-bucket!
updater-download!
updater-cancel!
updater-wait
Expand All @@ -31,12 +37,24 @@
rollout-bucket maximum-manifest-bytes maximum-download-bytes
check-timeout stall-timeout total-timeout
fetch-manifest download
lock snapshot candidate operation worker cancel-reason)
lock snapshot candidate operation worker cancel-reason
silent-throttle-seconds settings-get settings-set!
silent-stamp-memory)
#:mutable)

(define current-managed-fetch-manifest (make-parameter fetch-update-manifest))
(define current-managed-download (make-parameter download-update))

;; The family contract: one silent check per ~4 hours. The stamp is owned by
;; this module and persisted behind the host's settings interface — hosts
;; stop hand-rolling timestamps in three languages. "Checked" means the
;; check *settled*: an answer (up-to-date, available) or a definitive
;; failure. A check interrupted from outside (break) has not settled and
;; leaves the stamp untouched, so an offline launch does not buy four
;; silent hours without an actual answer.
(define default-silent-throttle-seconds (* 4 60 60))
(define silent-check-stamp-key "update-last-check")

(define (update-architecture-for machine)
(with-handlers ([exn:fail:contract?
(lambda (_)
Expand Down Expand Up @@ -103,7 +121,11 @@
[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)])
#:total-timeout [total-timeout (* 30.0 60.0)]
#:silent-throttle-seconds
[silent-throttle-seconds default-silent-throttle-seconds]
#:settings-get [settings-get #f]
#:settings-set! [settings-set! #f])
(unless (and (string? application-id) (not (string=? application-id "")))
(raise-argument-error 'make-updater "non-empty string?" application-id))
(unless (version? current-version)
Expand Down Expand Up @@ -142,6 +164,20 @@
(raise-argument-error 'make-updater
(format "positive real? for ~a" name)
value)))
(unless (positive-real? silent-throttle-seconds)
(raise-argument-error 'make-updater "positive real?" silent-throttle-seconds))
(unless (if settings-get settings-set! (not settings-set!))
(raise-arguments-error
'make-updater
"settings-get and settings-set! must be provided together"
"settings-get" (if settings-get "a procedure" "#f")
"settings-set!" (if settings-set! "a procedure" "#f")))
(when settings-get
(unless (and (procedure-arity-includes? settings-get 1)
(procedure-arity-includes? settings-set! 2))
(raise-argument-error 'make-updater
"settings-get of arity 1 and settings-set! of arity 2"
settings-get)))
(updater (string->immutable-string application-id)
(string->immutable-string current-version)
channel platform architecture
Expand All @@ -156,7 +192,9 @@
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))
(make-semaphore 1) (initial-state current-version) #f #f #f #f
silent-throttle-seconds settings-get settings-set!
(box #f)))

(define (with-updater-lock instance thunk)
(call-with-semaphore (updater-lock instance) thunk))
Expand Down Expand Up @@ -460,8 +498,94 @@
(updater-state instance)]
[else #f]))

;; ---- Silent-check throttle (issue #251) ----
;;
;; The stamp lives behind the host's settings interface so each product keeps
;; its own persistence (config file, SQLite, Keychain-backed store). The
;; contract with the settings functions: settings-get receives a key (string
;; or symbol) and returns the stored value or #f; settings-set! writes a
;; value and persists it durably before returning. This module stores plain
;; unix-seconds integers for the stamp and an integer 0-99 for the rollout
;; bucket; hosts whose store is string-only serialize on their side of the
;; interface. A stamp written but unreadable, or a failed stamp write, falls
;; back to a process-lifetime memory stamp so a broken store cannot turn
;; into a tight silent-check loop.

(define (read-silent-check-stamp instance)
(or (and (updater-settings-get instance)
(let ([value ((updater-settings-get instance)
silent-check-stamp-key)])
(and (exact-integer? value) value)))
(and (updater-silent-stamp-memory instance)
(unbox (updater-silent-stamp-memory instance)))))

(define (write-silent-check-stamp! instance stamp)
(when (updater-settings-set! instance)
(with-handlers ([exn:fail? void])
((updater-settings-set! instance) silent-check-stamp-key stamp)))
(when (updater-silent-stamp-memory instance)
(set-box! (updater-silent-stamp-memory instance) stamp)))

(define (updater-silent-check-due? instance)
(unless (updater? instance)
(raise-argument-error 'updater-silent-check-due? "updater?" instance))
(unless (updater-settings-get instance)
(raise-arguments-error
'updater-silent-check-due?
"silent checks require a settings interface; provide #:settings-get and #:settings-set!"))
(define stamp (read-silent-check-stamp instance))
(or (not stamp)
(>= (current-seconds)
(+ stamp (floor (updater-silent-throttle-seconds instance))))))

(define (updater-silent-check! instance)
;; One gate, one stamp write, in the right order: a throttled check never
;; touches the network, and a settled check (answer or definitive error
;; result) always records the stamp before returning. updater-check!
;; returns an error result instead of raising for transport failures, so
;; an outage settles the check exactly once instead of retrying in a
;; tight loop. An outside break propagates without stamping.
(unless (updater? instance)
(raise-argument-error 'updater-silent-check! "updater?" instance))
(unless (updater-settings-get instance)
(raise-arguments-error
'updater-silent-check!
"silent checks require a settings interface; provide #:settings-get and #:settings-set!"))
(if (not (updater-silent-check-due? instance))
(immutable-json-value
(hasheq 'status "throttled"
'currentVersion (updater-current-version instance)))
(let ([result (updater-check! instance)])
(write-silent-check-stamp! instance (current-seconds))
result)))

;; ---- Sticky rollout bucket (issue #240 glue) ----

(define (draw-rollout-bucket)
;; racket/base's PRNG has a fixed seed; draw from the OS entropy pool.
(modulo (integer-bytes->integer (crypto-random-bytes 4) #t #t) 100))

(define (resolve-rollout-bucket! settings-get settings-set! [key #f])
;; Recover this installation's sticky 0-99 bucket, drawing and persisting
;; a fresh one on first run. A broken store degrades to an unpersisted
;; draw rather than an error; rollouts remain stable for the process
;; lifetime in that case.
(define bucket-key (or key "update-rollout-bucket"))
(define stored
(and settings-get
(let ([value (with-handlers ([exn:fail? (lambda (_) #f)])
(settings-get bucket-key))])
(and (exact-integer? value) (<= 0 value 99) value))))
(or stored
(let ([bucket (draw-rollout-bucket)])
(when settings-set!
(with-handlers ([exn:fail? void])
(settings-set! bucket-key bucket)))
bucket)))

(module+ test-support
(provide current-managed-fetch-manifest
current-managed-download
default-destination
update-architecture-for))
update-architecture-for
read-silent-check-stamp))
35 changes: 35 additions & 0 deletions rivet/scribblings/rivet.scrbl
Original file line number Diff line number Diff line change
Expand Up @@ -874,6 +874,41 @@ hash. The stable phase vocabulary is @racket["idle"], @racket["checking"],
or cancelled download retains its authenticated candidate so an application
may explicitly retry; starting a new check resets it.}

@defproc[(updater-silent-check! [instance updater?]) hash?]{
Runs the gated silent check: a check within the last
@racket[#:silent-throttle-seconds] window (default four hours) returns
@racketcode[(hasheq 'status "throttled")] without touching the network. A due
check settles first — an answer or a definitive error result — and only then
persists the throttle stamp behind the updater's settings interface, so an
outage cannot be retried in a tight loop and an offline launch does not buy
four silent hours without an actual answer. Requires
@racket[#:settings-get] and @racket[#:settings-set!] on the updater.}

@defproc[(updater-silent-check-due? [instance updater?]) boolean?]{
Reports whether the silent-check throttle window has elapsed since the last
settled check, or no stamp exists. Also requires the settings interface.}

@defthing[silent-check-stamp-key string?]{
The settings key under which the managed updater persists its silent-check
throttle stamp (a unix-seconds integer). Hosts whose settings store uses
symbol keys or string serialization translate on their side of the
@racket[#:settings-get]/@racket[#:settings-set!] boundary; the value stored
for this key is always an integer.}

@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-get (-> any/c any/c)]
[settings-set! (-> any/c any/c any/c)]
[key any/c "update-rollout-bucket"])
(integer-in 0 99)]{
Recovers this installation's sticky rollout bucket from the settings store,
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[make-updater] as @racket[#:rollout-bucket].}

@defproc[(updater-wait [instance updater?]
[timeout (or/c #f (and/c real? (not/c negative?))) #f])
(or/c #f hash?)]{
Expand Down
Loading
Loading