diff --git a/packages/SwingSet/tools/bootstrap-relay.js b/packages/SwingSet/tools/bootstrap-relay.js index f054f567ae5..6fa802b397e 100644 --- a/packages/SwingSet/tools/bootstrap-relay.js +++ b/packages/SwingSet/tools/bootstrap-relay.js @@ -1,6 +1,7 @@ import { assert } from '@agoric/assert'; import { objectMap } from '@agoric/internal'; import { Far, E } from '@endo/far'; +import { makePromiseKit } from '@endo/promise-kit'; import { buildManualTimer } from './manual-timer.js'; const { Fail, quote: q } = assert; @@ -37,10 +38,13 @@ export const buildRootObject = () => { return root; }, - createVat: async ({ name, bundleCapName, vatParameters = {} }) => { + createVat: async ( + { name, bundleCapName, vatParameters = {} }, + options = {}, + ) => { const bcap = await E(vatAdmin).getNamedBundleCap(bundleCapName); - const options = { vatParameters }; - const { adminNode, root } = await E(vatAdmin).createVat(bcap, options); + const vatOptions = { ...options, vatParameters }; + const { adminNode, root } = await E(vatAdmin).createVat(bcap, vatOptions); vatData.set(name, { adminNode, root }); return root; }, @@ -77,6 +81,12 @@ export const buildRootObject = () => { return remotable; }, + makePromiseKit: () => { + const { promise, ...resolverMethods } = makePromiseKit(); + const resolver = Far('resolver', resolverMethods); + return harden({ promise, resolver }); + }, + /** * Returns a copy of a remotable's logs. * diff --git a/packages/boot/test/upgrading/upgrade-vats.test.js b/packages/boot/test/upgrading/upgrade-vats.test.js index 252eb42de81..8ffbfb84f87 100644 --- a/packages/boot/test/upgrading/upgrade-vats.test.js +++ b/packages/boot/test/upgrading/upgrade-vats.test.js @@ -1,6 +1,7 @@ // @ts-check import { test as anyTest } from '@agoric/swingset-vat/tools/prepare-test-env-ava.js'; +import { makeTagged } from '@endo/marshal'; import { BridgeId } from '@agoric/internal'; import { buildVatController } from '@agoric/swingset-vat'; import { makeRunUtils } from '@agoric/swingset-vat/tools/run-utils.js'; @@ -445,6 +446,8 @@ test('upgrade vat-priceAuthority', async t => { matchRef(t, reincarnatedRegistry.adminFacet, registry.adminFacet); }); +const dataOnly = obj => JSON.parse(JSON.stringify(obj)); + test('upgrade vat-vow', async t => { const bundles = { vow: { @@ -454,25 +457,71 @@ test('upgrade vat-vow', async t => { const { EV } = await makeScenario(t, { bundles }); - t.log('create initial version'); + t.log('create initial version, metered'); + const vatAdmin = await EV.vat('bootstrap').getVatAdmin(); + const meter = await EV(vatAdmin).createUnlimitedMeter(); const vowVatConfig = { name: 'vow', bundleCapName: 'vow', }; - const vowRoot = await EV.vat('bootstrap').createVat(vowVatConfig); + const vatOptions = { managerType: 'xs-worker', meter }; + const vowRoot = await EV.vat('bootstrap').createVat(vowVatConfig, vatOptions); + + const makeFakeVowKit = async () => { + const internalPromiseKit = await EV.vat('bootstrap').makePromiseKit(); + const fakeVowV0 = await EV.vat('bootstrap').makeRemotable('fakeVowV0', { + shorten: internalPromiseKit.promise, + }); + const fakeVow = makeTagged('Vow', harden({ vowV0: fakeVowV0 })); + return harden({ resolver: internalPromiseKit.resolver, vow: fakeVow }); + }; t.log('test incarnation 0'); /** @type {Record} */ const localPromises = { - forever: [], - fulfilled: ['hello'], - rejected: ['goodbye', true], + promiseForever: [], + promiseFulfilled: ['hello'], + promiseRejected: ['goodbye', true], + }; + const promiseKit = await EV.vat('bootstrap').makePromiseKit(); + const fakeVowKit = await makeFakeVowKit(); + const localVows = { + vowForever: [], + vowFulfilled: ['hello'], + vowRejected: ['goodbye', true], + vowPostUpgrade: [], + vowExternalPromise: [promiseKit.promise], + vowExternalVow: [fakeVowKit.vow], + vowPromiseForever: [undefined, false, true], }; await EV(vowRoot).makeLocalPromiseWatchers(localPromises); - t.deepEqual(await EV(vowRoot).getWatcherResults(), { - fulfilled: { status: 'fulfilled', value: 'hello' }, - forever: { status: 'unsettled' }, - rejected: { status: 'rejected', reason: 'goodbye' }, + await EV(vowRoot).makeLocalVowWatchers(localVows); + t.deepEqual(dataOnly(await EV(vowRoot).getWatcherResults()), { + promiseForever: { status: 'unsettled' }, + promiseFulfilled: { status: 'fulfilled', value: 'hello' }, + promiseRejected: { status: 'rejected', reason: 'goodbye' }, + vowForever: { + status: 'unsettled', + resolver: {}, + }, + vowFulfilled: { status: 'fulfilled', value: 'hello' }, + vowRejected: { status: 'rejected', reason: 'goodbye' }, + vowPostUpgrade: { + status: 'unsettled', + resolver: {}, + }, + vowExternalPromise: { + status: 'unsettled', + resolver: {}, + }, + vowExternalVow: { + status: 'unsettled', + resolver: {}, + }, + vowPromiseForever: { + status: 'unsettled', + resolver: {}, + }, }); t.log('restart'); @@ -481,16 +530,35 @@ test('upgrade vat-vow', async t => { t.is(incarnationNumber, 1, 'vat must be reincarnated'); t.log('test incarnation 1'); - t.deepEqual(await EV(vowRoot).getWatcherResults(), { - fulfilled: { status: 'fulfilled', value: 'hello' }, - forever: { - status: 'rejected', - reason: { - name: 'vatUpgraded', - upgradeMessage: 'vat upgraded', - incarnationNumber: 0, - }, + const localVowsUpdates = { + vowPostUpgrade: ['bonjour'], + }; + await EV(vowRoot).resolveVowWatchers(localVowsUpdates); + await EV(promiseKit.resolver).resolve('ciao'); + t.timeout(10_000); + const upgradeRejection = harden({ + status: 'rejected', + reason: { + name: 'vatUpgraded', + upgradeMessage: 'vat upgraded', + incarnationNumber: 0, + }, + }); + await EV(fakeVowKit.resolver).reject(upgradeRejection.reason); + t.timeout(600_000); // t.timeout.clear() not yet available in our ava version + t.deepEqual(dataOnly(await EV(vowRoot).getWatcherResults()), { + promiseForever: upgradeRejection, + promiseFulfilled: { status: 'fulfilled', value: 'hello' }, + promiseRejected: { status: 'rejected', reason: 'goodbye' }, + vowForever: { + status: 'unsettled', + resolver: {}, }, - rejected: { status: 'rejected', reason: 'goodbye' }, + vowFulfilled: { status: 'fulfilled', value: 'hello' }, + vowRejected: { status: 'rejected', reason: 'goodbye' }, + vowPostUpgrade: { status: 'fulfilled', value: 'bonjour' }, + vowExternalPromise: { status: 'fulfilled', value: 'ciao' }, + vowExternalVow: upgradeRejection, + vowPromiseForever: upgradeRejection, }); }); diff --git a/packages/boot/test/upgrading/vat-vow.js b/packages/boot/test/upgrading/vat-vow.js index e8cebdb0bee..1ba97ac52e3 100644 --- a/packages/boot/test/upgrading/vat-vow.js +++ b/packages/boot/test/upgrading/vat-vow.js @@ -4,9 +4,11 @@ import { Far } from '@endo/far'; export const buildRootObject = (_vatPowers, _args, baggage) => { const zone = makeDurableZone(baggage); - const { watch } = prepareVowTools(zone.subZone('VowTools')); + const { watch, makeVowKit } = prepareVowTools(zone.subZone('VowTools')); - /** @type {MapStore>} */ + /** @typedef {({ status: 'unsettled' } | PromiseSettledResult) & { resolver?: import('@agoric/vow').VowResolver }} WatcherResult */ + + /** @type {MapStore} */ const nameToResult = zone.mapStore('nameToResult'); const makeWatcher = zone.exoClass('Watcher', undefined, name => ({ name }), { @@ -43,5 +45,48 @@ export const buildRootObject = (_vatPowers, _args, baggage) => { watch(p, makeWatcher(name)); } }, + /** @param {Record} localVows */ + async makeLocalVowWatchers(localVows) { + for (const [name, settlement] of Object.entries(localVows)) { + const { vow, resolver } = makeVowKit(); + nameToResult.init(name, harden({ status: 'unsettled', resolver })); + if (settlement.length) { + let [settlementValue, isRejection] = settlement; + const wrapInPromise = settlement[2]; + if (wrapInPromise) { + if (isRejection) { + settlementValue = Promise.reject(settlementValue); + isRejection = false; + } else if (settlementValue === undefined) { + // Consider an undefined value as no settlement + settlementValue = new Promise(() => {}); + } else { + settlementValue = Promise.resolve(settlementValue); + } + } + if (isRejection) { + resolver.reject(settlementValue); + } else { + resolver.resolve(settlementValue); + } + } + watch(vow, makeWatcher(name)); + } + }, + /** @param {Record} localVows */ + async resolveVowWatchers(localVows) { + for (const [name, settlement] of Object.entries(localVows)) { + const { status, resolver } = nameToResult.get(name); + if (status !== 'unsettled' || !resolver) { + throw Error(`Invalid pending vow for ${name}`); + } + const [settlementValue, isRejection] = settlement; + if (isRejection) { + resolver.reject(settlementValue); + } else { + resolver.resolve(settlementValue); + } + } + }, }); }; diff --git a/packages/vat-data/vow.js b/packages/vat-data/vow.js deleted file mode 100644 index 031568326dc..00000000000 --- a/packages/vat-data/vow.js +++ /dev/null @@ -1,2 +0,0 @@ -// Backward-compatibility forwarding to the vow package. -export * from '@agoric/vow/vat.js'; diff --git a/packages/vats/src/vat-transfer.js b/packages/vats/src/vat-transfer.js index b3a78973ae8..fdb85e8c675 100644 --- a/packages/vats/src/vat-transfer.js +++ b/packages/vats/src/vat-transfer.js @@ -3,7 +3,7 @@ import { Far } from '@endo/far'; import { makeDurableZone } from '@agoric/zone/durable.js'; import { provideLazy } from '@agoric/store'; -import { prepareVowTools } from '@agoric/vat-data/vow.js'; +import { prepareVowTools } from '@agoric/vow/vat.js'; import { prepareBridgeTargetModule } from './bridge-target.js'; import { prepareTransferTools } from './transfer.js'; diff --git a/packages/vow/src/tools.js b/packages/vow/src/tools.js index 020298a6056..7351457b6a5 100644 --- a/packages/vow/src/tools.js +++ b/packages/vow/src/tools.js @@ -5,14 +5,16 @@ import { prepareWatch } from './watch.js'; import { prepareWatchUtils } from './watch-utils.js'; /** @import {Zone} from '@agoric/base-zone' */ +/** @import {IsRetryableReason} from './types.js' */ /** * @param {Zone} zone * @param {object} [powers] - * @param {(reason: any) => boolean} [powers.isRetryableReason] + * @param {IsRetryableReason} [powers.isRetryableReason] */ export const prepareVowTools = (zone, powers = {}) => { - const { isRetryableReason = () => false } = powers; + const { isRetryableReason = /** @type {IsRetryableReason} */ (() => false) } = + powers; const makeVowKit = prepareVowKit(zone); const when = makeWhen(isRetryableReason); const watch = prepareWatch(zone, makeVowKit, isRetryableReason); diff --git a/packages/vow/src/types.js b/packages/vow/src/types.js index bccf77bec71..18a96b78c35 100644 --- a/packages/vow/src/types.js +++ b/packages/vow/src/types.js @@ -10,6 +10,16 @@ export {}; * @import {prepareVowTools} from './tools.js' */ +/** + * @callback IsRetryableReason + * Return truthy if a rejection reason should result in a retry. + * @param {any} reason + * @param {any} priorRetryValue the previous value returned by this function + * when deciding whether to retry the same logical operation + * @returns {any} If falsy, the reason is not retryable. If truthy, the + * priorRetryValue for the next call. + */ + /** * @template T * @typedef {Promise>} PromiseVow Return type of a function that may diff --git a/packages/vow/src/vow.js b/packages/vow/src/vow.js index d1bd7075efc..742a9c2e737 100644 --- a/packages/vow/src/vow.js +++ b/packages/vow/src/vow.js @@ -69,7 +69,9 @@ export const prepareVowKit = zone => { () => ({ value: undefined, // The stepStatus is null if the promise step hasn't settled yet. - stepStatus: /** @type {null | 'fulfilled' | 'rejected'} */ (null), + stepStatus: /** @type {null | 'pending' | 'fulfilled' | 'rejected'} */ ( + null + ), }), { vowV0: { @@ -84,6 +86,7 @@ export const prepareVowKit = zone => { case 'rejected': throw value; case null: + case 'pending': return provideCurrentKit(this.facets.resolver).promise; default: throw new TypeError(`unexpected stepStatus ${stepStatus}`); @@ -96,10 +99,17 @@ export const prepareVowKit = zone => { */ resolve(value) { const { resolver } = this.facets; - const { promise, resolve } = getPromiseKitForResolution(resolver); + const { stepStatus } = this.state; + const { resolve } = getPromiseKitForResolution(resolver); if (resolve) { resolve(value); - zone.watchPromise(promise, this.facets.watchNextStep); + } + if (stepStatus === null) { + this.state.stepStatus = 'pending'; + zone.watchPromise( + HandledPromise.resolve(value), + this.facets.watchNextStep, + ); } }, /** @@ -107,15 +117,23 @@ export const prepareVowKit = zone => { */ reject(reason) { const { resolver, watchNextStep } = this.facets; + const { stepStatus } = this.state; const { reject } = getPromiseKitForResolution(resolver); if (reject) { reject(reason); + } + if (stepStatus === null) { watchNextStep.onRejected(reason); } }, }, watchNextStep: { onFulfilled(value) { + const { resolver } = this.facets; + const { resolve } = getPromiseKitForResolution(resolver); + if (resolve) { + resolve(value); + } this.state.stepStatus = 'fulfilled'; this.state.value = value; }, diff --git a/packages/vow/src/watch.js b/packages/vow/src/watch.js index a7bb6736aad..1f6597cd3dd 100644 --- a/packages/vow/src/watch.js +++ b/packages/vow/src/watch.js @@ -6,7 +6,7 @@ const { apply } = Reflect; /** * @import { PromiseWatcher, Zone } from '@agoric/base-zone'; - * @import { ERef, Vow, VowKit, VowResolver, Watcher } from './types.js'; + * @import { ERef, IsRetryableReason, Vow, VowKit, VowResolver, Watcher } from './types.js'; */ /** @@ -62,7 +62,7 @@ const settle = (resolver, watcher, wcb, value, watcherContext) => { /** * @param {Zone} zone - * @param {(reason: any) => boolean} isRetryableReason + * @param {IsRetryableReason} isRetryableReason * @param {ReturnType} watchNextStep */ const preparePromiseWatcher = (zone, isRetryableReason, watchNextStep) => @@ -80,6 +80,7 @@ const preparePromiseWatcher = (zone, isRetryableReason, watchNextStep) => (resolver, watcher, watcherContext) => { const state = { vow: /** @type {unknown} */ (undefined), + priorRetryValue: /** @type {any} */ (undefined), resolver, watcher, watcherContext: harden(watcherContext), @@ -96,17 +97,25 @@ const preparePromiseWatcher = (zone, isRetryableReason, watchNextStep) => watchNextStep(value, this.self); return; } + this.state.priorRetryValue = undefined; this.state.watcher = undefined; this.state.resolver = undefined; settle(resolver, watcher, 'onFulfilled', value, watcherContext); }, /** @type {Required['onRejected']} */ onRejected(reason) { - const { vow, watcher, watcherContext, resolver } = this.state; - if (vow && isRetryableReason(reason)) { - watchNextStep(vow, this.self); - return; + const { vow, watcher, watcherContext, resolver, priorRetryValue } = + this.state; + if (vow) { + const retryValue = isRetryableReason(reason, priorRetryValue); + if (retryValue) { + // Retry the same specimen. + this.state.priorRetryValue = retryValue; + watchNextStep(vow, this.self); + return; + } } + this.state.priorRetryValue = undefined; this.state.resolver = undefined; this.state.watcher = undefined; settle(resolver, watcher, 'onRejected', reason, watcherContext); @@ -117,12 +126,12 @@ const preparePromiseWatcher = (zone, isRetryableReason, watchNextStep) => /** * @param {Zone} zone * @param {() => VowKit} makeVowKit - * @param {(reason: any) => boolean} [isRetryableReason] + * @param {(reason: any, lastValue: any) => any} [isRetryableReason] */ export const prepareWatch = ( zone, makeVowKit, - isRetryableReason = _reason => false, + isRetryableReason = (_reason, _lastValue) => undefined, ) => { const watchNextStep = makeWatchNextStep(zone); const makePromiseWatcher = preparePromiseWatcher( diff --git a/packages/vow/src/when.js b/packages/vow/src/when.js index aba0ed84925..ef1a6bad3c3 100644 --- a/packages/vow/src/when.js +++ b/packages/vow/src/when.js @@ -1,12 +1,14 @@ // @ts-check import { getVowPayload, basicE } from './vow-utils.js'; -/** @import { Unwrap } from './types.js' */ +/** @import { IsRetryableReason, Unwrap } from './types.js' */ /** - * @param {(reason: any) => boolean} [isRetryableReason] + * @param {IsRetryableReason} [isRetryableReason] */ -export const makeWhen = (isRetryableReason = () => false) => { +export const makeWhen = ( + isRetryableReason = /** @type {IsRetryableReason} */ (() => false), +) => { /** * Shorten `specimenP` until we achieve a final result. * @@ -25,16 +27,25 @@ export const makeWhen = (isRetryableReason = () => false) => { // Ensure we have a presence that won't be disconnected later. let result = await specimenP; let payload = getVowPayload(result); + let priorRetryValue; while (payload) { result = await basicE(payload.vowV0) .shorten() - .catch(e => { - if (isRetryableReason(e)) { - // Shorten the same specimen to try again. - return result; - } - throw e; - }); + .then( + res => { + priorRetryValue = undefined; + return res; + }, + e => { + const nextValue = isRetryableReason(e, priorRetryValue); + if (nextValue) { + // Shorten the same specimen to try again. + priorRetryValue = nextValue; + return result; + } + throw e; + }, + ); // Advance to the next vow. payload = getVowPayload(result); } diff --git a/packages/vow/vat.js b/packages/vow/vat.js index a677ee42eb6..27b7e09a031 100644 --- a/packages/vow/vat.js +++ b/packages/vow/vat.js @@ -4,12 +4,17 @@ import { isUpgradeDisconnection } from '@agoric/internal/src/upgrade-api.js'; import { makeHeapZone } from '@agoric/base-zone/heap.js'; import { makeE, prepareVowTools as rawPrepareVowTools } from './src/index.js'; -/** - * Return truthy if a rejection reason should result in a retry. - * @param {any} reason - * @returns {boolean} - */ -const isRetryableReason = reason => isUpgradeDisconnection(reason); +/** @type {import('./src/types.js').IsRetryableReason} */ +const isRetryableReason = (reason, priorRetryValue) => { + if ( + isUpgradeDisconnection(reason) && + (!priorRetryValue || + reason.incarnationNumber > priorRetryValue.incarnationNumber) + ) { + return reason; + } + return undefined; +}; export const defaultPowers = harden({ isRetryableReason,