|
| 1 | +# Design: park a vat that fails to upgrade, resumable via its admin facet |
| 2 | + |
| 3 | +Part of the XS-validation effort ([kriskowal/garden#33](https://github.com/kriskowal/garden/issues/33)). |
| 4 | +Maintainer requirement ([issue comment](https://github.com/kriskowal/garden/issues/33#issuecomment-4910381116), |
| 5 | +kriskowal, 2026-07-08): *"the ability to park a vat that fails to upgrade such |
| 6 | +that it can be resumed with an explicit upgrade or restart on its admin facet. |
| 7 | +That may be a new pull request."* |
| 8 | + |
| 9 | +Target project: **`kriscendobot/agoric-sdk`** (fork of Agoric/agoric-sdk; upstream |
| 10 | +stays untouched per `roles/COMMON.md` § External-repo etiquette). Companion |
| 11 | +mechanism: the xsnap **legacy/latest variant** split (upstream issue #11030 / |
| 12 | +draft PR #11031, being mirrored into the fork by jobs `xst-mirror-agoric-11031` |
| 13 | +and `xst-mirror-agoric-11297`). Upstream context: Epic #10905 (Upgrade Moddable |
| 14 | +and Native XSnap Worker), prior art #6361 (how to change XS on a deployed |
| 15 | +system). All upstream text consulted read-only, as untrusted data. |
| 16 | + |
| 17 | +**Where this design lives.** Here, in the garden's `designs/`, not the fork — |
| 18 | +recommended because (a) the designer carve-out for agoric-sdk is |
| 19 | +"output-is-the-file, no PR", (b) no mirror branches exist on the fork yet to |
| 20 | +anchor a fork-side design PR, and (c) the garden note survives job-worktree |
| 21 | +teardown and is readable by every follow-on build job. The build PR should carry |
| 22 | +a distilled operator/developer page at `packages/SwingSet/docs/parked-vats.md` |
| 23 | +derived from this note, matching the existing `docs/vat-upgrade.md` style. |
| 24 | + |
| 25 | +## Problem |
| 26 | + |
| 27 | +SwingSet persists each vat as durable state (vatstore, c-lists) plus a heap |
| 28 | +**snapshot** and a **transcript** tail. A vat worker is reconstructed by loading |
| 29 | +the snapshot and replaying the transcript tail with `replayTranscript` |
| 30 | +(`packages/SwingSet/src/kernel/vat-warehouse.js`), and the replay must be |
| 31 | +byte-exact: `makeSyscallSimulator` throws an *anachrophobia* error on any |
| 32 | +wrong/extra/missing syscall. Today the failure handling is maximally harsh, in |
| 33 | +two distinct places: |
| 34 | + |
| 35 | +1. **Worker re-creation / replay failure.** `ensureVatOnline` is called from |
| 36 | + `deliverToVat` and `start()` with `recreate = true` — literally commented |
| 37 | + "PANIC in the failure case". A manager-creation failure calls |
| 38 | + `panic('unable to re-create vat …')`; a replay divergence or snapshot-load |
| 39 | + failure throws through `deliverAndLogToVat`, which rethrows — a **kernel |
| 40 | + panic**, i.e. a halted chain, deterministically on every node. Any XS engine |
| 41 | + change that perturbs one old vat's replay halts everything. |
| 42 | +2. **Vat-upgrade failure.** `processUpgradeVat` (`kernel.js`) delivers a final |
| 43 | + `bringOutYourDead` to the old incarnation, then `startVat` to the new one. If |
| 44 | + either reports failure, `abortUpgrade` unwinds the crank, stops the worker, |
| 45 | + and rejects the upgrade via `vatUpgradeCallback(upgradeID, false, error)` — |
| 46 | + and the **next delivery silently re-creates the OLD incarnation** from |
| 47 | + snapshot + replay. That rollback is only safe while an engine that can |
| 48 | + faithfully resume the old snapshot/transcript still exists. |
| 49 | + |
| 50 | +The variant split (#11031) gives every vat `xsnap({ variant: 'legacy' | 'latest' })`, |
| 51 | +default `legacy`, and anticipates "further work to automatically promote vats |
| 52 | +from legacy to latest if they successfully upgrade." This design is the failure |
| 53 | +half of that promotion story: when the upgrade-to-latest **fails**, the vat must |
| 54 | +degrade gracefully into a **parked** state instead of either halting the chain |
| 55 | +or thrashing between rollback and retry — and later be **resumed** with an |
| 56 | +explicit `upgrade` or `restart` on its admin facet, once a fixed XS or a fixed |
| 57 | +bundle ships. The bootstrap vat, which is non-upgradable (Epic #10905), makes |
| 58 | +the panic path especially costly: today it has no graceful failure mode at all. |
| 59 | + |
| 60 | +## Park semantics |
| 61 | + |
| 62 | +A new per-vat kernel state, **parked**, sibling to *terminated* but fully |
| 63 | +reversible: |
| 64 | + |
| 65 | +- **State keys** (mirroring `vats.terminated` / `markVatAsTerminated` in |
| 66 | + `kernelKeeper.js`): a `vats.parked` array, plus `${vatID}.parked` holding |
| 67 | + `{ reason, phase: 'upgrade' | 'replay' | 'explicit', incarnation, crankNum }`. |
| 68 | + No wall-clock time (consensus determinism); a kernel-schema bump in |
| 69 | + `upgradeSwingset.js` seeds the empty set. |
| 70 | +- **Everything is retained.** kvStore (vatstore, c-lists), transcript spans, |
| 71 | + snapshots, meter, options, incarnation number. `vatIsAlive` stays true; a new |
| 72 | + `vatIsParked` predicate distinguishes it. Exports are *not* orphaned; promises |
| 73 | + the vat decides remain unresolved. |
| 74 | +- **No worker.** The worker is evicted (`stopWorker`); `ensureVatOnline` refuses |
| 75 | + parked vats; BOYD/reap scheduling, snapshot scheduling, and warehouse preload |
| 76 | + (`start()`) all skip parked vats. |
| 77 | +- **Deliveries are deferred, not refused.** A per-vat **park queue** |
| 78 | + (`${vatID}.parkQueue`, reusing kernelKeeper's queue helpers) holds run-queue |
| 79 | + events routed to a parked vat — `send`s whose target object it owns |
| 80 | + (`routeSendEvent`), `notify`s for which it is subscriber, GC deliveries |
| 81 | + suppressed. Refcounts are held while queued. On resume the queue drains |
| 82 | + FIFO onto the acceptance queue ahead of new traffic. **Caller-observable |
| 83 | + semantics: a parked vat is indistinguishable from a very slow vat** — no new |
| 84 | + error contract leaks to senders; result promises simply do not settle until |
| 85 | + resume. (Considered and rejected: splat like `'vat terminated'` — destroys |
| 86 | + messages and forces every client to grow retry logic; leaving events on the |
| 87 | + shared run queue — head-of-line blocks the whole kernel, and the run/acceptance |
| 88 | + queues at `kernelKeeper.js` are global FIFOs with no per-vat lanes.) |
| 89 | +- **Terminable.** `terminateWithFailure` is allowed on a parked vat (relaxing |
| 90 | + `assertRunningVat` in `vat-vat-admin.js`), so a vat nobody will ever fix can |
| 91 | + still be given up on; termination drains the park queue by splatting, then |
| 92 | + proceeds as today (`terminateVat`, cleanup budget machinery). |
| 93 | +- **Interaction with snapshot/transcript machinery.** Parking freezes the vat at |
| 94 | + its last committed delivery: the current transcript span and last snapshot |
| 95 | + remain the resume point for `restart`, and are discarded wholesale by |
| 96 | + `beginNewWorkerIncarnation` on resume-by-`upgrade`, exactly as in a normal |
| 97 | + upgrade. The snapshot records (or is keyed to) the xsnap **variant** that |
| 98 | + wrote it, so a resume always re-launches the worker on the engine that can |
| 99 | + read it. |
| 100 | + |
| 101 | +```mermaid |
| 102 | +stateDiagram-v2 |
| 103 | + running --> parked : upgrade fails (policy park) |
| 104 | + running --> parked : replay diverges / worker unrecreatable |
| 105 | + running --> parked : explicit park (phase 2) |
| 106 | + parked --> running : adminNode.upgrade(bundlecap) — new incarnation, no replay |
| 107 | + parked --> replaying : adminNode.restart() — snapshot + replay |
| 108 | + replaying --> running : replay succeeds, park queue drains |
| 109 | + replaying --> parked : diverges again (re-park, no panic) |
| 110 | + parked --> terminated : terminateWithFailure |
| 111 | + running --> terminated : today's paths (unchanged) |
| 112 | +``` |
| 113 | + |
| 114 | +## Detection: routing failure to parking instead of panic |
| 115 | + |
| 116 | +Three hook points, in priority order: |
| 117 | + |
| 118 | +1. **Failed `adminNode.upgrade()`** — `processUpgradeVat`'s two `abortUpgrade` |
| 119 | + branches (BOYD-terminate, startVat-terminate). A new policy option |
| 120 | + `onUpgradeFailure: 'rollback' | 'park'` (recorded vat option, settable via |
| 121 | + `changeOptions` and overridable per-call in `upgrade()` options; default |
| 122 | + `'rollback'` preserves today's behavior). Under `'park'`, the crank still |
| 123 | + aborts/unwinds, but the CrankResults gain a `park: { vatID, info }` field |
| 124 | + processed **after** `abortCrank` — the same post-abort pattern that lets |
| 125 | + `terminate` survive the unwind today. `vatUpgradeCallback(upgradeID, false, |
| 126 | + error)` still fires, so the upgrader's promise still rejects; the error notes |
| 127 | + the vat is now parked rather than rolled back. |
| 128 | +2. **Worker re-create / replay failure** — the `panic` paths under |
| 129 | + `ensureVatOnline` (manager-creation rejection, `replayTranscript`'s |
| 130 | + anachrophobia throw, snapshot-load failure). For **non-critical** vats, |
| 131 | + catch in `deliverToVat` / warehouse `start()`: record the park, abort the |
| 132 | + crank, and move the triggering delivery to the park queue. Divergence is |
| 133 | + deterministic (same transcript, same engine on every node), so the parking |
| 134 | + decision is consensus-safe. **Critical vats keep panic semantics** in v1 |
| 135 | + (`vatKeeper.getOptions().critical`) — a chain missing a critical vat is |
| 136 | + presumed nonfunctional, and silently parking one would be worse than halting. |
| 137 | +3. **Explicit park** (phase 2, optional): `E(adminNode).park(reason)` and a |
| 138 | + host-side `controller.parkVat(vatID)`, for preemptively quiescing a vat ahead |
| 139 | + of a risky chain upgrade. |
| 140 | + |
| 141 | +Out of scope: vat-level faults during normal execution (hard metering faults, |
| 142 | +illegal syscalls, liveslots errors — the non-`'ok'` branch of |
| 143 | +`deliverAndLogToVat`). Those indicate broken vat *code*, not a broken *engine*, |
| 144 | +and today's termination semantics remain right for them. |
| 145 | + |
| 146 | +## Admin-facet surface and authority model |
| 147 | + |
| 148 | +The resume verbs live on the existing per-vat `adminNode` exo in |
| 149 | +`vat-vat-admin.js` (`makeAdminNode`), guarded today by `assertRunningVat`: |
| 150 | + |
| 151 | +- **`upgrade(bundlecap, options)`** — existing method, relaxed to accept parked |
| 152 | + vats. Resume-by-upgrade: `beginNewWorkerIncarnation` discards snapshot and |
| 153 | + transcript, the new incarnation boots from durable state (baggage) via |
| 154 | + `startVat` on a fresh worker — **no replay of the old-engine transcript**, so |
| 155 | + this is the intended recovery once a fixed bundle or fixed XS ships. One |
| 156 | + deviation from the normal upgrade sequence: the pre-upgrade |
| 157 | + `bringOutYourDead` to the old incarnation is **skipped** (it cannot run — that |
| 158 | + is why the vat is parked); the code comment in `processUpgradeVat` already |
| 159 | + anticipates making BOYD optional "if a vat is so broken it can't do BOYD". |
| 160 | + Kernel-side promise disconnection and non-durable-export abandonment still |
| 161 | + run. On success the park queue drains into the new incarnation (its objects |
| 162 | + are the same krefs; upgrade-disconnection semantics for in-flight promises |
| 163 | + apply as in any upgrade). |
| 164 | +- **`restart()`** — new method (the maintainer's "restart" verb). Clears the |
| 165 | + parked flag and lets the normal `ensureVatOnline` path re-create the worker |
| 166 | + from snapshot + replay on the vat's recorded variant, then drains the park |
| 167 | + queue. Right when the cause was engine-environmental and has been fixed |
| 168 | + engine-side (a patched legacy binary restoring compatibility, an explicit park |
| 169 | + being lifted). If replay diverges again the vat **re-parks** (detection hook 2) |
| 170 | + — each retry costs one failed replay, never a panic. |
| 171 | +- **`parkStatus()`** — new query returning |
| 172 | + `{ parked, reason, phase, incarnation }`, via a `vatAdminHooks` kernel hook |
| 173 | + (the same device-hook route `terminateWithFailure` uses). Parking does *not* |
| 174 | + settle `done()` — the vat is not dead. |
| 175 | + |
| 176 | +**Authority model: unchanged.** Whoever holds the `adminNode` (returned by |
| 177 | +`E(vatAdminService).createVat`) holds park-resume authority — no new authority |
| 178 | +is minted, parking just makes the existing facet's `upgrade` meaningful on a vat |
| 179 | +that would previously have taken the chain down with it. On Agoric mainnet that |
| 180 | +holder is typically Zoe (contract vats — surfaced to governance via Zoe's |
| 181 | +adminFacet / `restartContract` null-upgrade machinery) or bootstrap-held vats |
| 182 | +driven by core-eval. **Static vats have no adminNode**: their resume path is the |
| 183 | +host/controller surface — the existing `controller.upgradeStaticVat(vatName, |
| 184 | +shouldPauseFirst, bundleID, options)` (which routes through the vatAdmin vat's |
| 185 | +same `upgradeVat` path) plus a new `controller.restartVat(vatID)`; on chain, |
| 186 | +both are reachable through core-eval governance. The **bootstrap vat** remains |
| 187 | +non-upgradable: for it, parking converts a fatal engine divergence into a |
| 188 | +degraded-but-alive chain (bootstrap is mostly quiescent post-bootstrap; its park |
| 189 | +queue holds any stray traffic), and `restart()` after an engine-side fix is its |
| 190 | +only resume path. That honest limit — parked-bootstrap has no upgrade escape — |
| 191 | +is a feature gap this design surfaces but does not solve; #10905 owns it. |
| 192 | + |
| 193 | +## Composition with the legacy/latest variant split |
| 194 | + |
| 195 | +- `WorkerOptions` (type `'xsnap'`, `makeWorkerOptions` / |
| 196 | + `updateWorkerOptions` in `src/lib/workerOptions.js`) gains a `variant` |
| 197 | + field, plumbed through `manager-subprocess-xsnap.js` → `startXSnap` → the |
| 198 | + `xsnap()` constructor option from #11031. Existing vats default `'legacy'`. |
| 199 | +- A **legacy vat resumed from a snapshot** never sees the new engine: rollback |
| 200 | + after a failed upgrade re-creates the old incarnation on its recorded legacy |
| 201 | + variant. This is precisely why `'rollback'` stays a safe default *while the |
| 202 | + legacy binary ships*. |
| 203 | +- An **upgrade may switch variant** (an `upgrade()` option, defaulting to |
| 204 | + promotion `legacy → latest` per #11031's stated intent): the new incarnation |
| 205 | + starts on the target variant with no old-engine replay. On failure under |
| 206 | + `'park'`, the vat parks with its legacy-written snapshot intact, and both |
| 207 | + resume verbs remain live: `restart()` back onto legacy, or a later |
| 208 | + `upgrade()` retry onto latest. |
| 209 | +- The endgame this enables: a chain software upgrade ships a new latest xsnap; |
| 210 | + legacy vats are untouched (no divergence risk); vats are promoted one at a |
| 211 | + time via their admin facets; each failure parks that one vat instead of |
| 212 | + halting the chain; the release-validation gauntlet shrinks from "every vat |
| 213 | + replays clean" to "parked vats get fixes, at leisure". |
| 214 | + |
| 215 | +## Test plan |
| 216 | + |
| 217 | +SwingSet unit tests (patterns from `test/upgrade/upgrade.test.js`): |
| 218 | +park-on-failed-upgrade under `'park'` vs rollback under default; sends/notifies |
| 219 | +to a parked vat deferring and draining in order on resume; resume-by-`upgrade` |
| 220 | +(baggage intact, no replay) and by-`restart` (replay) including re-park on |
| 221 | +repeat divergence; replay-divergence parking via a doctored transcript entry; |
| 222 | +critical-vat panic preserved; `terminateWithFailure` on a parked vat; |
| 223 | +`parkStatus`; kernel-schema upgrade seeding. Variant-composition tests (need the |
| 224 | +#11031 mirror): a fixture vat whose replay diverges under `latest` parks and |
| 225 | +then resumes by upgrade onto `latest`. Slog assertions for new `vat-parked` / |
| 226 | +`vat-resumed` events. |
| 227 | + |
| 228 | +## Path to a PR (follow-on build jobs) |
| 229 | + |
| 230 | +A **new PR on `kriscendobot/agoric-sdk`** (suggested branch |
| 231 | +`kriskowal-park-on-upgrade-failure`, base `master`), independent of the #11297 |
| 232 | +xsnap-bump mirror and only *test-coupled* to the #11031 mirror. Recommended |
| 233 | +decomposition, orchestrated serially: |
| 234 | + |
| 235 | +1. **`xst-park-on-fail-build-kernel`** — park state + park queue + routing + |
| 236 | + detection hooks 1–2 + adminNode `restart`/`parkStatus` + controller surface + |
| 237 | + schema bump + unit tests + `packages/SwingSet/docs/parked-vats.md`. One PR. |
| 238 | +2. **`xst-park-on-fail-build-variant`** — after the #11031 mirror lands on the |
| 239 | + fork: `WorkerOptions.variant` plumbing, upgrade-time variant switch, |
| 240 | + snapshot-variant keying, the divergence fixture test. Could be a second |
| 241 | + commit series on the same PR or a stacked PR. |
| 242 | + |
| 243 | +## Open questions |
| 244 | + |
| 245 | +- Should `onUpgradeFailure: 'park'` become the *default* when an upgrade |
| 246 | + targets the `latest` variant? (Recommend: explicit option in v1; flip the |
| 247 | + default in the chain-software-upgrade release that removes any legacy-rollback |
| 248 | + guarantee.) |
| 249 | +- May a **critical** vat ever park instead of panic? (Recommend: no in v1; |
| 250 | + revisit for the bootstrap vat specifically, which is both critical-in-spirit |
| 251 | + and non-upgradable — parking it is strictly better than halting only if the |
| 252 | + chain can limp without it, a chain-operations judgment.) |
| 253 | +- Park-queue growth: a busy parked vat accumulates refcount-holding queue |
| 254 | + entries without bound. Cap, or metric-only? (Recommend: metric + slog only in |
| 255 | + v1, with `terminateWithFailure` as the relief valve.) |
| 256 | +- Does the adminNode need a push notification on park (a `vatParkedCallback` |
| 257 | + sibling to `vatUpgradeCallback`), or does the rejected `upgrade()` promise |
| 258 | + plus poll-able `parkStatus()` suffice for v1? |
| 259 | +- Naming: `parked` chosen (the maintainer's word); `paused` collides with |
| 260 | + vatAdminService's own `pauseService` self-upgrade machinery in |
| 261 | + `vat-vat-admin.js`. |
0 commit comments