Skip to content

Commit

Permalink
feat(vows): improve handling of ephemeral values (#9620)
Browse files Browse the repository at this point in the history
refs: #9308

## Description
- adds `asPromise` helper to `VowTools` for unwrapping `Vow|Promise`, ensuring proper handling of ephemeral promises
- updates watch-utils to better handle values that are not storable durably, such as promises

### Testing Considerations
This does not include tests that simulate an upgrade - only a heap zone is used in the included tests. See #9631

### Upgrade Considerations
This PR includes changes we'd like to be in the initial release of vows.
  • Loading branch information
mergify[bot] committed Jul 1, 2024
2 parents caaec05 + ff92211 commit 8edf902
Show file tree
Hide file tree
Showing 7 changed files with 264 additions and 42 deletions.
2 changes: 1 addition & 1 deletion packages/base-zone/src/watch-promise.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 17 additions & 6 deletions packages/vow/src/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -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} from './types.js' */
/**
* @import {Zone} from '@agoric/base-zone';
* @import {IsRetryableReason, AsPromiseFunction, EVow} from './types.js';
*/

/**
* @param {Zone} zone
Expand All @@ -19,18 +21,27 @@ 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);

/**
* Vow-tolerant implementation of Promise.all.
*
* @param {unknown[]} vows
* @param {EVow<unknown>[]} maybeVows
*/
const allVows = vows => watchUtils.all(vows);
const allVows = maybeVows => watchUtils.all(maybeVows);

/** @type {AsPromiseFunction} */
const asPromise = (specimenP, ...watcherArgs) =>
watchUtils.asPromise(specimenP, ...watcherArgs);

return harden({ when, watch, makeVowKit, allVows, asVow });
return harden({ when, watch, makeVowKit, allVows, asVow, asPromise });
};
harden(prepareVowTools);

Expand Down
20 changes: 20 additions & 0 deletions packages/vow/src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export {};
* @typedef {T | PromiseLike<T>} ERef
*/

/**
* Eventually a value T or Vow for it.
* @template T
* @typedef {ERef<T | Vow<T>>} 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.
Expand Down Expand Up @@ -86,3 +92,17 @@ export {};
* @property {(value: T, ...args: C) => Vow<TResult1> | PromiseVow<TResult1> | TResult1} [onFulfilled]
* @property {(reason: any, ...args: C) => Vow<TResult2> | PromiseVow<TResult2> | 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<T | Vow<T>>} specimenP
* @param {Watcher<T, TResult1, TResult2, C>} [watcher]
* @param {C} [watcherArgs]
* @returns {Promise<TResult1 | TResult2>}
*/
78 changes: 66 additions & 12 deletions packages/vow/src/vow.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ 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 {MapStore} from '@agoric/store';
* @import {VowResolver, VowKit} from './types.js';
*/

const sink = () => {};
Expand All @@ -25,6 +28,9 @@ export const prepareVowKit = zone => {
/** @type {WeakMap<VowResolver, VowEphemera>} */
const resolverToEphemera = new WeakMap();

/** @type {WeakMap<VowResolver, any>} */
const resolverToNonStoredValue = new WeakMap();

/**
* Get the current incarnation's promise kit associated with a vowV0.
*
Expand Down Expand Up @@ -61,30 +67,55 @@ 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
),
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<any, any> | undefined}
*/
extra: undefined,
}),
{
vowV0: {
/**
* @returns {Promise<any>}
*/
async shorten() {
const { stepStatus, value } = this.state;
const { stepStatus, isStoredValue, value } = this.state;
const { resolver } = this.facets;

switch (stepStatus) {
case 'fulfilled':
return value;
case 'rejected':
case 'fulfilled': {
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 (!isStoredValue && resolverToNonStoredValue.has(resolver)) {
// Non-stored reason is available.
throw resolverToNonStoredValue.get(resolver);
}
// Always throw a stored rejection reason.
throw value;
}
case null:
case 'pending':
return provideCurrentKit(this.facets.resolver).promise;
Expand Down Expand Up @@ -131,15 +162,38 @@ export const prepareVowKit = zone => {
onFulfilled(value) {
const { resolver } = this.facets;
const { resolve } = getPromiseKitForResolution(resolver);
harden(value);
if (resolve) {
resolve(value);
}
this.state.stepStatus = 'fulfilled';
this.state.value = value;
this.state.isStoredValue = zone.isStorable(value);
if (this.state.isStoredValue) {
this.state.value = value;
} else {
resolverToNonStoredValue.set(resolver, value);
this.state.value = assert.error(
X`Vow fulfillment value was not stored: ${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;
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 was not stored: ${reason}`,
);
}
},
},
},
Expand Down
Loading

0 comments on commit 8edf902

Please sign in to comment.