Skip to content

Commit 3485005

Browse files
endolinbotclaude
andcommitted
xst-park-on-fail-design: park a failed-upgrade vat, resumable via its admin facet
Design note for the XS-validation effort (#33): a new reversible 'parked' vat state in SwingSet, detection hooks that route upgrade failures and replay divergence to parking instead of rollback or kernel panic, adminNode upgrade/restart resume verbs, and the composition with the xsnap legacy/latest variant split (#11030/#11031). Recommends a new PR on kriscendobot/agoric-sdk in two build stages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e268c4f commit 3485005

1 file changed

Lines changed: 261 additions & 0 deletions

File tree

designs/xst-park-on-fail.md

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
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

Comments
 (0)