diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc87cb..6f29802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/release-and-updates.md b/docs/release-and-updates.md index a49c43b..10f5668 100644 --- a/docs/release-and-updates.md +++ b/docs/release-and-updates.md @@ -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. diff --git a/rivet/distribution/platform-adapter.rkt b/rivet/distribution/platform-adapter.rkt index ee1dc59..30a2ef8 100644 --- a/rivet/distribution/platform-adapter.rkt +++ b/rivet/distribution/platform-adapter.rkt @@ -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 @@ -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) @@ -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)) @@ -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)) diff --git a/rivet/scribblings/rivet.scrbl b/rivet/scribblings/rivet.scrbl index d76b3e8..22c7b84 100644 --- a/rivet/scribblings/rivet.scrbl +++ b/rivet/scribblings/rivet.scrbl @@ -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} @@ -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} diff --git a/tests/platform-update-adapter.rkt b/tests/platform-update-adapter.rkt index 3c70e1e..f71e925 100644 --- a/tests/platform-update-adapter.rkt +++ b/tests/platform-update-adapter.rkt @@ -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) diff --git a/tests/public-api.rkt b/tests/public-api.rkt index c573b94..59b1313 100644 --- a/tests/public-api.rkt +++ b/tests/public-api.rkt @@ -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