Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@
of entering semantic validation and contradicting the documented trust
order. `read-signed-manifest` remains an explicitly unauthenticated
inspection API; update clients use `verify-signed-manifest`.

- Serialize portable update execution and recovery with an installation-scoped
in-process semaphore plus a target-derived operating-system file lock, even
when callers configure different backup paths. A competing updater now fails
before it can remove or rename shared staging, backup, marker, or journal
state; the persistent empty lock file is not a crash marker. The prepared
installation handle is opaque so callers cannot forge its lock identity.
- Add a Windows `rivet::system::TrayController` that owns tray window
subclassing, localized native menu presentation, command dispatch, and
automatic `TaskbarCreated` recovery. The reference Taskboard now exercises
Expand Down
8 changes: 8 additions & 0 deletions docs/release-and-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ Require `rivet/distribution`. `fetch-update-manifest` verifies before parsing, `

Pass `#:journal-path` to atomically persist every destructive phase. On the next start, reconstruct the same installation and call `recover-platform-installation!`; it accepts only the exact same signed candidate and absolute download/target/backup paths. An interruption before health restores the prior payload. An interruption during the idempotent commit repeats backup cleanup instead of rolling a healthy application back.

Portable execution and recovery share an exclusive lock derived from the
installation target identity, independent of a caller-selected backup path.
The lock serializes threads and processes before
they can touch staging, backup, marker, or journal state; a competing updater
fails explicitly. Its sibling lock file is intentionally persistent and empty:
the operating-system lock on that file carries ownership, so a crash releases
ownership without making stale-file cleanup part of recovery.

The lower-level `make-install-plan`, `execute-install-plan!`, and `recover-install-plan!` remain available for product-specific native installers. `execute-install-plan!` accepts `#:commit` for idempotent post-health cleanup. Elevation stays behind those adapters and never enters RVT1 or the embedded runtime.

- `stable` accepts release SemVer versions only.
Expand Down
84 changes: 71 additions & 13 deletions rivet/distribution/platform-adapter.rkt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"manifest.rkt"
"updater.rkt")

