diff --git a/docs/release-and-updates.md b/docs/release-and-updates.md index c3c9fc4..b168f28 100644 --- a/docs/release-and-updates.md +++ b/docs/release-and-updates.md @@ -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 diff --git a/rivet/distribution/managed-updater.rkt b/rivet/distribution/managed-updater.rkt index fa53ecb..012c767 100644 --- a/rivet/distribution/managed-updater.rkt +++ b/rivet/distribution/managed-updater.rkt @@ -6,6 +6,7 @@ (require racket/async-channel racket/file + racket/random racket/match racket/path racket/string @@ -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 @@ -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 (_) @@ -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) @@ -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 @@ -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)) @@ -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)) diff --git a/rivet/scribblings/rivet.scrbl b/rivet/scribblings/rivet.scrbl index a9c1882..692a695 100644 --- a/rivet/scribblings/rivet.scrbl +++ b/rivet/scribblings/rivet.scrbl @@ -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?)]{ diff --git a/rivet/tests/managed-updater.rkt b/rivet/tests/managed-updater.rkt index ce51ead..fa29d20 100644 --- a/rivet/tests/managed-updater.rkt +++ b/rivet/tests/managed-updater.rkt @@ -29,7 +29,11 @@ #:check-timeout [check-timeout 1.0] #:stall-timeout [stall-timeout 1.0] #:total-timeout [total-timeout 2.0] - #:fetch [fetch fake-fetch]) + #:fetch [fetch fake-fetch] + #:silent-throttle-seconds [silent-throttle-seconds + (* 4 60 60)] + #:settings-get [settings-get #f] + #:settings-set! [settings-set! #f]) (parameterize ([current-managed-fetch-manifest fetch] [current-managed-download download]) (make-updater @@ -45,7 +49,19 @@ #:rollout-bucket 17 #:check-timeout check-timeout #:stall-timeout stall-timeout - #:total-timeout total-timeout))) + #:total-timeout total-timeout + #:silent-throttle-seconds silent-throttle-seconds + #:settings-get settings-get + #:settings-set! settings-set!))) + +;; A plain in-memory settings store behind the managed interface: string +;; keys, arbitrary values, durable-enough semantics for tests. +(define (make-settings-store) + (define table (make-hash)) + (values + (lambda (key) (hash-ref table key #f)) + (lambda (key value) (hash-set! table key value)) + table)) (define root (make-temporary-file "rivet-managed-updater-~a" 'directory)) (dynamic-wind @@ -186,5 +202,118 @@ (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")) + (check-equal? (hash-ref (updater-state interrupted) 'phase) "error") + + ;; ---- Silent-check throttle (issue #251) ---- + ;; + ;; "Checked" means the check settled: an answer or a definitive failure. + ;; The stamp lives behind the host's settings interface, so hosts stop + ;; hand-rolling timestamps in three languages. + (let* ([settings-mismatch-updater + (lambda () + (make-test-updater root successful-download + #:settings-get (lambda (key) #f)))] + [fetch-count (box 0)] + [counting-fetch + (lambda _ + (set-box! fetch-count (add1 (unbox fetch-count))) + manifest)] + [failing-fetch (lambda _ (error 'fetch "connection refused"))] + [broken-set! (lambda (key value) (error 'settings "disk full"))] + [interfaceless (make-test-updater root successful-download)]) + (let-values ([(store-get store-set! store-table) + (make-settings-store)] + [(broken-get _broken-set! _broken-table) + (make-settings-store)] + [(bucket-get bucket-set! bucket-table) + (make-settings-store)]) + (let ([throttled-updater + (make-test-updater root successful-download + #:settings-get store-get + #:settings-set! store-set!)] + [counting-updater + (make-test-updater root successful-download + #:fetch counting-fetch + #:silent-throttle-seconds 3600 + #:settings-get store-get + #:settings-set! store-set!)] + [failing-updater + (make-test-updater root successful-download + #:fetch failing-fetch + #:settings-get store-get + #:settings-set! store-set!)] + [broken-updater + (make-test-updater root successful-download + #:settings-get broken-get + #:settings-set! broken-set!)]) + (check-exn #rx"settings-get and settings-set! must be provided together" + (lambda () (settings-mismatch-updater))) + ;; A fresh installation is due; a settled check stamps exactly once. + (check-true (updater-silent-check-due? throttled-updater)) + (check-equal? + (hash-ref (updater-silent-check! throttled-updater) 'status) + "available") + (check-true + (exact-integer? (read-silent-check-stamp throttled-updater))) + (check-equal? (hash-ref store-table silent-check-stamp-key) + (read-silent-check-stamp throttled-updater)) + (check-false (updater-silent-check-due? throttled-updater)) + + ;; A throttled check never touches the network. + (check-equal? + (hash-ref (updater-silent-check! counting-updater) 'status) + "throttled") + (check-equal? (unbox fetch-count) 0) + + ;; A definitive transport failure settles the check: the error + ;; result is returned and the stamp is still written, so an outage + ;; cannot be retried in a tight loop. + (hash-clear! store-table) + (check-equal? + (hash-ref (updater-silent-check! failing-updater) 'status) "error") + (check-true + (exact-integer? (read-silent-check-stamp failing-updater))) + (check-false (updater-silent-check-due? failing-updater)) + + ;; An expired stamp is due again. + (hash-set! store-table silent-check-stamp-key + (- (current-seconds) (* 4 60 60))) + (check-true (updater-silent-check-due? failing-updater)) + + ;; A broken settings store cannot defeat the throttle: the memory + ;; fallback keeps silent checks bounded for the process lifetime. + (check-true (updater-silent-check-due? broken-updater)) + (check-equal? + (hash-ref (updater-silent-check! broken-updater) 'status) + "available") + (check-true + (exact-integer? (read-silent-check-stamp broken-updater))) + (check-false (updater-silent-check-due? broken-updater)) + + ;; Silent checks without a settings interface are a hard error. + (check-exn #rx"silent checks require a settings interface" + (lambda () (updater-silent-check-due? interfaceless))) + (check-exn #rx"silent checks require a settings interface" + (lambda () (updater-silent-check! interfaceless))) + + ;; ---- Sticky rollout bucket (issue #240 glue) ---- + (for ([_i (in-range 20)]) + (check-true (<= 0 (draw-rollout-bucket) 99))) + (define first-bucket (resolve-rollout-bucket! bucket-get bucket-set!)) + (check-true (and (exact-integer? first-bucket) + (<= 0 first-bucket 99) #t)) + (check-equal? (hash-ref bucket-table "update-rollout-bucket") + first-bucket) + ;; Sticky: the stored bucket wins on the next run. + (check-equal? (resolve-rollout-bucket! bucket-get bucket-set!) + first-bucket) + ;; Corrupt or out-of-range values redraw instead of propagating. + (hash-set! bucket-table "update-rollout-bucket" 200) + (check-true (<= 0 (resolve-rollout-bucket! bucket-get bucket-set!) 99)) + (check-false + (equal? (hash-ref bucket-table "update-rollout-bucket") 200)) + ;; A store that cannot persist degrades to an unpersisted draw. + (check-true + (let ([bucket (resolve-rollout-bucket! #f #f)]) + (and (exact-integer? bucket) (<= 0 bucket 99)))))))) (lambda () (delete-directory/files root))) diff --git a/rivet/tests/public-api.rkt b/rivet/tests/public-api.rkt index e9ae03e..0125645 100644 --- a/rivet/tests/public-api.rkt +++ b/rivet/tests/public-api.rkt @@ -94,7 +94,9 @@ 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? rfc3339-now + updater-silent-check! updater-silent-check-due? updater-wait + updater? rfc3339-now + draw-rollout-bucket resolve-rollout-bucket! silent-check-stamp-key valid-channel? verify-platform-payload! verify-signed-manifest verify-update-artifact! version-compare version<=? version=? version>? version? write-signed-manifest))