From ada94d40058dba746d500de2edf69364d75fa9cc Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Mon, 24 Jun 2024 18:35:17 -0400 Subject: [PATCH 1/9] feat(vowTools): add asVow helper - adds asVow() helper function that coerces the result of a function to a Vow - see comment from @mhofman for inspiration: https://github.com/Agoric/agoric-sdk/pull/9454#discussion_r1626898694 --- packages/vow/src/tools.js | 6 ++++-- packages/vow/src/vow-utils.js | 27 +++++++++++++++++++++++++-- packages/vow/src/vow.js | 1 + packages/vow/test/asVow.test.js | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 packages/vow/test/asVow.test.js diff --git a/packages/vow/src/tools.js b/packages/vow/src/tools.js index 7351457b6a5..595dad161f5 100644 --- a/packages/vow/src/tools.js +++ b/packages/vow/src/tools.js @@ -3,9 +3,10 @@ import { makeWhen } from './when.js'; import { prepareVowKit } from './vow.js'; import { prepareWatch } from './watch.js'; import { prepareWatchUtils } from './watch-utils.js'; +import { makeAsVow } from './vow-utils.js'; /** @import {Zone} from '@agoric/base-zone' */ -/** @import {IsRetryableReason} from './types.js' */ +/** @import {IsRetryableReason, Vow} from './types.js' */ /** * @param {Zone} zone @@ -20,6 +21,7 @@ export const prepareVowTools = (zone, powers = {}) => { const watch = prepareWatch(zone, makeVowKit, isRetryableReason); const makeWatchUtils = prepareWatchUtils(zone, watch, makeVowKit); const watchUtils = makeWatchUtils(); + const asVow = makeAsVow(makeVowKit); /** * Vow-tolerant implementation of Promise.all. @@ -28,6 +30,6 @@ export const prepareVowTools = (zone, powers = {}) => { */ const allVows = vows => watchUtils.all(vows); - return harden({ when, watch, makeVowKit, allVows }); + return harden({ when, watch, makeVowKit, allVows, asVow }); }; harden(prepareVowTools); diff --git a/packages/vow/src/vow-utils.js b/packages/vow/src/vow-utils.js index 63ba2ecf6aa..664bfa0698d 100644 --- a/packages/vow/src/vow-utils.js +++ b/packages/vow/src/vow-utils.js @@ -4,8 +4,9 @@ import { isPassable } from '@endo/pass-style'; import { M, matches } from '@endo/patterns'; /** - * @import {PassableCap} from '@endo/pass-style' - * @import {VowPayload, Vow} from './types.js' + * @import {PassableCap} from '@endo/pass-style'; + * @import {VowPayload, Vow} from './types.js'; + * @import {MakeVowKit} from './vow.js'; */ export { basicE }; @@ -73,3 +74,25 @@ export const toPassableCap = k => { return vowV0; }; harden(toPassableCap); + +/** @param {MakeVowKit} makeVowKit */ +export const makeAsVow = makeVowKit => { + /** + * Helper function that coerces the result of a function to a Vow. Helpful + * for scenarios like a synchronously thrown error. + * @template {any} T + * @param {(...args: any[]) => Vow> | Awaited} fn + * @returns {Vow>} + */ + const asVow = fn => { + const kit = makeVowKit(); + try { + kit.resolver.resolve(fn()); + } catch (e) { + kit.resolver.reject(e); + } + return kit.vow; + }; + return harden(asVow); +}; +harden(makeAsVow); diff --git a/packages/vow/src/vow.js b/packages/vow/src/vow.js index 742a9c2e737..1a1ca4a472e 100644 --- a/packages/vow/src/vow.js +++ b/packages/vow/src/vow.js @@ -157,5 +157,6 @@ export const prepareVowKit = zone => { return makeVowKit; }; +/** @typedef {ReturnType} MakeVowKit */ harden(prepareVowKit); diff --git a/packages/vow/test/asVow.test.js b/packages/vow/test/asVow.test.js new file mode 100644 index 00000000000..25d22005584 --- /dev/null +++ b/packages/vow/test/asVow.test.js @@ -0,0 +1,32 @@ +// @ts-check +import test from 'ava'; + +import { makeHeapZone } from '@agoric/base-zone/heap.js'; + +import { prepareVowTools } from '../src/tools.js'; +import { isVow } from '../src/vow-utils.js'; + +test('asVow takes a function that throws/returns synchronously and returns a vow', async t => { + const zone = makeHeapZone(); + const { watch, when, asVow } = prepareVowTools(zone); + + const fnThatThrows = () => { + throw Error('fail'); + }; + + const vowWithRejection = asVow(fnThatThrows); + t.true(isVow(vowWithRejection)); + await t.throwsAsync(when(vowWithRejection), { message: 'fail' }, 'failure '); + + const isWatchAble = watch(asVow(fnThatThrows)); + t.true(isVow(vowWithRejection)); + await t.throwsAsync(when(isWatchAble), { message: 'fail' }, 'failure '); + + const fnThatReturns = () => { + return 'early return'; + }; + const vowWithReturn = asVow(fnThatReturns); + t.true(isVow(vowWithReturn)); + t.is(await when(vowWithReturn), 'early return'); + t.is(await when(watch(vowWithReturn)), 'early return'); +}); From 0c5276ec1cfba684e05018775772af35eeed3891 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Mon, 24 Jun 2024 21:40:08 -0400 Subject: [PATCH 2/9] feat(vowTools): asVow should not wrap a vow as a vow --- packages/vow/src/tools.js | 2 +- packages/vow/src/vow-utils.js | 11 ++++++++--- packages/vow/test/asVow.test.js | 32 +++++++++++++++++++++++++++----- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/packages/vow/src/tools.js b/packages/vow/src/tools.js index 595dad161f5..16662ba843a 100644 --- a/packages/vow/src/tools.js +++ b/packages/vow/src/tools.js @@ -6,7 +6,7 @@ import { prepareWatchUtils } from './watch-utils.js'; import { makeAsVow } from './vow-utils.js'; /** @import {Zone} from '@agoric/base-zone' */ -/** @import {IsRetryableReason, Vow} from './types.js' */ +/** @import {IsRetryableReason} from './types.js' */ /** * @param {Zone} zone diff --git a/packages/vow/src/vow-utils.js b/packages/vow/src/vow-utils.js index 664bfa0698d..2da8ccda1c5 100644 --- a/packages/vow/src/vow-utils.js +++ b/packages/vow/src/vow-utils.js @@ -85,12 +85,17 @@ export const makeAsVow = makeVowKit => { * @returns {Vow>} */ const asVow = fn => { - const kit = makeVowKit(); + let result; try { - kit.resolver.resolve(fn()); + result = fn(); } catch (e) { - kit.resolver.reject(e); + result = Promise.reject(e); + } + if (isVow(result)) { + return result; } + const kit = makeVowKit(); + kit.resolver.resolve(result); return kit.vow; }; return harden(asVow); diff --git a/packages/vow/test/asVow.test.js b/packages/vow/test/asVow.test.js index 25d22005584..3109c74e027 100644 --- a/packages/vow/test/asVow.test.js +++ b/packages/vow/test/asVow.test.js @@ -1,14 +1,14 @@ // @ts-check import test from 'ava'; +import { E } from '@endo/far'; import { makeHeapZone } from '@agoric/base-zone/heap.js'; import { prepareVowTools } from '../src/tools.js'; -import { isVow } from '../src/vow-utils.js'; +import { getVowPayload, isVow } from '../src/vow-utils.js'; test('asVow takes a function that throws/returns synchronously and returns a vow', async t => { - const zone = makeHeapZone(); - const { watch, when, asVow } = prepareVowTools(zone); + const { watch, when, asVow } = prepareVowTools(makeHeapZone()); const fnThatThrows = () => { throw Error('fail'); @@ -16,11 +16,15 @@ test('asVow takes a function that throws/returns synchronously and returns a vow const vowWithRejection = asVow(fnThatThrows); t.true(isVow(vowWithRejection)); - await t.throwsAsync(when(vowWithRejection), { message: 'fail' }, 'failure '); + await t.throwsAsync( + when(vowWithRejection), + { message: 'fail' }, + 'error should propogate as promise rejection', + ); const isWatchAble = watch(asVow(fnThatThrows)); t.true(isVow(vowWithRejection)); - await t.throwsAsync(when(isWatchAble), { message: 'fail' }, 'failure '); + await t.throwsAsync(when(isWatchAble), { message: 'fail' }); const fnThatReturns = () => { return 'early return'; @@ -30,3 +34,21 @@ test('asVow takes a function that throws/returns synchronously and returns a vow t.is(await when(vowWithReturn), 'early return'); t.is(await when(watch(vowWithReturn)), 'early return'); }); + +test('asVow does not resolve a vow to a vow', async t => { + const { watch, when, asVow } = prepareVowTools(makeHeapZone()); + + const testVow = watch(Promise.resolve('payload')); + const testVowAsVow = asVow(() => testVow); + + const vowPayload = getVowPayload(testVowAsVow); + assert(vowPayload?.vowV0, 'testVowAsVow is a vow'); + const unwrappedOnce = await E(vowPayload.vowV0).shorten(); + t.false( + isVow(unwrappedOnce), + 'vows passed to asVow are not rewrapped as vows', + ); + t.is(unwrappedOnce, 'payload'); + + t.is(await when(testVow), await when(testVowAsVow), 'result is preserved'); +}); From c7b1fcba8bc2365c443da2609fe3fde2e8c018ff Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Sun, 30 Jun 2024 15:48:36 -0400 Subject: [PATCH 3/9] feat(watchUtils): add asPromise helper Co-authored-by: Michael FIG --- packages/vow/src/tools.js | 7 +++++- packages/vow/src/watch-utils.js | 38 ++++++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/vow/src/tools.js b/packages/vow/src/tools.js index 16662ba843a..dac1645d48d 100644 --- a/packages/vow/src/tools.js +++ b/packages/vow/src/tools.js @@ -19,7 +19,12 @@ export const prepareVowTools = (zone, powers = {}) => { const makeVowKit = prepareVowKit(zone); const when = makeWhen(isRetryableReason); const watch = prepareWatch(zone, makeVowKit, isRetryableReason); - const makeWatchUtils = prepareWatchUtils(zone, watch, makeVowKit); + const makeWatchUtils = prepareWatchUtils(zone, { + watch, + when, + makeVowKit, + isRetryableReason, + }); const watchUtils = makeWatchUtils(); const asVow = makeAsVow(makeVowKit); diff --git a/packages/vow/src/watch-utils.js b/packages/vow/src/watch-utils.js index a198906555d..a77f82ae71b 100644 --- a/packages/vow/src/watch-utils.js +++ b/packages/vow/src/watch-utils.js @@ -1,12 +1,17 @@ // @ts-check import { M } from '@endo/patterns'; +import { PromiseWatcherI } from '@agoric/base-zone'; + +const { Fail, bare } = assert; /** * @import {MapStore} from '@agoric/store/src/types.js' * @import { Zone } from '@agoric/base-zone' * @import { Watch } from './watch.js' + * @import { When } from './when.js' * @import {VowKit} from './types.js' + * @import {IsRetryableReason} from './types.js' */ const VowShape = M.tagged( @@ -18,21 +23,29 @@ const VowShape = M.tagged( /** * @param {Zone} zone - * @param {Watch} watch - * @param {() => VowKit} makeVowKit + * @param {object} powers + * @param {Watch} powers.watch + * @param {When} powers.when + * @param {() => VowKit} powers.makeVowKit + * @param {IsRetryableReason} powers.isRetryableReason */ -export const prepareWatchUtils = (zone, watch, makeVowKit) => { +export const prepareWatchUtils = ( + zone, + { watch, when, makeVowKit, isRetryableReason }, +) => { const detached = zone.detached(); const makeWatchUtilsKit = zone.exoClassKit( 'WatchUtils', { utils: M.interface('Utils', { all: M.call(M.arrayOf(M.any())).returns(VowShape), + asPromise: M.call(M.raw()).rest(M.raw()).returns(M.promise()), }), watcher: M.interface('Watcher', { onFulfilled: M.call(M.any()).rest(M.any()).returns(M.any()), onRejected: M.call(M.any()).rest(M.any()).returns(M.any()), }), + retryRejectionPromiseWatcher: PromiseWatcherI, }, () => { /** @@ -83,6 +96,17 @@ export const prepareWatchUtils = (zone, watch, makeVowKit) => { } return kit.vow; }, + asPromise(specimenP, ...watcherArgs) { + // Watch the specimen in case it is an ephemeral promise. + const vow = watch(specimenP, ...watcherArgs); + const promise = when(vow); + // Watch the ephemeral result promise to ensure that if its settlement is + // lost due to upgrade of this incarnation, we will at least cause an + // unhandled rejection in the new incarnation. + zone.watchPromise(promise, this.facets.retryRejectionPromiseWatcher); + + return promise; + }, }, watcher: { onFulfilled(value, { id, index }) { @@ -122,6 +146,14 @@ export const prepareWatchUtils = (zone, watch, makeVowKit) => { resolver.reject(value); }, }, + retryRejectionPromiseWatcher: { + onFulfilled(_result) {}, + onRejected(reason, failedOp) { + if (isRetryableReason(reason, undefined)) { + Fail`Pending ${bare(failedOp)} could not retry; {reason}`; + } + }, + }, }, ); From ab5481127af8a7d8d150edda296b7551f3d5163d Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Sun, 30 Jun 2024 17:32:45 -0400 Subject: [PATCH 4/9] feat(vowTools): asPromise helper for unwrapping vows --- packages/vow/src/tools.js | 8 +++-- packages/vow/src/types.js | 14 ++++++++ packages/vow/src/watch-utils.js | 3 +- packages/vow/test/watch-utils.test.js | 52 +++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/packages/vow/src/tools.js b/packages/vow/src/tools.js index dac1645d48d..e6eccb2ee76 100644 --- a/packages/vow/src/tools.js +++ b/packages/vow/src/tools.js @@ -6,7 +6,7 @@ import { prepareWatchUtils } from './watch-utils.js'; import { makeAsVow } from './vow-utils.js'; /** @import {Zone} from '@agoric/base-zone' */ -/** @import {IsRetryableReason} from './types.js' */ +/** @import {IsRetryableReason, AsPromiseFunction} from './types.js' */ /** * @param {Zone} zone @@ -35,6 +35,10 @@ export const prepareVowTools = (zone, powers = {}) => { */ const allVows = vows => watchUtils.all(vows); - return harden({ when, watch, makeVowKit, allVows, asVow }); + /** @type {AsPromiseFunction} */ + const asPromise = (specimenP, ...watcherArgs) => + watchUtils.asPromise(specimenP, ...watcherArgs); + + return harden({ when, watch, makeVowKit, allVows, asVow, asPromise }); }; harden(prepareVowTools); diff --git a/packages/vow/src/types.js b/packages/vow/src/types.js index 635819e4bea..d439c9b5444 100644 --- a/packages/vow/src/types.js +++ b/packages/vow/src/types.js @@ -87,4 +87,18 @@ export {}; * @property {(reason: any, ...args: C) => Vow | PromiseVow | TResult2} [onRejected] */ +/** + * Converts a vow or promise to a promise, ensuring proper handling of ephemeral promises. + * + * @template [T=any] + * @template [TResult1=T] + * @template [TResult2=never] + * @template {any[]} [C=any[]] + * @callback AsPromiseFunction + * @param {ERef>} specimenP + * @param {Watcher} [watcher] + * @param {C} [watcherArgs] + * @returns {Promise} + */ + /** @typedef {ReturnType} VowTools */ diff --git a/packages/vow/src/watch-utils.js b/packages/vow/src/watch-utils.js index a77f82ae71b..d7068033102 100644 --- a/packages/vow/src/watch-utils.js +++ b/packages/vow/src/watch-utils.js @@ -10,7 +10,7 @@ const { Fail, bare } = assert; * @import { Zone } from '@agoric/base-zone' * @import { Watch } from './watch.js' * @import { When } from './when.js' - * @import {VowKit} from './types.js' + * @import {VowKit, AsPromiseFunction} from './types.js' * @import {IsRetryableReason} from './types.js' */ @@ -96,6 +96,7 @@ export const prepareWatchUtils = ( } return kit.vow; }, + /** @type {AsPromiseFunction} */ asPromise(specimenP, ...watcherArgs) { // Watch the specimen in case it is an ephemeral promise. const vow = watch(specimenP, ...watcherArgs); diff --git a/packages/vow/test/watch-utils.test.js b/packages/vow/test/watch-utils.test.js index b0c79c597a3..d4c37e98c3b 100644 --- a/packages/vow/test/watch-utils.test.js +++ b/packages/vow/test/watch-utils.test.js @@ -112,3 +112,55 @@ test('allVows - watch promises mixed with vows', async t => { t.is(result.length, 2); t.like(result, ['vow', 'promise']); }); + +test('asPromise converts a vow to a promise', async t => { + const zone = makeHeapZone(); + const { watch, asPromise } = prepareVowTools(zone); + + const testPromiseP = Promise.resolve('test value'); + const vow = watch(testPromiseP); + + const result = await asPromise(vow); + t.is(result, 'test value'); +}); + +test('asPromise handles vow rejection', async t => { + const zone = makeHeapZone(); + const { watch, asPromise } = prepareVowTools(zone); + + const testPromiseP = Promise.reject(new Error('test error')); + const vow = watch(testPromiseP); + + await t.throwsAsync(asPromise(vow), { message: 'test error' }); +}); + +test('asPromise accepts and resolves promises', async t => { + const zone = makeHeapZone(); + const { asPromise } = prepareVowTools(zone); + + const p = Promise.resolve('a promise'); + const result = await asPromise(p); + t.is(result, 'a promise'); +}); + +test('asPromise handles watcher arguments', async t => { + const zone = makeHeapZone(); + const { watch, asPromise } = prepareVowTools(zone); + + const testPromiseP = Promise.resolve('watcher test'); + const vow = watch(testPromiseP); + + let watcherCalled = false; + const watcher = { + onFulfilled(value, ctx) { + watcherCalled = true; + t.is(value, 'watcher test'); + t.deepEqual(ctx, ['ctx']); + return value; + }, + }; + + const result = await asPromise(vow, watcher, ['ctx']); + t.is(result, 'watcher test'); + t.true(watcherCalled); +}); From 111703ec70b65154c19c329d01627c15d730e2c5 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Sun, 30 Jun 2024 20:20:48 -0400 Subject: [PATCH 5/9] feat(watchUtils): handle non-storables Co-authored-by: Michael FIG --- packages/base-zone/src/watch-promise.js | 2 +- packages/vow/src/tools.js | 6 +- packages/vow/src/vow.js | 50 +++++++++++++---- packages/vow/src/watch-utils.js | 74 +++++++++++++++++++++---- 4 files changed, 108 insertions(+), 24 deletions(-) diff --git a/packages/base-zone/src/watch-promise.js b/packages/base-zone/src/watch-promise.js index 4693bd98377..4efec17195f 100644 --- a/packages/base-zone/src/watch-promise.js +++ b/packages/base-zone/src/watch-promise.js @@ -9,7 +9,7 @@ const { apply } = Reflect; /** * A PromiseWatcher method guard callable with or more arguments, returning void. */ -export const PromiseWatcherHandler = M.call(M.any()).rest(M.any()).returns(); +export const PromiseWatcherHandler = M.call(M.raw()).rest(M.raw()).returns(); /** * A PromiseWatcher interface that has both onFulfilled and onRejected handlers. diff --git a/packages/vow/src/tools.js b/packages/vow/src/tools.js index e6eccb2ee76..ebd0ba1a56b 100644 --- a/packages/vow/src/tools.js +++ b/packages/vow/src/tools.js @@ -5,8 +5,10 @@ import { prepareWatch } from './watch.js'; import { prepareWatchUtils } from './watch-utils.js'; import { makeAsVow } from './vow-utils.js'; -/** @import {Zone} from '@agoric/base-zone' */ -/** @import {IsRetryableReason, AsPromiseFunction} from './types.js' */ +/** + * @import {Zone} from '@agoric/base-zone'; + * @import {IsRetryableReason, AsPromiseFunction} from './types.js'; + */ /** * @param {Zone} zone diff --git a/packages/vow/src/vow.js b/packages/vow/src/vow.js index 1a1ca4a472e..7566b2cb3a1 100644 --- a/packages/vow/src/vow.js +++ b/packages/vow/src/vow.js @@ -4,10 +4,12 @@ import { M } from '@endo/patterns'; import { makeTagged } from '@endo/pass-style'; import { PromiseWatcherI } from '@agoric/base-zone'; +const { details: X } = assert; + /** - * @import {PromiseKit} from '@endo/promise-kit' - * @import {Zone} from '@agoric/base-zone' - * @import {VowResolver, VowKit} from './types.js' + * @import {PromiseKit} from '@endo/promise-kit'; + * @import {Zone} from '@agoric/base-zone'; + * @import {VowResolver, VowKit} from './types.js'; */ const sink = () => {}; @@ -61,13 +63,13 @@ export const prepareVowKit = zone => { shorten: M.call().returns(M.promise()), }), resolver: M.interface('VowResolver', { - resolve: M.call().optional(M.any()).returns(), - reject: M.call().optional(M.any()).returns(), + resolve: M.call().optional(M.raw()).returns(), + reject: M.call().optional(M.raw()).returns(), }), watchNextStep: PromiseWatcherI, }, () => ({ - value: undefined, + value: /** @type {any} */ (undefined), // The stepStatus is null if the promise step hasn't settled yet. stepStatus: /** @type {null | 'pending' | 'fulfilled' | 'rejected'} */ ( null @@ -80,11 +82,18 @@ export const prepareVowKit = zone => { */ async shorten() { const { stepStatus, value } = this.state; + const { resolver } = this.facets; + const ephemera = resolverToEphemera.get(resolver); + switch (stepStatus) { - case 'fulfilled': + case 'fulfilled': { + if (ephemera) return ephemera.promise; return value; - case 'rejected': + } + case 'rejected': { + if (ephemera) return ephemera.promise; throw value; + } case null: case 'pending': return provideCurrentKit(this.facets.resolver).promise; @@ -129,17 +138,36 @@ export const prepareVowKit = zone => { }, watchNextStep: { onFulfilled(value) { - const { resolver } = this.facets; + const { resolver, watchNextStep } = this.facets; const { resolve } = getPromiseKitForResolution(resolver); + harden(value); if (resolve) { resolve(value); } this.state.stepStatus = 'fulfilled'; - this.state.value = value; + if (zone.isStorable(value)) { + this.state.value = value; + } else { + watchNextStep.onRejected( + assert.error(X`Vow fulfillment value is not storable: ${value}`), + ); + } }, onRejected(reason) { + const { resolver } = this.facets; + const { reject } = getPromiseKitForResolution(resolver); + harden(reason); + if (reject) { + reject(reason); + } this.state.stepStatus = 'rejected'; - this.state.value = reason; + if (zone.isStorable(reason)) { + this.state.value = reason; + } else { + this.state.value = assert.error( + X`Vow rejection reason is not storable: ${reason}`, + ); + } }, }, }, diff --git a/packages/vow/src/watch-utils.js b/packages/vow/src/watch-utils.js index d7068033102..c314883a301 100644 --- a/packages/vow/src/watch-utils.js +++ b/packages/vow/src/watch-utils.js @@ -3,7 +3,7 @@ import { M } from '@endo/patterns'; import { PromiseWatcherI } from '@agoric/base-zone'; -const { Fail, bare } = assert; +const { Fail, bare, details: X } = assert; /** * @import {MapStore} from '@agoric/store/src/types.js' @@ -21,6 +21,20 @@ const VowShape = M.tagged( }), ); +/** + * Like `provideLazy`, but accepts non-Passable values. + * + * @param {WeakMap} map + * @param {any} key + * @param {(key: any) => any} makeValue + */ +const provideLazyMap = (map, key, makeValue) => { + if (!map.has(key)) { + map.set(key, makeValue(key)); + } + return map.get(key); +}; + /** * @param {Zone} zone * @param {object} powers @@ -34,6 +48,8 @@ export const prepareWatchUtils = ( { watch, when, makeVowKit, isRetryableReason }, ) => { const detached = zone.detached(); + const utilsToNonStorableResults = new WeakMap(); + const makeWatchUtilsKit = zone.exoClassKit( 'WatchUtils', { @@ -75,7 +91,11 @@ export const prepareWatchUtils = ( // Preserve the order of the vow results. let index = 0; for (const vow of vows) { - watch(vow, this.facets.watcher, { id, index }); + watch(vow, this.facets.watcher, { + id, + index, + numResults: vows.length, + }); index += 1; } @@ -90,6 +110,12 @@ export const prepareWatchUtils = ( resultsMap: detached.mapStore('resultsMap'), }), ); + const idToNonStorableResults = provideLazyMap( + utilsToNonStorableResults, + this.facets.utils, + () => new Map(), + ); + idToNonStorableResults.set(id, new Map()); } else { // Base case: nothing to wait for. kit.resolver.resolve(harden([])); @@ -110,15 +136,30 @@ export const prepareWatchUtils = ( }, }, watcher: { - onFulfilled(value, { id, index }) { + onFulfilled(value, { id, index, numResults }) { const { idToVowState } = this.state; if (!idToVowState.has(id)) { // Resolution of the returned vow happened already. return; } const { remaining, resultsMap, resolver } = idToVowState.get(id); + const idToNonStorableResults = provideLazyMap( + utilsToNonStorableResults, + this.facets.utils, + () => new Map(), + ); + const nonStorableResults = provideLazyMap( + idToNonStorableResults, + id, + () => new Map(), + ); + // Capture the fulfilled value. - resultsMap.init(index, value); + if (zone.isStorable(value)) { + resultsMap.init(index, value); + } else { + nonStorableResults.set(index, value); + } const vowState = harden({ remaining: remaining - 1, resultsMap, @@ -130,13 +171,26 @@ export const prepareWatchUtils = ( } // We're done! Extract the array. idToVowState.delete(id); - const results = new Array(resultsMap.getSize()); - for (const [i, val] of resultsMap.entries()) { - results[i] = val; + const results = new Array(numResults); + let numLost = 0; + for (let i = 0; i < numResults; i += 1) { + if (nonStorableResults.has(i)) { + results[i] = nonStorableResults.get(i); + } else if (resultsMap.has(i)) { + results[i] = resultsMap.get(i); + } else { + numLost += 1; + } + } + if (numLost > 0) { + resolver.reject( + assert.error(X`${numLost} unstorable results were lost`), + ); + } else { + resolver.resolve(harden(results)); } - resolver.resolve(harden(results)); }, - onRejected(value, { id, index: _index }) { + onRejected(value, { id, index: _index, numResults: _numResults }) { const { idToVowState } = this.state; if (!idToVowState.has(id)) { // First rejection wins. @@ -151,7 +205,7 @@ export const prepareWatchUtils = ( onFulfilled(_result) {}, onRejected(reason, failedOp) { if (isRetryableReason(reason, undefined)) { - Fail`Pending ${bare(failedOp)} could not retry; {reason}`; + Fail`Pending ${bare(failedOp)} could not retry; ${reason}`; } }, }, From 09de0b7b1e5182b154bbbcaf12fb712dae17b457 Mon Sep 17 00:00:00 2001 From: Turadg Aleahmad Date: Mon, 1 Jul 2024 11:51:40 -0700 Subject: [PATCH 6/9] feat(types): EVow --- packages/vow/src/tools.js | 6 +++--- packages/vow/src/types.js | 6 ++++++ packages/vow/src/watch-utils.js | 13 ++++++------- packages/vow/src/watch.js | 4 ++-- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/vow/src/tools.js b/packages/vow/src/tools.js index ebd0ba1a56b..588d7eb7f8d 100644 --- a/packages/vow/src/tools.js +++ b/packages/vow/src/tools.js @@ -7,7 +7,7 @@ import { makeAsVow } from './vow-utils.js'; /** * @import {Zone} from '@agoric/base-zone'; - * @import {IsRetryableReason, AsPromiseFunction} from './types.js'; + * @import {IsRetryableReason, AsPromiseFunction, Vow, EVow} from './types.js'; */ /** @@ -33,9 +33,9 @@ export const prepareVowTools = (zone, powers = {}) => { /** * Vow-tolerant implementation of Promise.all. * - * @param {unknown[]} vows + * @param {EVow[]} maybeVows */ - const allVows = vows => watchUtils.all(vows); + const allVows = maybeVows => watchUtils.all(maybeVows); /** @type {AsPromiseFunction} */ const asPromise = (specimenP, ...watcherArgs) => diff --git a/packages/vow/src/types.js b/packages/vow/src/types.js index d439c9b5444..0f05bcb12fd 100644 --- a/packages/vow/src/types.js +++ b/packages/vow/src/types.js @@ -29,6 +29,12 @@ export {}; * @typedef {T | PromiseLike} ERef */ +/** + * Eventually a value T or Vow for it. + * @template T + * @typedef {ERef>} EVow + */ + /** * Follow the chain of vow shortening to the end, returning the final value. * This is used within E, so we must narrow the type to its remote form. diff --git a/packages/vow/src/watch-utils.js b/packages/vow/src/watch-utils.js index c314883a301..9af06f3ab6a 100644 --- a/packages/vow/src/watch-utils.js +++ b/packages/vow/src/watch-utils.js @@ -6,12 +6,11 @@ import { PromiseWatcherI } from '@agoric/base-zone'; const { Fail, bare, details: X } = assert; /** - * @import {MapStore} from '@agoric/store/src/types.js' - * @import { Zone } from '@agoric/base-zone' - * @import { Watch } from './watch.js' - * @import { When } from './when.js' - * @import {VowKit, AsPromiseFunction} from './types.js' - * @import {IsRetryableReason} from './types.js' + * @import {MapStore} from '@agoric/store/src/types.js'; + * @import {Zone} from '@agoric/base-zone'; + * @import {Watch} from './watch.js'; + * @import {When} from './when.js'; + * @import {VowKit, AsPromiseFunction, IsRetryableReason, Vow, EVow} from './types.js'; */ const VowShape = M.tagged( @@ -81,7 +80,7 @@ export const prepareWatchUtils = ( { utils: { /** - * @param {unknown[]} vows + * @param {EVow[]} vows */ all(vows) { const { nextId: id, idToVowState } = this.state; diff --git a/packages/vow/src/watch.js b/packages/vow/src/watch.js index 9391dfeefb7..fb5a793500b 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, IsRetryableReason, Vow, VowKit, VowResolver, Watcher } from './types.js'; + * @import { ERef, EVow, IsRetryableReason, Vow, VowKit, VowResolver, Watcher } from './types.js'; */ /** @@ -170,7 +170,7 @@ export const prepareWatch = ( * @template [TResult1=T] * @template [TResult2=never] * @template {any[]} [C=any[]] watcher args - * @param {ERef>} specimenP + * @param {EVow} specimenP * @param {Watcher} [watcher] * @param {C} watcherArgs */ From 1cdfdfdf1d8fe0ce54ee06b05fe45a9cee69fc0c Mon Sep 17 00:00:00 2001 From: Turadg Aleahmad Date: Mon, 1 Jul 2024 12:29:37 -0700 Subject: [PATCH 7/9] refactor: don't re-use index --- packages/vow/src/watch-utils.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/vow/src/watch-utils.js b/packages/vow/src/watch-utils.js index 9af06f3ab6a..24e70909649 100644 --- a/packages/vow/src/watch-utils.js +++ b/packages/vow/src/watch-utils.js @@ -88,24 +88,22 @@ export const prepareWatchUtils = ( const kit = makeVowKit(); // Preserve the order of the vow results. - let index = 0; - for (const vow of vows) { - watch(vow, this.facets.watcher, { + for (let index = 0; index < vows.length; index += 1) { + watch(vows[index], this.facets.watcher, { id, index, numResults: vows.length, }); - index += 1; } - if (index > 0) { + if (vows.length > 0) { // Save the state until rejection or all fulfilled. this.state.nextId += 1n; idToVowState.init( id, harden({ resolver: kit.resolver, - remaining: index, + remaining: vows.length, resultsMap: detached.mapStore('resultsMap'), }), ); From 57a46344d26483a717e0f04f2fae3cb46b035ad3 Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Sun, 30 Jun 2024 21:02:48 -0600 Subject: [PATCH 8/9] fix(vow): clearer stored/non-stored values --- packages/vow/src/vow.js | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/vow/src/vow.js b/packages/vow/src/vow.js index 7566b2cb3a1..4612cc68878 100644 --- a/packages/vow/src/vow.js +++ b/packages/vow/src/vow.js @@ -27,6 +27,9 @@ export const prepareVowKit = zone => { /** @type {WeakMap} */ const resolverToEphemera = new WeakMap(); + /** @type {WeakMap} */ + const resolverToNonStoredValue = new WeakMap(); + /** * Get the current incarnation's promise kit associated with a vowV0. * @@ -74,6 +77,7 @@ export const prepareVowKit = zone => { stepStatus: /** @type {null | 'pending' | 'fulfilled' | 'rejected'} */ ( null ), + isStoredValue: /** @type {boolean} */ (false), }), { vowV0: { @@ -81,17 +85,28 @@ export const prepareVowKit = zone => { * @returns {Promise} */ async shorten() { - const { stepStatus, value } = this.state; + const { stepStatus, isStoredValue, value } = this.state; const { resolver } = this.facets; - const ephemera = resolverToEphemera.get(resolver); switch (stepStatus) { case 'fulfilled': { - if (ephemera) return ephemera.promise; - return value; + if (isStoredValue) { + // Always return a stored fulfilled value. + return value; + } else if (resolverToNonStoredValue.has(resolver)) { + // Non-stored value is available. + return resolverToNonStoredValue.get(resolver); + } + // We can't recover the non-stored value, so throw the + // explanation. + throw value; } case 'rejected': { - if (ephemera) return ephemera.promise; + if (!isStoredValue && resolverToNonStoredValue.has(resolver)) { + // Non-stored reason is available. + throw resolverToNonStoredValue.get(resolver); + } + // Always throw a stored rejection reason. throw value; } case null: @@ -138,18 +153,20 @@ export const prepareVowKit = zone => { }, watchNextStep: { onFulfilled(value) { - const { resolver, watchNextStep } = this.facets; + const { resolver } = this.facets; const { resolve } = getPromiseKitForResolution(resolver); harden(value); if (resolve) { resolve(value); } this.state.stepStatus = 'fulfilled'; - if (zone.isStorable(value)) { + this.state.isStoredValue = zone.isStorable(value); + if (this.state.isStoredValue) { this.state.value = value; } else { - watchNextStep.onRejected( - assert.error(X`Vow fulfillment value is not storable: ${value}`), + resolverToNonStoredValue.set(resolver, value); + this.state.value = assert.error( + X`Vow fulfillment value was not stored: ${value}`, ); } }, @@ -161,11 +178,13 @@ export const prepareVowKit = zone => { reject(reason); } this.state.stepStatus = 'rejected'; - if (zone.isStorable(reason)) { + this.state.isStoredValue = zone.isStorable(reason); + if (this.state.isStoredValue) { this.state.value = reason; } else { + resolverToNonStoredValue.set(resolver, reason); this.state.value = assert.error( - X`Vow rejection reason is not storable: ${reason}`, + X`Vow rejection reason was not stored: ${reason}`, ); } }, From 57f6b4682999c024bc798031f3ee4a136bf3ecba Mon Sep 17 00:00:00 2001 From: Turadg Aleahmad Date: Mon, 1 Jul 2024 10:42:34 -0700 Subject: [PATCH 9/9] refactor: 'extra' field for future properties --- packages/vow/src/tools.js | 2 +- packages/vow/src/vow.js | 7 +++++++ packages/vow/src/watch-utils.js | 2 +- packages/vow/test/watch-utils.test.js | 1 + 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/vow/src/tools.js b/packages/vow/src/tools.js index 588d7eb7f8d..e20ebb7cdff 100644 --- a/packages/vow/src/tools.js +++ b/packages/vow/src/tools.js @@ -7,7 +7,7 @@ import { makeAsVow } from './vow-utils.js'; /** * @import {Zone} from '@agoric/base-zone'; - * @import {IsRetryableReason, AsPromiseFunction, Vow, EVow} from './types.js'; + * @import {IsRetryableReason, AsPromiseFunction, EVow} from './types.js'; */ /** diff --git a/packages/vow/src/vow.js b/packages/vow/src/vow.js index 4612cc68878..2b4013eb0cb 100644 --- a/packages/vow/src/vow.js +++ b/packages/vow/src/vow.js @@ -9,6 +9,7 @@ const { details: X } = assert; /** * @import {PromiseKit} from '@endo/promise-kit'; * @import {Zone} from '@agoric/base-zone'; + * @import {MapStore} from '@agoric/store'; * @import {VowResolver, VowKit} from './types.js'; */ @@ -78,6 +79,12 @@ export const prepareVowKit = zone => { null ), isStoredValue: /** @type {boolean} */ (false), + /** + * Map for future properties that aren't in the schema. + * UNTIL https://github.com/Agoric/agoric-sdk/issues/7407 + * @type {MapStore | undefined} + */ + extra: undefined, }), { vowV0: { diff --git a/packages/vow/src/watch-utils.js b/packages/vow/src/watch-utils.js index 24e70909649..0bb740530bc 100644 --- a/packages/vow/src/watch-utils.js +++ b/packages/vow/src/watch-utils.js @@ -10,7 +10,7 @@ const { Fail, bare, details: X } = assert; * @import {Zone} from '@agoric/base-zone'; * @import {Watch} from './watch.js'; * @import {When} from './when.js'; - * @import {VowKit, AsPromiseFunction, IsRetryableReason, Vow, EVow} from './types.js'; + * @import {VowKit, AsPromiseFunction, IsRetryableReason, EVow} from './types.js'; */ const VowShape = M.tagged( diff --git a/packages/vow/test/watch-utils.test.js b/packages/vow/test/watch-utils.test.js index d4c37e98c3b..1b3441cc495 100644 --- a/packages/vow/test/watch-utils.test.js +++ b/packages/vow/test/watch-utils.test.js @@ -160,6 +160,7 @@ test('asPromise handles watcher arguments', async t => { }, }; + // XXX fix type: `watcherContext` doesn't need to be an array const result = await asPromise(vow, watcher, ['ctx']); t.is(result, 'watcher test'); t.true(watcherCalled);