(provide (struct-out platform-installation)
(provide platform-installation?
current-update-platform
current-install-kind
platform-installer-policy
Expand All @@ -22,7 +22,7 @@
execute-platform-installation!
recover-platform-installation!)

(struct platform-installation (plan health-check commit) #:transparent)
(struct platform-installation (plan health-check commit transaction-lock))

(define (current-update-platform)
(case (system-type 'os)
Expand Down Expand Up @@ -139,6 +139,54 @@
(define (sibling-path target suffix)
(string->path (string-append (path->string target) suffix)))

;; OS file locks serialize separate application processes. Keep a matching
;; in-process semaphore because some file-lock implementations are scoped to a
;; process and would otherwise allow two threads to enter the same transaction.
(define transaction-locks (make-hash))
(define transaction-locks-guard (make-semaphore 1))

(define (transaction-semaphore lock-path)
(define key (path->string (normal-case-path lock-path)))
(call-with-semaphore
transaction-locks-guard
(lambda ()
(hash-ref transaction-locks key
(lambda ()
(define lock (make-semaphore 1))
(hash-set! transaction-locks key lock)
lock)))))

(define (call-with-platform-installation-lock who installation thunk)
(define lock-path (platform-installation-transaction-lock installation))
(unless (complete-path? lock-path)
(raise-arguments-error who
"platform installation has no absolute target identity"))
(make-parent-directory* lock-path)
(define local-lock (transaction-semaphore lock-path))
(dynamic-wind
(lambda ()
;; Do not allow an asynchronous break between acquiring the semaphore
;; and installing the dynamic-wind cleanup that releases it.
(parameterize-break
#f
(unless (semaphore-try-wait? local-lock)
(raise-arguments-error
who
"another update transaction already owns this installation"
"lock" lock-path))))
(lambda ()
(call-with-file-lock/timeout
#f
'exclusive
thunk
(lambda ()
(raise-arguments-error
who
"another process already owns this update transaction"
"lock" lock-path))
#:lock-file lock-path))
(lambda () (semaphore-post local-lock))))

(define (normalize-target who path)
(unless (complete-path? path)
(raise-argument-error who "complete-path?" path))
Expand Down Expand Up @@ -283,27 +331,37 @@
#:restart restart
#:rollback rollback)
health-check
commit))
commit
(sibling-path target ".rivet-update.lock")))

(define (execute-platform-installation! installation
#:journal-path [journal-path #f])
(unless (platform-installation? installation)
(raise-argument-error 'execute-platform-installation!
"platform-installation?" installation))
(execute-install-plan!
(platform-installation-plan installation)
#:health-check (platform-installation-health-check installation)
#:commit (platform-installation-commit installation)
#:journal-path journal-path))
(call-with-platform-installation-lock
'execute-platform-installation!
installation
(lambda ()
(execute-install-plan!
(platform-installation-plan installation)
#:health-check (platform-installation-health-check installation)
#:commit (platform-installation-commit installation)
#:journal-path journal-path))))

(define (recover-platform-installation! installation journal-path)
(unless (platform-installation? installation)
(raise-argument-error 'recover-platform-installation!
"platform-installation?" installation))
(recover-install-plan!
(platform-installation-plan installation)
journal-path
#:commit (platform-installation-commit installation)))
(call-with-platform-installation-lock
'recover-platform-installation!
installation
(lambda ()
(recover-install-plan!
(platform-installation-plan installation)
journal-path
#:commit (platform-installation-commit installation)))))

(module+ test-support
(provide detect-install-kind))
(provide detect-install-kind
call-with-platform-installation-lock))
18 changes: 9 additions & 9 deletions rivet/scribblings/rivet.scrbl
Original file line number Diff line number Diff line change
Expand Up @@ -386,13 +386,10 @@ Pairs an accepted manifest with its current-platform artifact.}
[rollback procedure?])]{
Contains the verified artifact and platform-owned lifecycle callbacks.}

@defstruct*[platform-installation
([plan install-plan?]
[health-check procedure?]
[commit procedure?])] {
Contains a first-party portable replacement plan plus its health and
idempotent commit callbacks. Applications normally pass this value to
@racket[execute-platform-installation!] rather than invoking the fields.}
@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
callers cannot bypass the installation invariants.}

@subsection{Versions and Channels}

Expand Down Expand Up @@ -531,14 +528,17 @@ tests; production callers should use the default fail-closed verifier.}
[#:journal-path journal-path (or/c #f path-string?) #f])
any/c]{
Executes the prepared replacement with its restart, health, rollback, and
commit callbacks. A backup is deleted only after health succeeds.}
commit callbacks. A backup is deleted only after health succeeds. Only one
thread or process may execute or recover an installation at a time; a
competing transaction fails before touching shared filesystem state.}

@defproc[(recover-platform-installation!
[installation platform-installation?]
[journal-path path-string?])
(or/c 'committed 'rolled-back)]{
Recovers the exact prepared replacement. Interrupted commits are repeated;
earlier phases restore the previous payload when signed policy permits it.}
earlier phases restore the previous payload when signed policy permits it. The
same installation-scoped lock used by execution protects recovery.}

@section{Command-Line Workflow}

Expand Down
37 changes: 37 additions & 0 deletions tests/platform-update-adapter.rkt
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,43 @@
(string=? (read-text (build-path success-target "version.txt"))
"new"))
#:verify-staged (test-verifier "new")))
(define same-target-different-backup
(prepare-platform-installation
(candidate 'zip) success-archive
#:target success-target
#:backup-path (build-path success-root "alternate-backup")
#:restart void
#:health-check (lambda () #t)
#:verify-staged (test-verifier "new")))

;; A second updater is rejected before it can touch the shared staging,
;; backup, marker, or journal paths. The first holder is a separate thread
;; so this proves the in-process layer instead of relying only on an OS
;; file lock.
(define lock-entered (make-semaphore 0))
(define release-lock (make-semaphore 0))
(define lock-result (make-channel))
(thread
(lambda ()
(with-handlers ([exn? (lambda (error) (channel-put lock-result error))])
(call-with-platform-installation-lock
'test-update-lock
success
(lambda ()
(semaphore-post lock-entered)
(semaphore-wait release-lock)))
(channel-put lock-result 'released))))
(check-not-false (sync/timeout 2 lock-entered))
(check-exn #rx"another update transaction already owns"
(lambda ()
(execute-platform-installation!
same-target-different-backup
#:journal-path success-journal)))
(check-equal? (read-text (build-path success-target "version.txt")) "old")
(check-false (file-exists? success-journal))
(semaphore-post release-lock)
(check-equal? (sync/timeout 2 lock-result) 'released)

(check-equal?
(execute-platform-installation! success #:journal-path success-journal)
'restarted)
Expand Down
4 changes: 1 addition & 3 deletions tests/public-api.rkt
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,11 @@
install-plan-downloaded-path install-plan-install install-plan-restart
install-plan-rollback install-plan? make-install-plan
manifest->payload-bytes manifest-schema-version payload-bytes->manifest
platform-installation platform-installation-commit
platform-installation-health-check platform-installation-plan
platform-installation? platform-installer-policy
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 struct:platform-installation
sha256-file/hex struct:install-plan
struct:update-artifact struct:update-candidate struct:update-manifest
struct:updater-config update-artifact update-artifact-architecture
update-artifact-arguments update-artifact-installer
Expand Down
Loading