fix(launcher): harden updater crash recovery
This commit is contained in:
parent
2d2a5b5046
commit
1955ca8ab5
27 changed files with 2714 additions and 544 deletions
|
|
@ -471,7 +471,8 @@ gate (user): clean-profile first-run against real DATs.
|
|||
verify, unpack to `DataDirectory/app/<version>/`, atomic `current.json`
|
||||
pointer swap, refuse while any session runs, keep previous version for
|
||||
one-step rollback.
|
||||
- Launcher self-update: staged download + rename-dance on next start.
|
||||
- Launcher self-update: staged download + target-local atomic replacement on
|
||||
next start.
|
||||
- Session-config composition targets `app/current`'s binaries.
|
||||
|
||||
**Acceptance:** manifest/download/verify/swap tests against a local HTTP
|
||||
|
|
@ -488,8 +489,11 @@ before doing network, extraction, or activation work.
|
|||
The production feed is pinned to GitHub owner/repository
|
||||
`eriknihlen/acdream`; the launcher reads
|
||||
`https://github.com/eriknihlen/acdream/releases/latest/download/manifest.json`.
|
||||
Tests may inject a loopback HTTP URI, but production artifacts and redirects
|
||||
must use HTTPS. `manifest.json` is:
|
||||
Tests use a separate internal fixture constructor that may admit loopback HTTP;
|
||||
that allowance never propagates to the production feed. Production manifest
|
||||
and artifact URIs use HTTPS. Automatic redirects are disabled and every
|
||||
redirect hop is validated before it is requested; redirect loops, a chain over
|
||||
five hops, and any HTTPS-to-HTTP downgrade are rejected. `manifest.json` is:
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -557,14 +561,18 @@ same-volume directory rename.
|
|||
write-through temporary-file + same-directory atomic rename. The last valid
|
||||
pointer is also atomically preserved as `current.previous.json`; startup may
|
||||
restore that exact backup only when `current.json` is missing/malformed and
|
||||
the referenced version verifies. Orphan LA10 staging directories and pointer
|
||||
temporaries are transaction-owned by exact names and are removed under the
|
||||
update lease. A corrupt installed version is never silently selected; the
|
||||
explicit one-step rollback swaps the two verified pointer versions.
|
||||
the referenced version verifies. Orphan LA10 staging directories, download
|
||||
archives, corrupt-version quarantine directories, and pointer temporaries are
|
||||
transaction-owned by exact lowercase GUID names and are removed only under the
|
||||
exclusive update lease; near-matching user names are preserved. A corrupt
|
||||
installed version is never silently selected; the explicit one-step rollback
|
||||
swaps the two verified pointer versions.
|
||||
|
||||
`DataDirectory/app/.update-session.lock` is the cross-process barrier. Each
|
||||
supervised launcher activity holds a shared OS handle from before executable
|
||||
resolution until terminal process observation; an update/rollback holds the
|
||||
resolution until terminal process observation; launcher disposal requests
|
||||
child termination and does not release that handle until the child is actually
|
||||
observed terminal. An update/rollback holds the
|
||||
exclusive handle for its entire recovery/download/extract/promote/pointer
|
||||
transaction. Failure to acquire the exclusive handle is an immediate refusal,
|
||||
not a wait behind a running session. The open handle, not lock-file contents,
|
||||
|
|
@ -572,11 +580,11 @@ owns the lease and therefore releases after process death.
|
|||
|
||||
Launcher self-update staging lives at
|
||||
`DataDirectory/launcher-update/transactions/<transactionId>/` and the sole
|
||||
durable authority is `DataDirectory/launcher-update/pending.json`:
|
||||
durable authority is `DataDirectory/launcher-update/pending.json` (schema 2):
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"schemaVersion": 2,
|
||||
"transactionId": "0123456789abcdef0123456789abcdef",
|
||||
"state": "staged",
|
||||
"version": "1.2.3",
|
||||
|
|
@ -591,17 +599,62 @@ durable authority is `DataDirectory/launcher-update/pending.json`:
|
|||
}
|
||||
```
|
||||
|
||||
Before mutation a next-start helper copied outside the target directory
|
||||
atomically advances the plan to `applying` and fills `apply` with each path's
|
||||
`hadOriginal` bit. It waits for the initiating launcher PID without invoking a
|
||||
shell, moves originals into the transaction backup tree, then moves verified
|
||||
staged files into place. It never opens a target with truncate/overwrite. On
|
||||
success the plan becomes `awaitingConfirmation`; the new launcher confirms at
|
||||
its first managed instruction, after which backup and plan cleanup is safe. An
|
||||
Before mutation the verified staged launcher becomes the next-start helper and
|
||||
waits for the initiating launcher PID without invoking a shell. It first copies
|
||||
the complete verified payload into the target-local
|
||||
`.acdream-self-update-<transactionId>/incoming/` tree. The plan then advances
|
||||
to `applying`; `apply` is an ordinally sorted union of new payload paths, the
|
||||
owned metadata path, and obsolete paths from the previous ownership record:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "path": "acdream-launcher.exe", "operation": "install", "hadOriginal": true },
|
||||
{ "path": "obsolete.dll", "operation": "remove", "hadOriginal": true }
|
||||
]
|
||||
```
|
||||
|
||||
Existing targets are replaced with one same-filesystem atomic replace whose
|
||||
backup is also target-local. Previously absent noncanonical files use one
|
||||
same-filesystem rename; obsolete owned files use one rename into backup. The
|
||||
canonical launcher path therefore contains either the complete old file or the
|
||||
complete new file at every durable crash boundary. Rollback reverses the same
|
||||
operations atomically and is idempotent after a process/power loss. Linux mode
|
||||
bits come from the verified incoming file. A helper that cannot immediately
|
||||
acquire the exclusive update lease defers the staged plan and exits without
|
||||
restarting the old launcher, preventing restart loops.
|
||||
|
||||
Successful application writes strict target ownership metadata at
|
||||
`<launcher directory>/launcher.install.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"version": "1.2.3",
|
||||
"rid": "win-x64",
|
||||
"files": [
|
||||
{ "path": "acdream-launcher.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The archive may not supply that reserved metadata path. A prior valid record is
|
||||
the only authority for obsolete-file removal; the first managed update does
|
||||
not infer ownership of unrelated legacy files. On success the plan becomes
|
||||
`awaitingConfirmation`; the new launcher confirms at its first managed
|
||||
instruction, after which the helper releases its lease and the confirmed
|
||||
launcher reclaims plan, data-transaction, and target-local residue. An
|
||||
`applying` plan is rolled back before retry, and failure to start/confirm the
|
||||
new launcher restores every original (and removes every no-original target).
|
||||
All plan paths are re-derived/contained under the pinned data root except the
|
||||
target directory, which must equal the actual launcher base directory.
|
||||
Reading `pending.json` never performs cleanup. Ordinary startup attempts the
|
||||
exclusive lease without waiting and skips update cleanup entirely when another
|
||||
session/staging transaction owns it. All plan paths are re-derived/contained
|
||||
under pinned roots; the target directory must equal the actual launcher base
|
||||
directory.
|
||||
|
||||
Every portable archive and persisted relative path rejects Windows device
|
||||
segments on every host: `CON`, `PRN`, `AUX`, `NUL`, `CLOCK$`, `CONIN$`,
|
||||
`CONOUT$`, `COM1`-`COM9`, `LPT1`-`LPT9`, and the Windows-equivalent superscript
|
||||
forms `COM¹`/`COM²`/`COM³` and `LPT¹`/`LPT²`/`LPT³`, including extensions.
|
||||
|
||||
## LA11 — closeout
|
||||
|
||||
|
|
|
|||
|
|
@ -298,19 +298,19 @@ preview would be a deliberate divergence we are NOT taking.
|
|||
install to `DataDirectory/app/<version>/`; atomic pointer swap
|
||||
(`current.json`); never while any session is running; keep the previous
|
||||
version for one-step rollback.
|
||||
- **Launcher self-update:** same feed; staged download; rename-dance swap
|
||||
on next start (a running exe can't replace itself on Windows).
|
||||
- **Launcher self-update:** same feed; staged download; target-local atomic
|
||||
replacement on next start after the running process exits.
|
||||
- **Feed hosting:** GitHub Releases (user-confirmed). Manifest and zips
|
||||
are release assets; the launcher pins the repo/owner in its config.
|
||||
|
||||
The exact v1 manifest, extracted-version record, `current.json` activation
|
||||
pointer, shared-session/exclusive-update OS lease, and durable self-update
|
||||
plan are pinned in
|
||||
pointer and launcher ownership record, shared-session/exclusive-update OS
|
||||
lease, and durable self-update plan schema 2 are pinned in
|
||||
`docs/plans/2026-08-14-launcher-campaign.md` under **Pinned updater
|
||||
contracts (v1, BINDING)**. That section is normative: implementations reject
|
||||
unknown/duplicate fields and unsupported versions, use strict SemVer 2.0
|
||||
precedence, verify bounded streamed downloads before safe ZIP extraction, and
|
||||
derive all mutable staging/backup paths from the application data root. The
|
||||
use per-hop redirect validation plus same-filesystem atomic replacement. The
|
||||
LA9 DAT/pak install record remains the sole content descriptor fed to session
|
||||
configs; LA10 changes only which verified `app/current.json` client binaries
|
||||
the process supervisor executes.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue