From 793f028155702e613b1bdf8204af6837cfe5e8a3 Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Sun, 29 Oct 2023 15:51:14 -0600 Subject: [PATCH 01/19] feat(whenable): first cut --- packages/agoric-cli/src/sdk-package-names.js | 1 + packages/internal/src/queue.js | 9 +- packages/whenable/package.json | 49 ++++++ packages/whenable/src/heap.js | 17 ++ packages/whenable/src/index.js | 4 + packages/whenable/src/when.js | 156 +++++++++++++++++++ packages/whenable/src/whenable.js | 84 ++++++++++ packages/whenable/tsconfig.build.json | 6 + packages/whenable/tsconfig.json | 18 +++ 9 files changed, 339 insertions(+), 5 deletions(-) create mode 100755 packages/whenable/package.json create mode 100644 packages/whenable/src/heap.js create mode 100644 packages/whenable/src/index.js create mode 100644 packages/whenable/src/when.js create mode 100644 packages/whenable/src/whenable.js create mode 100644 packages/whenable/tsconfig.build.json create mode 100644 packages/whenable/tsconfig.json diff --git a/packages/agoric-cli/src/sdk-package-names.js b/packages/agoric-cli/src/sdk-package-names.js index 6d55731c6e0..79784c1f02e 100644 --- a/packages/agoric-cli/src/sdk-package-names.js +++ b/packages/agoric-cli/src/sdk-package-names.js @@ -44,6 +44,7 @@ export default [ "@agoric/vm-config", "@agoric/wallet", "@agoric/wallet-backend", + "@agoric/whenable", "@agoric/xsnap", "@agoric/xsnap-lockdown", "@agoric/zoe", diff --git a/packages/internal/src/queue.js b/packages/internal/src/queue.js index 6f9e8e2d0ed..5e04a7355f1 100644 --- a/packages/internal/src/queue.js +++ b/packages/internal/src/queue.js @@ -31,14 +31,13 @@ export const makeWithQueue = () => { }; /** - * @template {any[]} T - * @template R - * @param {(...args: T) => Promise} inner + * @template {(...args: any[]) => any} T + * @param {T} inner */ return function withQueue(inner) { /** - * @param {T} args - * @returns {Promise} + * @param {Parameters} args + * @returns {Promise>>} */ return function queueCall(...args) { // Curry the arguments into the inner function, and diff --git a/packages/whenable/package.json b/packages/whenable/package.json new file mode 100755 index 00000000000..27224f0f7e9 --- /dev/null +++ b/packages/whenable/package.json @@ -0,0 +1,49 @@ +{ + "name": "@agoric/whenable", + "version": "0.1.0", + "description": "Remote (shortening and disconnection-tolerant) Promise-likes", + "type": "module", + "main": "src/index.js", + "engines": { + "node": ">=14.15.0" + }, + "scripts": { + "build": "exit 0", + "prepack": "tsc --build tsconfig.build.json", + "postpack": "git clean -f '*.d.ts*'", + "test": "exit 0", + "test:nyc": "exit 0", + "test:xs": "exit 0", + "lint-fix": "yarn lint:eslint --fix", + "lint": "run-s --continue-on-error lint:*", + "lint:eslint": "eslint .", + "lint:types": "tsc" + }, + "dependencies": { + "@agoric/base-zone": "^0.1.0", + "@agoric/internal": "^0.3.2", + "@endo/far": "^0.2.21", + "@endo/patterns": "^0.2.5", + "@endo/promise-kit": "^0.2.59" + }, + "devDependencies": { + "@endo/init": "^0.5.59", + "ava": "^5.3.0" + }, + "ava": { + "require": [ + "@endo/init/debug.js" + ] + }, + "author": "Agoric", + "license": "Apache-2.0", + "files": [ + "src" + ], + "publishConfig": { + "access": "public" + }, + "typeCoverage": { + "atLeast": 92.26 + } +} diff --git a/packages/whenable/src/heap.js b/packages/whenable/src/heap.js new file mode 100644 index 00000000000..c1e23ae6bcd --- /dev/null +++ b/packages/whenable/src/heap.js @@ -0,0 +1,17 @@ +// @ts-check +import { makeHeapZone } from '@agoric/base-zone/heap.js'; +import { prepareWhen } from './when.js'; +import { prepareWhenableKit } from './whenable.js'; + +/** + * @param {import('@agoric/base-zone').Zone} zone + */ +export const prepareWhenableModule = zone => { + const makeWhenableKit = prepareWhenableKit(zone); + const when = prepareWhen(zone); + return harden({ when, makeWhenableKit }); +}; +harden(prepareWhenableModule); + +// Heap-based whenable support is exported to assist in migration. +export const { when, makeWhenableKit } = prepareWhenableModule(makeHeapZone()); diff --git a/packages/whenable/src/index.js b/packages/whenable/src/index.js new file mode 100644 index 00000000000..786544fd5a1 --- /dev/null +++ b/packages/whenable/src/index.js @@ -0,0 +1,4 @@ +// @ts-check +export * from './when.js'; +export * from './whenable.js'; +export * from './heap.js'; diff --git a/packages/whenable/src/when.js b/packages/whenable/src/when.js new file mode 100644 index 00000000000..abc553dd1e9 --- /dev/null +++ b/packages/whenable/src/when.js @@ -0,0 +1,156 @@ +// @ts-check +/* global globalThis */ +import { E } from '@endo/far'; +import { M } from '@endo/patterns'; +import { isUpgradeDisconnection } from '@agoric/internal/src/upgrade-api.js'; + +const { Fail } = assert; + +export const PromiseWatcherI = M.interface('PromiseWatcher', { + onFulfilled: M.call(M.any()).rest(M.any()).returns(M.any()), + onRejected: M.call(M.any()).rest(M.any()).returns(M.any()), +}); + +/** + * @template [T=any] + * @typedef {{ whenable0: { shorten(): Promise>} }} Whenable + */ + +/** + * @typedef {object} Watcher + * @property {(...args: unknown[]) => void} [onFulfilled] + * @property {(...args: unknown[]) => void} [onRejected] + */ + +/** @type {(p: PromiseLike, watcher: Watcher, ...args: unknown[]) => void} */ +let watchPromise = /** @type {any} */ (globalThis).VatData?.watchPromise; +if (!watchPromise) { + /** + * Adapt a promise watcher method to E.when. + * @param {Record unknown>} that + * @param {PropertyKey} prop + * @param {unknown[]} postArgs + */ + const callMeMaybe = (that, prop, postArgs) => { + const fn = that[prop]; + if (typeof fn !== 'function') { + return undefined; + } + /** + * @param {unknown} arg value or reason + */ + const wrapped = arg => { + // Don't return a value, to prevent E.when from subscribing to a resulting + // promise. + fn.call(that, arg, ...postArgs); + }; + return wrapped; + }; + + // Shim the promise watcher behaviour when VatData.watchPromise is not available. + watchPromise = (p, watcher, ...args) => { + const onFulfilled = callMeMaybe(watcher, 'onFulfilled', args); + const onRejected = callMeMaybe(watcher, 'onRejected', args); + onFulfilled || + onRejected || + Fail`promise watcher must implement at least one handler method`; + void E.when(p, onFulfilled, onRejected); + }; +} + +/** + * @param {any} specimen + * @param {Watcher} watcher + */ +const watchWhenable = (specimen, watcher) => { + let promise; + const whenable0 = specimen && specimen.whenable0; + if (whenable0) { + promise = E(whenable0).shorten(); + } else { + promise = E.resolve(specimen); + } + watchPromise(promise, watcher); +}; + +/** + * @param {import('@agoric/base-zone').Zone} zone + */ +export const prepareWhen = zone => { + const makeReconnectWatcher = zone.exoClass( + 'ReconnectWatcher', + PromiseWatcherI, + (whenable, watcher) => ({ + whenable, + watcher, + }), + { + /** + * @param {any} value + */ + onFulfilled(value) { + const { watcher } = this.state; + if (!watcher) { + return; + } + if (value && value.whenable0) { + // We've been shortened, so reflect our state accordingly, and go again. + this.state.whenable = value; + watchWhenable(this.state.whenable, this.self); + return; + } + this.state.watcher = undefined; + if (watcher.onFulfilled) { + watcher.onFulfilled(value); + } + }, + /** + * @param {any} reason + */ + onRejected(reason) { + const { watcher } = this.state; + if (!watcher) { + return; + } + if (isUpgradeDisconnection(reason)) { + watchWhenable(this.state.whenable, this.self); + return; + } + this.state.watcher = undefined; + if (watcher.onRejected) { + watcher.onRejected(reason); + } else { + throw reason; // for host's unhandled rejection handler to catch + } + }, + }, + ); + + /** + * @template T + * @param {import('@endo/far').ERef | T>} whenableP + * @param {{ onFulfilled(value: T): void, onRejected(reason: any): void; }} [watcher] + */ + const when = async (whenableP, watcher) => { + // Ensure we have a presence that won't be disconnected later. + /** @type {any} */ + let specimen = await whenableP; + if (!watcher) { + // Shorten the whenable chain without a watcher. + while (specimen && specimen.whenable0) { + specimen = await E(specimen.whenable0).shorten(); + } + return /** @type {T} */ (specimen); + } + const reconnectWatcher = makeReconnectWatcher(specimen, watcher); + watchWhenable(specimen, reconnectWatcher); + return /** @type {T} */ ('`when(p, watcher)` has no useful return value'); + }; + harden(when); + + return when; +}; + +harden(prepareWhen); + +/** @typedef {ReturnType} When */ diff --git a/packages/whenable/src/whenable.js b/packages/whenable/src/whenable.js new file mode 100644 index 00000000000..80f14fab4e9 --- /dev/null +++ b/packages/whenable/src/whenable.js @@ -0,0 +1,84 @@ +// @ts-check +import { makePromiseKit } from '@endo/promise-kit'; +import { M } from '@endo/patterns'; + +/** + * @param {import('@agoric/base-zone').Zone} zone + */ +export const prepareWhenableKit = zone => { + /** + * @type {WeakMap>} + */ + const whenableToPromiseKit = new WeakMap(); + + /** + * Get the current incarnation's promise kit associated with a whenable. + * + * @param {import('./when.js').Whenable['whenable0']} whenable + * @returns {import('@endo/promise-kit').PromiseKit} + */ + const findCurrentKit = whenable => { + let pk = whenableToPromiseKit.get(whenable); + if (!pk) { + pk = makePromiseKit(); + whenableToPromiseKit.set(whenable, pk); + } + return pk; + }; + + const rawMakeWhenableKit = zone.exoClassKit( + 'WhenableKit', + { + whenable: M.interface('Whenable', { + shorten: M.call().returns(M.promise()), + }), + settler: M.interface('Settler', { + resolve: M.call().optional(M.any()).returns(), + reject: M.call().optional(M.any()).returns(), + }), + }, + () => ({}), + { + whenable: { + /** + * @returns {Promise} + */ + shorten() { + return findCurrentKit(this.facets.whenable).promise; + }, + }, + settler: { + /** + * @param {any} [value] + */ + resolve(value) { + findCurrentKit(this.facets.whenable).resolve(value); + }, + /** + * @param {any} [reason] + */ + reject(reason) { + findCurrentKit(this.facets.whenable).reject(reason); + }, + }, + }, + ); + + const makeWhenableKit = () => { + const { settler, whenable: whenable0 } = rawMakeWhenableKit(); + return harden({ settler, whenable: { whenable0 } }); + }; + return makeWhenableKit; +}; + +harden(prepareWhenableKit); + +/** + * @template [T=any] + * @typedef {{ whenable: import('./when').Whenable, settler: Settler }} WhenableKit + */ + +/** + * @template [T=any] + * @typedef {{ resolve(value?: T): void, reject(reason?: any): void }} Settler + */ diff --git a/packages/whenable/tsconfig.build.json b/packages/whenable/tsconfig.build.json new file mode 100644 index 00000000000..fe5ea57d603 --- /dev/null +++ b/packages/whenable/tsconfig.build.json @@ -0,0 +1,6 @@ +{ + "extends": [ + "./tsconfig.json", + "../../tsconfig-build-options.json" + ] +} diff --git a/packages/whenable/tsconfig.json b/packages/whenable/tsconfig.json new file mode 100644 index 00000000000..69865103cc3 --- /dev/null +++ b/packages/whenable/tsconfig.json @@ -0,0 +1,18 @@ +// This file can contain .js-specific Typescript compiler config. +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "maxNodeModuleJsDepth": 2, // as in jsconfig's default + // Disable b/c @endo/init can't pass noImplicitAny + "checkJs": false, + "noImplicitAny": true, + }, + "include": [ + "*.js", + "*.ts", + "src/**/*.js", + "src/**/*.ts", + "test/**/*.js", + "test/**/*.ts" + ] +} From c65c774ef4efc69d70162dd14592de56d4ca7b8c Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Sun, 26 Nov 2023 18:56:38 -0600 Subject: [PATCH 02/19] test(whenable): find bugs and improve the implementation --- packages/whenable/package.json | 2 +- packages/whenable/src/heap.js | 13 +--- packages/whenable/src/index.js | 1 + packages/whenable/src/module.js | 17 +++++ packages/whenable/src/when.js | 18 +++++- packages/whenable/src/whenable.js | 53 +++++++++------ packages/whenable/test/test-disconnect.js | 78 +++++++++++++++++++++++ packages/whenable/tsconfig.json | 4 +- 8 files changed, 149 insertions(+), 37 deletions(-) create mode 100644 packages/whenable/src/module.js create mode 100644 packages/whenable/test/test-disconnect.js diff --git a/packages/whenable/package.json b/packages/whenable/package.json index 27224f0f7e9..a37fba3d09e 100755 --- a/packages/whenable/package.json +++ b/packages/whenable/package.json @@ -11,7 +11,7 @@ "build": "exit 0", "prepack": "tsc --build tsconfig.build.json", "postpack": "git clean -f '*.d.ts*'", - "test": "exit 0", + "test": "ava", "test:nyc": "exit 0", "test:xs": "exit 0", "lint-fix": "yarn lint:eslint --fix", diff --git a/packages/whenable/src/heap.js b/packages/whenable/src/heap.js index c1e23ae6bcd..1b14fc15a95 100644 --- a/packages/whenable/src/heap.js +++ b/packages/whenable/src/heap.js @@ -1,17 +1,6 @@ // @ts-check import { makeHeapZone } from '@agoric/base-zone/heap.js'; -import { prepareWhen } from './when.js'; -import { prepareWhenableKit } from './whenable.js'; - -/** - * @param {import('@agoric/base-zone').Zone} zone - */ -export const prepareWhenableModule = zone => { - const makeWhenableKit = prepareWhenableKit(zone); - const when = prepareWhen(zone); - return harden({ when, makeWhenableKit }); -}; -harden(prepareWhenableModule); +import { prepareWhenableModule } from './module.js'; // Heap-based whenable support is exported to assist in migration. export const { when, makeWhenableKit } = prepareWhenableModule(makeHeapZone()); diff --git a/packages/whenable/src/index.js b/packages/whenable/src/index.js index 786544fd5a1..346cdaca0f2 100644 --- a/packages/whenable/src/index.js +++ b/packages/whenable/src/index.js @@ -1,4 +1,5 @@ // @ts-check export * from './when.js'; export * from './whenable.js'; +export * from './module.js'; export * from './heap.js'; diff --git a/packages/whenable/src/module.js b/packages/whenable/src/module.js new file mode 100644 index 00000000000..c14e4039fb0 --- /dev/null +++ b/packages/whenable/src/module.js @@ -0,0 +1,17 @@ +// @ts-check +import { prepareWhen } from './when.js'; +import { prepareWhenableKit } from './whenable.js'; + +/** + * @param {import('@agoric/base-zone').Zone} zone + * @param {object} [powers] + * @param {(reason: any) => boolean} [powers.rejectionMeansRetry] + * @param {WeakMap} [powers.whenable0ToEphemeral] + */ +export const prepareWhenableModule = (zone, powers) => { + const { rejectionMeansRetry, whenable0ToEphemeral } = powers || {}; + const makeWhenableKit = prepareWhenableKit(zone, whenable0ToEphemeral); + const when = prepareWhen(zone, rejectionMeansRetry); + return harden({ when, makeWhenableKit }); +}; +harden(prepareWhenableModule); diff --git a/packages/whenable/src/when.js b/packages/whenable/src/when.js index abc553dd1e9..01a6ed42b25 100644 --- a/packages/whenable/src/when.js +++ b/packages/whenable/src/when.js @@ -2,6 +2,7 @@ /* global globalThis */ import { E } from '@endo/far'; import { M } from '@endo/patterns'; + import { isUpgradeDisconnection } from '@agoric/internal/src/upgrade-api.js'; const { Fail } = assert; @@ -75,8 +76,12 @@ const watchWhenable = (specimen, watcher) => { /** * @param {import('@agoric/base-zone').Zone} zone + * @param {(reason: any) => boolean} [rejectionMeansRetry] */ -export const prepareWhen = zone => { +export const prepareWhen = ( + zone, + rejectionMeansRetry = isUpgradeDisconnection, +) => { const makeReconnectWatcher = zone.exoClass( 'ReconnectWatcher', PromiseWatcherI, @@ -112,7 +117,7 @@ export const prepareWhen = zone => { if (!watcher) { return; } - if (isUpgradeDisconnection(reason)) { + if (rejectionMeansRetry(reason)) { watchWhenable(this.state.whenable, this.self); return; } @@ -138,7 +143,14 @@ export const prepareWhen = zone => { if (!watcher) { // Shorten the whenable chain without a watcher. while (specimen && specimen.whenable0) { - specimen = await E(specimen.whenable0).shorten(); + specimen = await E(specimen.whenable0) + .shorten() + .catch(e => { + if (rejectionMeansRetry(e)) { + return specimen; + } + throw e; + }); } return /** @type {T} */ (specimen); } diff --git a/packages/whenable/src/whenable.js b/packages/whenable/src/whenable.js index 80f14fab4e9..603ebfab075 100644 --- a/packages/whenable/src/whenable.js +++ b/packages/whenable/src/whenable.js @@ -4,32 +4,45 @@ import { M } from '@endo/patterns'; /** * @param {import('@agoric/base-zone').Zone} zone + * @param {WeakMap} [whenable0ToEphemeral] */ -export const prepareWhenableKit = zone => { +export const prepareWhenableKit = ( + zone, + whenable0ToEphemeral = new WeakMap(), +) => { /** - * @type {WeakMap>} - */ - const whenableToPromiseKit = new WeakMap(); - - /** - * Get the current incarnation's promise kit associated with a whenable. + * Get the current incarnation's promise kit associated with a whenable0. * - * @param {import('./when.js').Whenable['whenable0']} whenable + * @param {import('./when.js').Whenable['whenable0']} whenable0 * @returns {import('@endo/promise-kit').PromiseKit} */ - const findCurrentKit = whenable => { - let pk = whenableToPromiseKit.get(whenable); + const findCurrentKit = whenable0 => { + let pk = whenable0ToEphemeral.get(whenable0); if (!pk) { pk = makePromiseKit(); - whenableToPromiseKit.set(whenable, pk); + whenable0ToEphemeral.set(whenable0, pk); } return pk; }; + /** + * @param {(value: unknown) => void} cb + * @param {import('./when.js').Whenable['whenable0']} whenable0 + * @param {Promise} promise + * @param {unknown} value + */ + const settle = (cb, whenable0, promise, value) => { + if (!cb) { + return; + } + whenable0ToEphemeral.set(whenable0, harden({ promise })); + cb(value); + }; + const rawMakeWhenableKit = zone.exoClassKit( - 'WhenableKit', + 'Whenable0Kit', { - whenable: M.interface('Whenable', { + whenable0: M.interface('Whenable0', { shorten: M.call().returns(M.promise()), }), settler: M.interface('Settler', { @@ -39,12 +52,12 @@ export const prepareWhenableKit = zone => { }, () => ({}), { - whenable: { + whenable0: { /** * @returns {Promise} */ shorten() { - return findCurrentKit(this.facets.whenable).promise; + return findCurrentKit(this.facets.whenable0).promise; }, }, settler: { @@ -52,20 +65,24 @@ export const prepareWhenableKit = zone => { * @param {any} [value] */ resolve(value) { - findCurrentKit(this.facets.whenable).resolve(value); + const { whenable0 } = this.facets; + const { resolve, promise } = findCurrentKit(whenable0); + settle(resolve, whenable0, promise, value); }, /** * @param {any} [reason] */ reject(reason) { - findCurrentKit(this.facets.whenable).reject(reason); + const { whenable0 } = this.facets; + const { reject, promise } = findCurrentKit(whenable0); + settle(reject, whenable0, promise, reason); }, }, }, ); const makeWhenableKit = () => { - const { settler, whenable: whenable0 } = rawMakeWhenableKit(); + const { settler, whenable0 } = rawMakeWhenableKit(); return harden({ settler, whenable: { whenable0 } }); }; return makeWhenableKit; diff --git a/packages/whenable/test/test-disconnect.js b/packages/whenable/test/test-disconnect.js new file mode 100644 index 00000000000..618f2ef9de6 --- /dev/null +++ b/packages/whenable/test/test-disconnect.js @@ -0,0 +1,78 @@ +// @ts-check +import test from 'ava'; + +import { makeHeapZone } from '@agoric/base-zone/heap.js'; +import { prepareWhenableModule } from '../src/module.js'; +import { makePromiseKit } from '@endo/promise-kit'; + +/** + * @param {import('@agoric/base-zone').Zone} zone + * @returns {import('ava').ImplementationFn<[]>} + */ +const testRetryOnDisconnect = zone => async t => { + let doRetry = false; + const rejectionMeansRetry = _e => doRetry; + const whenable0ToEphemeral = new Map(); + + const { when, makeWhenableKit } = prepareWhenableModule(zone, { + rejectionMeansRetry, + whenable0ToEphemeral, + }); + + for await (const watchPromise of [false, true]) { + for await (const retry of [false, true]) { + doRetry = retry; + whenable0ToEphemeral.clear(); + + const { whenable, settler } = makeWhenableKit(); + + let resultP; + if (watchPromise) { + const pk = makePromiseKit(); + const p = when(whenable, { + onFulfilled(value) { + pk.resolve(value); + }, + onRejected(reason) { + pk.reject(reason); + }, + }); + t.regex( + await p, + /no useful return/, + `no useful return expected (retry=${retry}, watchPromise=${watchPromise})`, + ); + resultP = pk.promise; + } else { + resultP = when(whenable); + } + resultP = resultP.catch(e => ['rejected', e]); + + await null; // two turns to allow the whenable0 to be registered + await null; + + const ephemeral = [...whenable0ToEphemeral.values()]; + ephemeral[0].reject('disconnected'); + + // Simulate an upgrade. + whenable0ToEphemeral.clear(); + settler.resolve('resolved'); + + if (retry) { + t.is( + await resultP, + 'resolved', + `resolve expected (retry=${retry}, watchPromise=${watchPromise})`, + ); + } else { + t.deepEqual( + await resultP, + ['rejected', 'disconnected'], + `reject expected (retry=${retry}, watchPromise=${watchPromise})`, + ); + } + } + } +}; + +test('retry on disconnection', testRetryOnDisconnect(makeHeapZone())); diff --git a/packages/whenable/tsconfig.json b/packages/whenable/tsconfig.json index 69865103cc3..ef4aaafb0a2 100644 --- a/packages/whenable/tsconfig.json +++ b/packages/whenable/tsconfig.json @@ -3,9 +3,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "maxNodeModuleJsDepth": 2, // as in jsconfig's default - // Disable b/c @endo/init can't pass noImplicitAny - "checkJs": false, - "noImplicitAny": true, + "checkJs": false }, "include": [ "*.js", From 282b5b5146e5afcfec7a5b7d3b207e435c43ea72 Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Mon, 27 Nov 2023 10:09:18 -0600 Subject: [PATCH 03/19] ci(test-all-packages): add `packages/whenable` --- .github/workflows/test-all-packages.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test-all-packages.yml b/.github/workflows/test-all-packages.yml index 9cde1d97aac..98ffe2490ea 100644 --- a/.github/workflows/test-all-packages.yml +++ b/.github/workflows/test-all-packages.yml @@ -163,6 +163,9 @@ jobs: - name: yarn test (cosmic-proto) if: (success() || failure()) run: cd packages/cosmic-proto && yarn ${{ steps.vars.outputs.test }} | $TEST_COLLECT + - name: yarn test (whenable) + if: (success() || failure()) + run: cd packages/whenable && yarn ${{ steps.vars.outputs.test }} | $TEST_COLLECT - name: notify on failure if: failure() && github.event_name != 'pull_request' uses: ./.github/actions/notify-status From 663a5fde5e8c4cfe68eb9e65e980aa3b00412d95 Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Sun, 3 Dec 2023 19:41:57 -0600 Subject: [PATCH 04/19] fix(whenable): enhance to pass basic tests --- packages/whenable/src/index.js | 3 + packages/whenable/src/module.js | 12 +- packages/whenable/src/types.js | 32 ++++ packages/whenable/src/watch.js | 193 ++++++++++++++++++++++ packages/whenable/src/when.js | 155 +++-------------- packages/whenable/src/whenable-utils.js | 16 ++ packages/whenable/src/whenable.js | 77 +++++---- packages/whenable/test/test-disconnect.js | 124 ++++++++------ 8 files changed, 397 insertions(+), 215 deletions(-) create mode 100644 packages/whenable/src/types.js create mode 100644 packages/whenable/src/watch.js create mode 100644 packages/whenable/src/whenable-utils.js diff --git a/packages/whenable/src/index.js b/packages/whenable/src/index.js index 346cdaca0f2..850d3e02e36 100644 --- a/packages/whenable/src/index.js +++ b/packages/whenable/src/index.js @@ -3,3 +3,6 @@ export * from './when.js'; export * from './whenable.js'; export * from './module.js'; export * from './heap.js'; + +// eslint-disable-next-line import/export +export * from './types.js'; diff --git a/packages/whenable/src/module.js b/packages/whenable/src/module.js index c14e4039fb0..61c0ad5b866 100644 --- a/packages/whenable/src/module.js +++ b/packages/whenable/src/module.js @@ -1,17 +1,19 @@ // @ts-check +import { isUpgradeDisconnection } from '@agoric/internal/src/upgrade-api.js'; import { prepareWhen } from './when.js'; import { prepareWhenableKit } from './whenable.js'; +import { prepareWatch } from './watch.js'; /** * @param {import('@agoric/base-zone').Zone} zone * @param {object} [powers] * @param {(reason: any) => boolean} [powers.rejectionMeansRetry] - * @param {WeakMap} [powers.whenable0ToEphemeral] */ export const prepareWhenableModule = (zone, powers) => { - const { rejectionMeansRetry, whenable0ToEphemeral } = powers || {}; - const makeWhenableKit = prepareWhenableKit(zone, whenable0ToEphemeral); - const when = prepareWhen(zone, rejectionMeansRetry); - return harden({ when, makeWhenableKit }); + const { rejectionMeansRetry = isUpgradeDisconnection } = powers || {}; + const makeWhenableKit = prepareWhenableKit(zone); + const when = prepareWhen(zone, makeWhenableKit, rejectionMeansRetry); + const watch = prepareWatch(zone, makeWhenableKit, rejectionMeansRetry); + return harden({ watch, when, makeWhenableKit }); }; harden(prepareWhenableModule); diff --git a/packages/whenable/src/types.js b/packages/whenable/src/types.js new file mode 100644 index 00000000000..b992c9d3df8 --- /dev/null +++ b/packages/whenable/src/types.js @@ -0,0 +1,32 @@ +export {}; + +/** + * @template T + * @typedef {PromiseLike>} PromiseWhenable + */ + +/** + * @template [T=any] + * @typedef {{ whenable0: { shorten(): Promise>} }} Whenable + */ + +/** + * @template [T=any] + * @typedef {{ + * whenable: import('./types.js').Whenable, + * settler: Settler, + * promise: Promise + * }} WhenableKit + */ + +/** + * @template [T=any] + * @typedef {{ resolve(value?: T): void, reject(reason?: any): void }} Settler + */ + +/** + * @template [T=any] + * @typedef {object} Watcher + * @property {(value: T) => Whenable | PromiseWhenable | TResult1} [onFullfilled] + * @property {(reason: any) => Whenable | PromiseWhenable | TResult2} [onRejected] + */ diff --git a/packages/whenable/src/watch.js b/packages/whenable/src/watch.js new file mode 100644 index 00000000000..49750b75b80 --- /dev/null +++ b/packages/whenable/src/watch.js @@ -0,0 +1,193 @@ +// @ts-check +/* global globalThis */ +import { E } from '@endo/far'; +import { M } from '@endo/patterns'; + +import { prepareWhenableKit } from './whenable.js'; +import { getFirstWhenable } from './whenable-utils.js'; + +const { Fail } = assert; + +export const PromiseWatcherI = M.interface('PromiseWatcher', { + onFulfilled: M.call(M.any()).rest(M.any()).returns(M.any()), + onRejected: M.call(M.any()).rest(M.any()).returns(M.any()), +}); + +/** + * @typedef {object} PromiseWatcher + * @property {(...args: unknown[]) => void} [onFulfilled] + * @property {(...args: unknown[]) => void} [onRejected] + */ + +/** @type {(p: PromiseLike, watcher: PromiseWatcher, ...args: unknown[]) => void} */ +let watchPromise = /** @type {any} */ (globalThis).VatData?.watchPromise; +if (!watchPromise) { + /** + * Adapt a promise watcher method to E.when. + * @param {Record unknown>} that + * @param {PropertyKey} prop + * @param {unknown[]} postArgs + */ + const callMeMaybe = (that, prop, postArgs) => { + const fn = that[prop]; + if (typeof fn !== 'function') { + return undefined; + } + /** + * @param {unknown} arg value or reason + */ + const wrapped = arg => { + // Don't return a value, to prevent E.when from subscribing to a resulting + // promise. + fn.call(that, arg, ...postArgs); + }; + return wrapped; + }; + + // Shim the promise watcher behaviour when VatData.watchPromise is not available. + watchPromise = (p, watcher, ...args) => { + const onFulfilled = callMeMaybe(watcher, 'onFulfilled', args); + const onRejected = callMeMaybe(watcher, 'onRejected', args); + onFulfilled || + onRejected || + Fail`promise watcher must implement at least one handler method`; + void E.when(p, onFulfilled, onRejected); + }; +} + +/** + * @param {any} specimen + * @param {import('./types.js').Watcher} watcher + */ +const watchWhenable = (specimen, watcher) => { + let promise; + const whenable0 = specimen && specimen.whenable0; + if (whenable0) { + promise = E(whenable0).shorten(); + } else { + promise = E.resolve(specimen); + } + watchPromise(promise, watcher); +}; + +/** + * @param {import('@agoric/base-zone').Zone} zone + * @param {() => import('./types.js').WhenableKit} makeWhenableKit + * @param {(reason: any) => boolean} [rejectionMeansRetry] + */ +export const prepareWatch = ( + zone, + makeWhenableKit, + rejectionMeansRetry = () => false, +) => { + const makeKit = makeWhenableKit || prepareWhenableKit(zone); + + /** + * @param {import('./types.js').Settler} settler + * @param {import('./types.js').Watcher} watcher + * @param {'onFulfilled' | 'onRejected'} wcb + * @param {unknown} value + */ + const settle = (settler, watcher, wcb, value) => { + try { + let chainValue = value; + const w = watcher[wcb]; + if (w) { + chainValue = w(value); + } else if (wcb === 'onRejected') { + throw value; + } + settler && settler.resolve(chainValue); + } catch (e) { + if (settler) { + settler.reject(e); + } else { + throw e; + } + } + }; + const makeReconnectWatcher = zone.exoClass( + 'ReconnectWatcher', + PromiseWatcherI, + (whenable, watcher, settler) => ({ + whenable, + watcher, + settler, + }), + { + /** + * @param {any} value + */ + onFulfilled(value) { + const { watcher, settler } = this.state; + if (value && value.whenable0) { + // We've been shortened, so reflect our state accordingly, and go again. + this.state.whenable = value; + watchWhenable(this.state.whenable, this.self); + return; + } + this.state.watcher = undefined; + this.state.settler = undefined; + if (watcher) { + settle(settler, watcher, 'onFulfilled', value); + } else if (settler) { + settler.resolve(value); + } + }, + /** + * @param {any} reason + */ + onRejected(reason) { + const { watcher, settler } = this.state; + if (rejectionMeansRetry(reason)) { + watchWhenable(this.state.whenable, this.self); + return; + } + if (!watcher) { + this.state.settler = undefined; + settler && settler.reject(reason); + return; + } + this.state.watcher = undefined; + this.state.settler = undefined; + if (watcher.onRejected) { + settle(settler, watcher, 'onRejected', reason); + } else if (settler) { + settler.reject(reason); + } else { + throw reason; // for host's unhandled rejection handler to catch + } + }, + }, + ); + + /** + * @template T + * @param {any} specimenP + * @param {{ onFulfilled(value: T): void, onRejected(reason: any): void; }} [watcher] + */ + const watch = (specimenP, watcher) => { + const { settler, whenable } = makeKit(); + // Ensure we have a presence that won't be disconnected later. + getFirstWhenable(specimenP, (specimen, whenable0) => { + if (!whenable0) { + // We're already as short as we can get. + settler.resolve(specimen); + return; + } + + // Persistently watch the specimen. + const reconnectWatcher = makeReconnectWatcher(specimen, watcher, settler); + watchWhenable(specimen, reconnectWatcher); + }).catch(e => settler.reject(e)); + + return whenable; + }; + harden(watch); + + return watch; +}; + +harden(prepareWatch); + +/** @typedef {ReturnType} Watch */ diff --git a/packages/whenable/src/when.js b/packages/whenable/src/when.js index 01a6ed42b25..162cc90ad2f 100644 --- a/packages/whenable/src/when.js +++ b/packages/whenable/src/when.js @@ -1,162 +1,47 @@ // @ts-check -/* global globalThis */ import { E } from '@endo/far'; -import { M } from '@endo/patterns'; -import { isUpgradeDisconnection } from '@agoric/internal/src/upgrade-api.js'; - -const { Fail } = assert; - -export const PromiseWatcherI = M.interface('PromiseWatcher', { - onFulfilled: M.call(M.any()).rest(M.any()).returns(M.any()), - onRejected: M.call(M.any()).rest(M.any()).returns(M.any()), -}); - -/** - * @template [T=any] - * @typedef {{ whenable0: { shorten(): Promise>} }} Whenable - */ - -/** - * @typedef {object} Watcher - * @property {(...args: unknown[]) => void} [onFulfilled] - * @property {(...args: unknown[]) => void} [onRejected] - */ - -/** @type {(p: PromiseLike, watcher: Watcher, ...args: unknown[]) => void} */ -let watchPromise = /** @type {any} */ (globalThis).VatData?.watchPromise; -if (!watchPromise) { - /** - * Adapt a promise watcher method to E.when. - * @param {Record unknown>} that - * @param {PropertyKey} prop - * @param {unknown[]} postArgs - */ - const callMeMaybe = (that, prop, postArgs) => { - const fn = that[prop]; - if (typeof fn !== 'function') { - return undefined; - } - /** - * @param {unknown} arg value or reason - */ - const wrapped = arg => { - // Don't return a value, to prevent E.when from subscribing to a resulting - // promise. - fn.call(that, arg, ...postArgs); - }; - return wrapped; - }; - - // Shim the promise watcher behaviour when VatData.watchPromise is not available. - watchPromise = (p, watcher, ...args) => { - const onFulfilled = callMeMaybe(watcher, 'onFulfilled', args); - const onRejected = callMeMaybe(watcher, 'onRejected', args); - onFulfilled || - onRejected || - Fail`promise watcher must implement at least one handler method`; - void E.when(p, onFulfilled, onRejected); - }; -} - -/** - * @param {any} specimen - * @param {Watcher} watcher - */ -const watchWhenable = (specimen, watcher) => { - let promise; - const whenable0 = specimen && specimen.whenable0; - if (whenable0) { - promise = E(whenable0).shorten(); - } else { - promise = E.resolve(specimen); - } - watchPromise(promise, watcher); -}; +import { prepareWhenableKit } from './whenable.js'; +import { getFirstWhenable } from './whenable-utils.js'; /** * @param {import('@agoric/base-zone').Zone} zone + * @param {() => import('./types.js').WhenableKit} makeWhenableKit * @param {(reason: any) => boolean} [rejectionMeansRetry] */ export const prepareWhen = ( zone, - rejectionMeansRetry = isUpgradeDisconnection, + makeWhenableKit, + rejectionMeansRetry = () => false, ) => { - const makeReconnectWatcher = zone.exoClass( - 'ReconnectWatcher', - PromiseWatcherI, - (whenable, watcher) => ({ - whenable, - watcher, - }), - { - /** - * @param {any} value - */ - onFulfilled(value) { - const { watcher } = this.state; - if (!watcher) { - return; - } - if (value && value.whenable0) { - // We've been shortened, so reflect our state accordingly, and go again. - this.state.whenable = value; - watchWhenable(this.state.whenable, this.self); - return; - } - this.state.watcher = undefined; - if (watcher.onFulfilled) { - watcher.onFulfilled(value); - } - }, - /** - * @param {any} reason - */ - onRejected(reason) { - const { watcher } = this.state; - if (!watcher) { - return; - } - if (rejectionMeansRetry(reason)) { - watchWhenable(this.state.whenable, this.self); - return; - } - this.state.watcher = undefined; - if (watcher.onRejected) { - watcher.onRejected(reason); - } else { - throw reason; // for host's unhandled rejection handler to catch - } - }, - }, - ); + const makeKit = makeWhenableKit || prepareWhenableKit(zone); /** - * @template T - * @param {import('@endo/far').ERef | T>} whenableP - * @param {{ onFulfilled(value: T): void, onRejected(reason: any): void; }} [watcher] + * @param {any} specimenP */ - const when = async (whenableP, watcher) => { + const when = specimenP => { + const { settler, promise } = makeKit(); // Ensure we have a presence that won't be disconnected later. - /** @type {any} */ - let specimen = await whenableP; - if (!watcher) { + getFirstWhenable(specimenP, async (specimen, whenable0) => { // Shorten the whenable chain without a watcher. - while (specimen && specimen.whenable0) { - specimen = await E(specimen.whenable0) + await null; + while (whenable0) { + specimen = await E(whenable0) .shorten() .catch(e => { if (rejectionMeansRetry(e)) { + // Shorten the same specimen to try again. return specimen; } throw e; }); + // Advance to the next whenable. + whenable0 = specimen && specimen.whenable0; } - return /** @type {T} */ (specimen); - } - const reconnectWatcher = makeReconnectWatcher(specimen, watcher); - watchWhenable(specimen, reconnectWatcher); - return /** @type {T} */ ('`when(p, watcher)` has no useful return value'); + settler.resolve(specimen); + }).catch(e => settler.reject(e)); + + return promise; }; harden(when); diff --git a/packages/whenable/src/whenable-utils.js b/packages/whenable/src/whenable-utils.js new file mode 100644 index 00000000000..abdb85ca8b6 --- /dev/null +++ b/packages/whenable/src/whenable-utils.js @@ -0,0 +1,16 @@ +/** A unique object identity just for internal use. */ +const PUMPKIN = harden({}); + +export const getFirstWhenable = (specimen, cb) => + Promise.resolve().then(async () => { + let whenable0 = specimen && specimen.whenable0; + + // Take exactly 1 turn to find the first whenable, if any. + const awaited = await (whenable0 ? PUMPKIN : specimen); + if (awaited !== PUMPKIN) { + specimen = awaited; + whenable0 = specimen && specimen.whenable0; + } + + return cb(specimen, whenable0); + }); diff --git a/packages/whenable/src/whenable.js b/packages/whenable/src/whenable.js index 603ebfab075..dbc5d1ca07f 100644 --- a/packages/whenable/src/whenable.js +++ b/packages/whenable/src/whenable.js @@ -4,38 +4,41 @@ import { M } from '@endo/patterns'; /** * @param {import('@agoric/base-zone').Zone} zone - * @param {WeakMap} [whenable0ToEphemeral] */ -export const prepareWhenableKit = ( - zone, - whenable0ToEphemeral = new WeakMap(), -) => { +export const prepareWhenableKit = zone => { + /** WeakMap */ + const whenable0ToEphemeral = new WeakMap(); + /** * Get the current incarnation's promise kit associated with a whenable0. * - * @param {import('./when.js').Whenable['whenable0']} whenable0 + * @param {import('./types.js').Whenable['whenable0']} whenable0 * @returns {import('@endo/promise-kit').PromiseKit} */ const findCurrentKit = whenable0 => { let pk = whenable0ToEphemeral.get(whenable0); - if (!pk) { - pk = makePromiseKit(); - whenable0ToEphemeral.set(whenable0, pk); + if (pk) { + return pk; } + + pk = makePromiseKit(); + pk.promise.catch(() => {}); // silence unhandled rejection + whenable0ToEphemeral.set(whenable0, pk); return pk; }; /** - * @param {(value: unknown) => void} cb - * @param {import('./when.js').Whenable['whenable0']} whenable0 - * @param {Promise} promise + * @param {'resolve' | 'reject'} kind + * @param {import('./types.js').Whenable['whenable0']} whenable0 * @param {unknown} value */ - const settle = (cb, whenable0, promise, value) => { + const settle = (kind, whenable0, value) => { + const kit = findCurrentKit(whenable0); + const cb = kit[kind]; if (!cb) { return; } - whenable0ToEphemeral.set(whenable0, harden({ promise })); + whenable0ToEphemeral.set(whenable0, harden({ promise: kit.promise })); cb(value); }; @@ -66,36 +69,52 @@ export const prepareWhenableKit = ( */ resolve(value) { const { whenable0 } = this.facets; - const { resolve, promise } = findCurrentKit(whenable0); - settle(resolve, whenable0, promise, value); + settle('resolve', whenable0, value); }, /** * @param {any} [reason] */ reject(reason) { const { whenable0 } = this.facets; - const { reject, promise } = findCurrentKit(whenable0); - settle(reject, whenable0, promise, reason); + settle('reject', whenable0, reason); }, }, }, ); + /** + * @template T + * @returns {import('./types.js').WhenableKit} + */ const makeWhenableKit = () => { const { settler, whenable0 } = rawMakeWhenableKit(); - return harden({ settler, whenable: { whenable0 } }); + const whenable = { whenable0 }; + /** + * It would be nice to fully type this, but TypeScript gives: + * TS1320: Type of 'await' operand must either be a valid promise or must not contain a callable 'then' member. + * @type {unknown} + */ + const whenablePromiseLike = { + whenable0, + then(onFulfilled, onRejected) { + // This promise behaviour is ephemeral. If you want a persistent + // subscription, you must use `when(p, watcher)`. + const { promise } = findCurrentKit(whenable0); + return promise.then(onFulfilled, onRejected); + }, + catch(onRejected) { + const { promise } = findCurrentKit(whenable0); + return promise.catch(onRejected); + }, + finally(onFinally) { + const { promise } = findCurrentKit(whenable0); + return promise.finally(onFinally); + }, + }; + const promise = /** @type {Promise} */ (whenablePromiseLike); + return harden({ settler, whenable, promise }); }; return makeWhenableKit; }; harden(prepareWhenableKit); - -/** - * @template [T=any] - * @typedef {{ whenable: import('./when').Whenable, settler: Settler }} WhenableKit - */ - -/** - * @template [T=any] - * @typedef {{ resolve(value?: T): void, reject(reason?: any): void }} Settler - */ diff --git a/packages/whenable/test/test-disconnect.js b/packages/whenable/test/test-disconnect.js index 618f2ef9de6..1e11d675ec8 100644 --- a/packages/whenable/test/test-disconnect.js +++ b/packages/whenable/test/test-disconnect.js @@ -3,73 +3,105 @@ import test from 'ava'; import { makeHeapZone } from '@agoric/base-zone/heap.js'; import { prepareWhenableModule } from '../src/module.js'; -import { makePromiseKit } from '@endo/promise-kit'; /** * @param {import('@agoric/base-zone').Zone} zone * @returns {import('ava').ImplementationFn<[]>} */ const testRetryOnDisconnect = zone => async t => { - let doRetry = false; - const rejectionMeansRetry = _e => doRetry; - const whenable0ToEphemeral = new Map(); + const rejectionMeansRetry = e => e && e.message === 'disconnected'; - const { when, makeWhenableKit } = prepareWhenableModule(zone, { + const { watch, when } = prepareWhenableModule(zone, { rejectionMeansRetry, - whenable0ToEphemeral, }); + const makeTestWhenable0 = zone.exoClass( + 'TestWhenable0', + undefined, + plan => ({ plan }), + { + shorten() { + const { plan } = this.state; + const [step, ...rest] = plan; + this.state.plan = rest; + switch (step) { + case 'disco': { + const p = Promise.reject(Error('disconnected')); + return p; + } + case 'happy': { + const p = Promise.resolve('resolved'); + return p; + } + case 'sad': { + const p = Promise.reject(Error('dejected')); + return p; + } + default: { + return Promise.reject(Error(`unknown plan step ${step}`)); + } + } + }, + }, + ); - for await (const watchPromise of [false, true]) { - for await (const retry of [false, true]) { - doRetry = retry; - whenable0ToEphemeral.clear(); + const PLANS = [ + [0, 'happy'], + [0, 'sad', 'happy'], + [1, 'disco', 'happy'], + [1, 'disco', 'sad'], + [1, 'disco', 'sad', 'disco', 'happy'], + [2, 'disco', 'disco', 'happy'], + [2, 'disco', 'disco', 'sad'], + ]; - const { whenable, settler } = makeWhenableKit(); + for await (const watchWhenable of [false, true]) { + t.log('testing watchWhenable', watchWhenable); + for await (const [final, ...plan] of PLANS) { + t.log(`testing (plan=${plan}, watchWhenable=${watchWhenable})`); + + /** @type {import('../src/types.js').Whenable} */ + const whenable = harden({ whenable0: makeTestWhenable0(plan) }); let resultP; - if (watchPromise) { - const pk = makePromiseKit(); - const p = when(whenable, { + if (watchWhenable) { + const resultW = watch(whenable, { onFulfilled(value) { - pk.resolve(value); + t.is(plan[final], 'happy'); + t.is(value, 'resolved'); + return value; }, onRejected(reason) { - pk.reject(reason); + t.is(plan[final], 'sad'); + t.is(reason && reason.message, 'dejected'); + return ['rejected', reason]; }, }); - t.regex( - await p, - /no useful return/, - `no useful return expected (retry=${retry}, watchPromise=${watchPromise})`, - ); - resultP = pk.promise; + t.is('then' in resultW, false, 'watch resultW.then is undefined'); + resultP = when(resultW); } else { - resultP = when(whenable); + resultP = when(whenable).catch(e => ['rejected', e]); } - resultP = resultP.catch(e => ['rejected', e]); - - await null; // two turns to allow the whenable0 to be registered - await null; - - const ephemeral = [...whenable0ToEphemeral.values()]; - ephemeral[0].reject('disconnected'); - // Simulate an upgrade. - whenable0ToEphemeral.clear(); - settler.resolve('resolved'); - - if (retry) { - t.is( - await resultP, - 'resolved', - `resolve expected (retry=${retry}, watchPromise=${watchPromise})`, - ); - } else { - t.deepEqual( - await resultP, - ['rejected', 'disconnected'], - `reject expected (retry=${retry}, watchPromise=${watchPromise})`, - ); + switch (plan[final]) { + case 'happy': { + t.is( + await resultP, + 'resolved', + `resolve expected (plan=${plan}, watchWhenable=${watchWhenable})`, + ); + break; + } + case 'sad': { + t.like( + await resultP, + ['rejected', Error('dejected')], + `reject expected (plan=${plan}, watchWhenable=${watchWhenable})`, + ); + break; + } + default: { + t.fail(`unknown final plan step ${plan[final]}`); + } } } } From 996e33da44b3493a65cdbe0f0c0bc48e5e2d6dbe Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Mon, 8 Jan 2024 16:50:02 -0600 Subject: [PATCH 05/19] feat(whenable): use features from more recent Endo --- packages/whenable/package.json | 9 ++++---- packages/whenable/src/module.js | 8 ++++---- packages/whenable/src/types.js | 17 ++++++++++++--- packages/whenable/src/watch.js | 10 ++++----- packages/whenable/src/when.js | 13 ++++++------ packages/whenable/src/whenable-utils.js | 12 +++++++++-- packages/whenable/src/whenable.js | 25 ++++++++++++++++------- packages/whenable/test/test-disconnect.js | 5 ++++- 8 files changed, 67 insertions(+), 32 deletions(-) diff --git a/packages/whenable/package.json b/packages/whenable/package.json index a37fba3d09e..6b73bf360b2 100755 --- a/packages/whenable/package.json +++ b/packages/whenable/package.json @@ -22,12 +22,13 @@ "dependencies": { "@agoric/base-zone": "^0.1.0", "@agoric/internal": "^0.3.2", - "@endo/far": "^0.2.21", - "@endo/patterns": "^0.2.5", - "@endo/promise-kit": "^0.2.59" + "@endo/far": "^1.0.1", + "@endo/pass-style": "^1.0.1", + "@endo/patterns": "^1.0.1", + "@endo/promise-kit": "^1.0.1" }, "devDependencies": { - "@endo/init": "^0.5.59", + "@endo/init": "^1.0.1", "ava": "^5.3.0" }, "ava": { diff --git a/packages/whenable/src/module.js b/packages/whenable/src/module.js index 61c0ad5b866..dd86fbb9987 100644 --- a/packages/whenable/src/module.js +++ b/packages/whenable/src/module.js @@ -1,7 +1,7 @@ // @ts-check import { isUpgradeDisconnection } from '@agoric/internal/src/upgrade-api.js'; import { prepareWhen } from './when.js'; -import { prepareWhenableKit } from './whenable.js'; +import { prepareWhenableKits } from './whenable.js'; import { prepareWatch } from './watch.js'; /** @@ -11,9 +11,9 @@ import { prepareWatch } from './watch.js'; */ export const prepareWhenableModule = (zone, powers) => { const { rejectionMeansRetry = isUpgradeDisconnection } = powers || {}; - const makeWhenableKit = prepareWhenableKit(zone); - const when = prepareWhen(zone, makeWhenableKit, rejectionMeansRetry); + const { makeWhenableKit, makeWhenablePromiseKit } = prepareWhenableKits(zone); + const when = prepareWhen(zone, makeWhenablePromiseKit, rejectionMeansRetry); const watch = prepareWatch(zone, makeWhenableKit, rejectionMeansRetry); - return harden({ watch, when, makeWhenableKit }); + return harden({ watch, when, makeWhenableKit, makeWhenablePromiseKit }); }; harden(prepareWhenableModule); diff --git a/packages/whenable/src/types.js b/packages/whenable/src/types.js index b992c9d3df8..75f479a3086 100644 --- a/packages/whenable/src/types.js +++ b/packages/whenable/src/types.js @@ -7,18 +7,29 @@ export {}; /** * @template [T=any] - * @typedef {{ whenable0: { shorten(): Promise>} }} Whenable + * @typedef {import('@endo/pass-style').CopyTagged< + * 'Whenable', + * { whenable0: { shorten(): Promise>} } + * >} Whenable */ /** * @template [T=any] * @typedef {{ - * whenable: import('./types.js').Whenable, + * whenable: Whenable, * settler: Settler, - * promise: Promise * }} WhenableKit */ +/** + * @template [T=any] + * @typedef {{ + * whenable: Whenable, + * settler: Settler, + * promise: Promise + * }} WhenablePromiseKit + */ + /** * @template [T=any] * @typedef {{ resolve(value?: T): void, reject(reason?: any): void }} Settler diff --git a/packages/whenable/src/watch.js b/packages/whenable/src/watch.js index 49750b75b80..03f975cf986 100644 --- a/packages/whenable/src/watch.js +++ b/packages/whenable/src/watch.js @@ -3,8 +3,8 @@ import { E } from '@endo/far'; import { M } from '@endo/patterns'; -import { prepareWhenableKit } from './whenable.js'; -import { getFirstWhenable } from './whenable-utils.js'; +import { prepareWhenableKits } from './whenable.js'; +import { getWhenable0, getFirstWhenable } from './whenable-utils.js'; const { Fail } = assert; @@ -61,7 +61,7 @@ if (!watchPromise) { */ const watchWhenable = (specimen, watcher) => { let promise; - const whenable0 = specimen && specimen.whenable0; + const whenable0 = getWhenable0(specimen); if (whenable0) { promise = E(whenable0).shorten(); } else { @@ -80,7 +80,7 @@ export const prepareWatch = ( makeWhenableKit, rejectionMeansRetry = () => false, ) => { - const makeKit = makeWhenableKit || prepareWhenableKit(zone); + const makeKit = makeWhenableKit || prepareWhenableKits(zone).makeWhenableKit; /** * @param {import('./types.js').Settler} settler @@ -120,7 +120,7 @@ export const prepareWatch = ( */ onFulfilled(value) { const { watcher, settler } = this.state; - if (value && value.whenable0) { + if (getWhenable0(value)) { // We've been shortened, so reflect our state accordingly, and go again. this.state.whenable = value; watchWhenable(this.state.whenable, this.self); diff --git a/packages/whenable/src/when.js b/packages/whenable/src/when.js index 162cc90ad2f..063c705c439 100644 --- a/packages/whenable/src/when.js +++ b/packages/whenable/src/when.js @@ -1,20 +1,21 @@ // @ts-check import { E } from '@endo/far'; -import { prepareWhenableKit } from './whenable.js'; -import { getFirstWhenable } from './whenable-utils.js'; +import { prepareWhenableKits } from './whenable.js'; +import { getFirstWhenable, getWhenable0 } from './whenable-utils.js'; /** * @param {import('@agoric/base-zone').Zone} zone - * @param {() => import('./types.js').WhenableKit} makeWhenableKit + * @param {() => import('./types.js').WhenablePromiseKit} makeWhenablePromiseKit * @param {(reason: any) => boolean} [rejectionMeansRetry] */ export const prepareWhen = ( zone, - makeWhenableKit, + makeWhenablePromiseKit, rejectionMeansRetry = () => false, ) => { - const makeKit = makeWhenableKit || prepareWhenableKit(zone); + const makeKit = + makeWhenablePromiseKit || prepareWhenableKits(zone).makeWhenablePromiseKit; /** * @param {any} specimenP @@ -36,7 +37,7 @@ export const prepareWhen = ( throw e; }); // Advance to the next whenable. - whenable0 = specimen && specimen.whenable0; + whenable0 = getWhenable0(specimen); } settler.resolve(specimen); }).catch(e => settler.reject(e)); diff --git a/packages/whenable/src/whenable-utils.js b/packages/whenable/src/whenable-utils.js index abdb85ca8b6..692dccbbdee 100644 --- a/packages/whenable/src/whenable-utils.js +++ b/packages/whenable/src/whenable-utils.js @@ -1,15 +1,23 @@ +import { getTag } from '@endo/pass-style'; + +export const getWhenable0 = specimen => + typeof specimen === 'object' && + getTag(specimen) === 'Whenable' && + specimen.payload && + specimen.payload.whenable0; + /** A unique object identity just for internal use. */ const PUMPKIN = harden({}); export const getFirstWhenable = (specimen, cb) => Promise.resolve().then(async () => { - let whenable0 = specimen && specimen.whenable0; + let whenable0 = getWhenable0(specimen); // Take exactly 1 turn to find the first whenable, if any. const awaited = await (whenable0 ? PUMPKIN : specimen); if (awaited !== PUMPKIN) { specimen = awaited; - whenable0 = specimen && specimen.whenable0; + whenable0 = getWhenable0(specimen); } return cb(specimen, whenable0); diff --git a/packages/whenable/src/whenable.js b/packages/whenable/src/whenable.js index dbc5d1ca07f..c92bb957440 100644 --- a/packages/whenable/src/whenable.js +++ b/packages/whenable/src/whenable.js @@ -1,18 +1,19 @@ // @ts-check import { makePromiseKit } from '@endo/promise-kit'; import { M } from '@endo/patterns'; +import { makeTagged } from '@endo/pass-style'; /** * @param {import('@agoric/base-zone').Zone} zone */ -export const prepareWhenableKit = zone => { +export const prepareWhenableKits = zone => { /** WeakMap */ const whenable0ToEphemeral = new WeakMap(); /** * Get the current incarnation's promise kit associated with a whenable0. * - * @param {import('./types.js').Whenable['whenable0']} whenable0 + * @param {import('./types.js').Whenable['payload']['whenable0']} whenable0 * @returns {import('@endo/promise-kit').PromiseKit} */ const findCurrentKit = whenable0 => { @@ -29,7 +30,7 @@ export const prepareWhenableKit = zone => { /** * @param {'resolve' | 'reject'} kind - * @param {import('./types.js').Whenable['whenable0']} whenable0 + * @param {import('./types.js').Whenable['payload']['whenable0']} whenable0 * @param {unknown} value */ const settle = (kind, whenable0, value) => { @@ -88,14 +89,24 @@ export const prepareWhenableKit = zone => { */ const makeWhenableKit = () => { const { settler, whenable0 } = rawMakeWhenableKit(); - const whenable = { whenable0 }; + const whenable = makeTagged('Whenable', harden({ whenable0 })); + return harden({ settler, whenable }); + }; + + /** + * @template T + * @returns {import('./types.js').WhenablePromiseKit} + */ + const makeWhenablePromiseKit = () => { + const { settler, whenable0 } = rawMakeWhenableKit(); + const whenable = makeTagged('Whenable', harden({ whenable0 })); + /** * It would be nice to fully type this, but TypeScript gives: * TS1320: Type of 'await' operand must either be a valid promise or must not contain a callable 'then' member. * @type {unknown} */ const whenablePromiseLike = { - whenable0, then(onFulfilled, onRejected) { // This promise behaviour is ephemeral. If you want a persistent // subscription, you must use `when(p, watcher)`. @@ -114,7 +125,7 @@ export const prepareWhenableKit = zone => { const promise = /** @type {Promise} */ (whenablePromiseLike); return harden({ settler, whenable, promise }); }; - return makeWhenableKit; + return { makeWhenableKit, makeWhenablePromiseKit }; }; -harden(prepareWhenableKit); +harden(prepareWhenableKits); diff --git a/packages/whenable/test/test-disconnect.js b/packages/whenable/test/test-disconnect.js index 1e11d675ec8..c04ab6cd65c 100644 --- a/packages/whenable/test/test-disconnect.js +++ b/packages/whenable/test/test-disconnect.js @@ -2,6 +2,7 @@ import test from 'ava'; import { makeHeapZone } from '@agoric/base-zone/heap.js'; +import { makeTagged } from '@endo/pass-style'; import { prepareWhenableModule } from '../src/module.js'; /** @@ -60,7 +61,9 @@ const testRetryOnDisconnect = zone => async t => { t.log(`testing (plan=${plan}, watchWhenable=${watchWhenable})`); /** @type {import('../src/types.js').Whenable} */ - const whenable = harden({ whenable0: makeTestWhenable0(plan) }); + const whenable = makeTagged('Whenable', { + whenable0: makeTestWhenable0(plan), + }); let resultP; if (watchWhenable) { From 65bb8ebf4e2add8d57876ea23eda4528c0ba5f86 Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Tue, 16 Jan 2024 23:44:42 -0600 Subject: [PATCH 06/19] refactor(whenable): putting on some polish for reviewers --- packages/whenable/src/heap.js | 6 +- packages/whenable/src/module.js | 11 +- packages/whenable/src/types.js | 6 +- packages/whenable/src/watch.js | 179 +++++++++++++----------- packages/whenable/src/when.js | 20 +-- packages/whenable/src/whenable-utils.js | 48 +++++-- 6 files changed, 155 insertions(+), 115 deletions(-) diff --git a/packages/whenable/src/heap.js b/packages/whenable/src/heap.js index 1b14fc15a95..a5f89c0b35a 100644 --- a/packages/whenable/src/heap.js +++ b/packages/whenable/src/heap.js @@ -1,6 +1,8 @@ // @ts-check import { makeHeapZone } from '@agoric/base-zone/heap.js'; -import { prepareWhenableModule } from './module.js'; +import { wrappedPrepareWhenableModule } from './module.js'; // Heap-based whenable support is exported to assist in migration. -export const { when, makeWhenableKit } = prepareWhenableModule(makeHeapZone()); +export const { when, makeWhenableKit } = wrappedPrepareWhenableModule( + makeHeapZone(), +); diff --git a/packages/whenable/src/module.js b/packages/whenable/src/module.js index dd86fbb9987..89e7a52e0c8 100644 --- a/packages/whenable/src/module.js +++ b/packages/whenable/src/module.js @@ -1,5 +1,4 @@ // @ts-check -import { isUpgradeDisconnection } from '@agoric/internal/src/upgrade-api.js'; import { prepareWhen } from './when.js'; import { prepareWhenableKits } from './whenable.js'; import { prepareWatch } from './watch.js'; @@ -8,12 +7,18 @@ import { prepareWatch } from './watch.js'; * @param {import('@agoric/base-zone').Zone} zone * @param {object} [powers] * @param {(reason: any) => boolean} [powers.rejectionMeansRetry] + * @param {(p: PromiseLike, watcher: import('./watch.js').PromiseWatcher, ...args: unknown[]) => void} [powers.watchPromise] */ export const prepareWhenableModule = (zone, powers) => { - const { rejectionMeansRetry = isUpgradeDisconnection } = powers || {}; + const { rejectionMeansRetry = _reason => false, watchPromise } = powers || {}; const { makeWhenableKit, makeWhenablePromiseKit } = prepareWhenableKits(zone); const when = prepareWhen(zone, makeWhenablePromiseKit, rejectionMeansRetry); - const watch = prepareWatch(zone, makeWhenableKit, rejectionMeansRetry); + const watch = prepareWatch( + zone, + makeWhenableKit, + watchPromise, + rejectionMeansRetry, + ); return harden({ watch, when, makeWhenableKit, makeWhenablePromiseKit }); }; harden(prepareWhenableModule); diff --git a/packages/whenable/src/types.js b/packages/whenable/src/types.js index 75f479a3086..89ea543c6a8 100644 --- a/packages/whenable/src/types.js +++ b/packages/whenable/src/types.js @@ -32,12 +32,14 @@ export {}; /** * @template [T=any] - * @typedef {{ resolve(value?: T): void, reject(reason?: any): void }} Settler + * @typedef {{ resolve(value?: T | PromiseWhenable): void, reject(reason?: any): void }} Settler */ /** * @template [T=any] + * @template [TResult1=T] + * @template [TResult2=T] * @typedef {object} Watcher - * @property {(value: T) => Whenable | PromiseWhenable | TResult1} [onFullfilled] + * @property {(value: T) => Whenable | PromiseWhenable | TResult1} [onFulfilled] * @property {(reason: any) => Whenable | PromiseWhenable | TResult2} [onRejected] */ diff --git a/packages/whenable/src/watch.js b/packages/whenable/src/watch.js index 03f975cf986..9fa4a0ae794 100644 --- a/packages/whenable/src/watch.js +++ b/packages/whenable/src/watch.js @@ -1,16 +1,16 @@ // @ts-check -/* global globalThis */ import { E } from '@endo/far'; import { M } from '@endo/patterns'; -import { prepareWhenableKits } from './whenable.js'; -import { getWhenable0, getFirstWhenable } from './whenable-utils.js'; +import { getWhenablePayload, getFirstWhenable } from './whenable-utils.js'; const { Fail } = assert; +const { apply } = Reflect; + export const PromiseWatcherI = M.interface('PromiseWatcher', { - onFulfilled: M.call(M.any()).rest(M.any()).returns(M.any()), - onRejected: M.call(M.any()).rest(M.any()).returns(M.any()), + onFulfilled: M.call(M.any()).rest(M.any()).returns(), + onRejected: M.call(M.any()).rest(M.any()).returns(), }); /** @@ -19,85 +19,89 @@ export const PromiseWatcherI = M.interface('PromiseWatcher', { * @property {(...args: unknown[]) => void} [onRejected] */ -/** @type {(p: PromiseLike, watcher: PromiseWatcher, ...args: unknown[]) => void} */ -let watchPromise = /** @type {any} */ (globalThis).VatData?.watchPromise; -if (!watchPromise) { +/** + * Adapt a promise watcher method to E.when. + * @param {Record unknown>} that + * @param {PropertyKey} prop + * @param {unknown[]} postArgs + */ +const callMeMaybe = (that, prop, postArgs) => { + const fn = that[prop]; + if (typeof fn !== 'function') { + return undefined; + } /** - * Adapt a promise watcher method to E.when. - * @param {Record unknown>} that - * @param {PropertyKey} prop - * @param {unknown[]} postArgs + * @param {unknown} arg value or reason */ - const callMeMaybe = (that, prop, postArgs) => { - const fn = that[prop]; - if (typeof fn !== 'function') { - return undefined; - } - /** - * @param {unknown} arg value or reason - */ - const wrapped = arg => { - // Don't return a value, to prevent E.when from subscribing to a resulting - // promise. - fn.call(that, arg, ...postArgs); - }; - return wrapped; - }; - - // Shim the promise watcher behaviour when VatData.watchPromise is not available. - watchPromise = (p, watcher, ...args) => { - const onFulfilled = callMeMaybe(watcher, 'onFulfilled', args); - const onRejected = callMeMaybe(watcher, 'onRejected', args); - onFulfilled || - onRejected || - Fail`promise watcher must implement at least one handler method`; - void E.when(p, onFulfilled, onRejected); + const wrapped = arg => { + // Don't return a value, to prevent E.when from subscribing to a resulting + // promise. + apply(fn, that, [arg, ...postArgs]); }; -} + return wrapped; +}; /** - * @param {any} specimen - * @param {import('./types.js').Watcher} watcher + * Shim the promise watcher behaviour when VatData.watchPromise is not available. + * + * @param {PromiseLike} p + * @param {PromiseWatcher} watcher + * @param {...unknown[]} watcherArgs + * @returns {void} */ -const watchWhenable = (specimen, watcher) => { - let promise; - const whenable0 = getWhenable0(specimen); - if (whenable0) { - promise = E(whenable0).shorten(); - } else { - promise = E.resolve(specimen); - } - watchPromise(promise, watcher); +const watchPromiseShim = (p, watcher, ...watcherArgs) => { + const onFulfilled = callMeMaybe(watcher, 'onFulfilled', watcherArgs); + const onRejected = callMeMaybe(watcher, 'onRejected', watcherArgs); + onFulfilled || + onRejected || + Fail`promise watcher must implement at least one handler method`; + void E.when(p, onFulfilled, onRejected); }; /** * @param {import('@agoric/base-zone').Zone} zone * @param {() => import('./types.js').WhenableKit} makeWhenableKit + * @param {typeof watchPromiseShim} [watchPromise] * @param {(reason: any) => boolean} [rejectionMeansRetry] */ export const prepareWatch = ( zone, makeWhenableKit, + watchPromise = watchPromiseShim, rejectionMeansRetry = () => false, ) => { - const makeKit = makeWhenableKit || prepareWhenableKits(zone).makeWhenableKit; + /** + * @param {any} specimen + * @param {PromiseWatcher} watcher + */ + const watchWhenable = (specimen, watcher) => { + let promise; + const payload = getWhenablePayload(specimen); + if (payload) { + promise = E(payload.whenable0).shorten(); + } else { + promise = E.resolve(specimen); + } + watchPromise(promise, watcher); + }; /** * @param {import('./types.js').Settler} settler - * @param {import('./types.js').Watcher} watcher - * @param {'onFulfilled' | 'onRejected'} wcb + * @param {import('./types.js').Watcher} watcher + * @param {keyof Required} wcb * @param {unknown} value */ const settle = (settler, watcher, wcb, value) => { try { - let chainValue = value; + let chainedValue = value; const w = watcher[wcb]; if (w) { - chainValue = w(value); + // + chainedValue = apply(w, watcher, [value]); } else if (wcb === 'onRejected') { throw value; } - settler && settler.resolve(chainValue); + settler && settler.resolve(chainedValue); } catch (e) { if (settler) { settler.reject(e); @@ -109,53 +113,62 @@ export const prepareWatch = ( const makeReconnectWatcher = zone.exoClass( 'ReconnectWatcher', PromiseWatcherI, - (whenable, watcher, settler) => ({ - whenable, - watcher, - settler, - }), + /** + * @template [T=any] + * @template [TResult1=T] + * @template [TResult2=never] + * @param {import('./types.js').Whenable} whenable + * @param {import('./types.js').Settler} settler + * @param {import('./types.js').Watcher} [watcher] + */ + (whenable, settler, watcher) => { + const state = { + whenable, + settler, + watcher, + }; + return /** @type {Partial} */ (state); + }, { - /** - * @param {any} value - */ + /** @type {Required['onFulfilled']} */ onFulfilled(value) { const { watcher, settler } = this.state; - if (getWhenable0(value)) { + if (getWhenablePayload(value)) { // We've been shortened, so reflect our state accordingly, and go again. - this.state.whenable = value; - watchWhenable(this.state.whenable, this.self); - return; + const whenable = /** @type {import('./types.js').Whenable} */ ( + value + ); + this.state.whenable = whenable; + watchWhenable(value, this.self); + return undefined; } this.state.watcher = undefined; this.state.settler = undefined; - if (watcher) { + if (!settler) { + return undefined; + } else if (watcher) { settle(settler, watcher, 'onFulfilled', value); - } else if (settler) { + } else { settler.resolve(value); } }, - /** - * @param {any} reason - */ + /** @type {Required['onRejected']} */ onRejected(reason) { const { watcher, settler } = this.state; if (rejectionMeansRetry(reason)) { watchWhenable(this.state.whenable, this.self); return; } + this.state.settler = undefined; + this.state.watcher = undefined; if (!watcher) { - this.state.settler = undefined; settler && settler.reject(reason); - return; - } - this.state.watcher = undefined; - this.state.settler = undefined; - if (watcher.onRejected) { + } else if (!settler) { + throw reason; // for host's unhandled rejection handler to catch + } else if (watcher.onRejected) { settle(settler, watcher, 'onRejected', reason); - } else if (settler) { - settler.reject(reason); } else { - throw reason; // for host's unhandled rejection handler to catch + settler.reject(reason); } }, }, @@ -164,10 +177,10 @@ export const prepareWatch = ( /** * @template T * @param {any} specimenP - * @param {{ onFulfilled(value: T): void, onRejected(reason: any): void; }} [watcher] + * @param {import('./types.js').Watcher} [watcher] */ const watch = (specimenP, watcher) => { - const { settler, whenable } = makeKit(); + const { settler, whenable } = makeWhenableKit(); // Ensure we have a presence that won't be disconnected later. getFirstWhenable(specimenP, (specimen, whenable0) => { if (!whenable0) { @@ -177,7 +190,7 @@ export const prepareWatch = ( } // Persistently watch the specimen. - const reconnectWatcher = makeReconnectWatcher(specimen, watcher, settler); + const reconnectWatcher = makeReconnectWatcher(specimen, settler, watcher); watchWhenable(specimen, reconnectWatcher); }).catch(e => settler.reject(e)); diff --git a/packages/whenable/src/when.js b/packages/whenable/src/when.js index 063c705c439..f5a0ccb28e3 100644 --- a/packages/whenable/src/when.js +++ b/packages/whenable/src/when.js @@ -1,8 +1,7 @@ // @ts-check import { E } from '@endo/far'; -import { prepareWhenableKits } from './whenable.js'; -import { getFirstWhenable, getWhenable0 } from './whenable-utils.js'; +import { getFirstWhenable, getWhenablePayload } from './whenable-utils.js'; /** * @param {import('@agoric/base-zone').Zone} zone @@ -14,20 +13,17 @@ export const prepareWhen = ( makeWhenablePromiseKit, rejectionMeansRetry = () => false, ) => { - const makeKit = - makeWhenablePromiseKit || prepareWhenableKits(zone).makeWhenablePromiseKit; - /** * @param {any} specimenP */ const when = specimenP => { - const { settler, promise } = makeKit(); + const { settler, promise } = makeWhenablePromiseKit(); // Ensure we have a presence that won't be disconnected later. - getFirstWhenable(specimenP, async (specimen, whenable0) => { + getFirstWhenable(specimenP, async (specimen, payload) => { // Shorten the whenable chain without a watcher. await null; - while (whenable0) { - specimen = await E(whenable0) + while (payload) { + specimen = await E(payload.whenable0) .shorten() .catch(e => { if (rejectionMeansRetry(e)) { @@ -37,7 +33,11 @@ export const prepareWhen = ( throw e; }); // Advance to the next whenable. - whenable0 = getWhenable0(specimen); + const nextPayload = getWhenablePayload(specimen); + if (!nextPayload) { + break; + } + payload = nextPayload; } settler.resolve(specimen); }).catch(e => settler.reject(e)); diff --git a/packages/whenable/src/whenable-utils.js b/packages/whenable/src/whenable-utils.js index 692dccbbdee..d962849cb3e 100644 --- a/packages/whenable/src/whenable-utils.js +++ b/packages/whenable/src/whenable-utils.js @@ -1,24 +1,42 @@ import { getTag } from '@endo/pass-style'; -export const getWhenable0 = specimen => +/** + * @template T + * @param {T} specimen + * @returns {T extends import('./types').Whenable ? + * import('./types').Whenable['payload'] : + * undefined + * } + */ +export const getWhenablePayload = specimen => typeof specimen === 'object' && + specimen !== null && getTag(specimen) === 'Whenable' && - specimen.payload && - specimen.payload.whenable0; + specimen.payload; /** A unique object identity just for internal use. */ -const PUMPKIN = harden({}); +const ALREADY_WHENABLE = harden({}); -export const getFirstWhenable = (specimen, cb) => - Promise.resolve().then(async () => { - let whenable0 = getWhenable0(specimen); +/** + * @template T + * @template U + * @param {T} specimenP + * @param {(specimen: Awaited, payload: import('./types').Whenable['payload']) => U} cb + * @returns {Promise>} + */ +export const getFirstWhenable = async (specimenP, cb) => { + let payload = getWhenablePayload(specimenP); - // Take exactly 1 turn to find the first whenable, if any. - const awaited = await (whenable0 ? PUMPKIN : specimen); - if (awaited !== PUMPKIN) { - specimen = awaited; - whenable0 = getWhenable0(specimen); - } + // Take exactly 1 turn to find the first whenable, if any. + let specimen = await (payload ? ALREADY_WHENABLE : specimenP); + if (specimen === ALREADY_WHENABLE) { + // The fact that we have a whenable payload means it's not actually a + // promise. + specimen = specimenP; + } else { + // Check if the awaited specimen is a whenable. + payload = getWhenablePayload(specimen); + } - return cb(specimen, whenable0); - }); + return cb(specimen, payload); +}; From 4afad84c20685ca22437258308c129c846d449b2 Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Tue, 16 Jan 2024 23:45:39 -0600 Subject: [PATCH 07/19] chore(internal): add `whenable.js` to preserve layering --- packages/internal/whenable.js | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 packages/internal/whenable.js diff --git a/packages/internal/whenable.js b/packages/internal/whenable.js new file mode 100644 index 00000000000..d82b695908b --- /dev/null +++ b/packages/internal/whenable.js @@ -0,0 +1,31 @@ +/* global globalThis */ + +import { makeHeapZone } from '@agoric/base-zone/heap'; +import { prepareWhenableModule as wrappedPrepareWhenableModule } from '@agoric/whenable'; +import { isUpgradeDisconnection } from './src/upgrade-api.js'; + +const vatData = /** @type {any} */ (globalThis).VatData; + +/** @type {(p: PromiseLike, watcher: PromiseWatcher, ...args: unknown[]) => void} */ +const watchPromise = vatData && vatData.watchPromise; + +/** + * Return truthy if a rejection reason should result in a retry. + * @param {any} reason + * @returns {boolean} + */ +const rejectionMeansRetry = reason => isUpgradeDisconnection(reason); + +/** @type {typeof wrappedPrepareWhenableModule} */ +export const prepareWhenableModule = (zone, powers) => + wrappedPrepareWhenableModule(zone, { + rejectionMeansRetry, + watchPromise, + ...powers, + }); +harden(prepareWhenableModule); + +// Heap-based whenable support for migration to durable objects. +const { watch, when, makeWhenableKit, makeWhenablePromiseKit } = + prepareWhenableModule(makeHeapZone()); +export { watch, when, makeWhenableKit, makeWhenablePromiseKit }; From d1ac054c5454132486616b438e65d8afff765b82 Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Wed, 17 Jan 2024 14:18:43 -0600 Subject: [PATCH 08/19] chore(whenable): remove hard dependency on `@agoric/internal` --- packages/internal/package.json | 1 + packages/internal/test/test-whenable.js | 36 +++++++++++++++++++++++++ packages/internal/whenable.js | 27 +++++++++---------- packages/whenable/package.json | 4 +-- packages/whenable/src/heap.js | 8 ------ packages/whenable/src/index.js | 3 --- scripts/check-dependency-cycles.sh | 10 +++++-- 7 files changed, 59 insertions(+), 30 deletions(-) create mode 100644 packages/internal/test/test-whenable.js delete mode 100644 packages/whenable/src/heap.js diff --git a/packages/internal/package.json b/packages/internal/package.json index f45ee3367e9..e16aada8c75 100755 --- a/packages/internal/package.json +++ b/packages/internal/package.json @@ -22,6 +22,7 @@ "dependencies": { "@agoric/assert": "^0.6.0", "@agoric/base-zone": "^0.1.0", + "@agoric/whenable": "^0.1.0", "@endo/common": "^1.0.2", "@endo/far": "^1.0.2", "@endo/init": "^1.0.2", diff --git a/packages/internal/test/test-whenable.js b/packages/internal/test/test-whenable.js new file mode 100644 index 00000000000..92e339593ba --- /dev/null +++ b/packages/internal/test/test-whenable.js @@ -0,0 +1,36 @@ +// @ts-check +import test from 'ava'; +import { E as basicE, Far } from '@endo/far'; + +import { E, makeWhenableKit } from '../whenable.js'; + +test('heap messages', async t => { + const greeter = Far('Greeter', { + hello: /** @param {string} name */ name => `Hello, ${name}!`, + }); + + /** @type {import('@agoric/whenable').WhenableKit} */ + const { whenable, settler } = makeWhenableKit(); + const retP = E(whenable).hello('World'); + settler.resolve(greeter); + + // Happy path: E(whenable)[method](...args) calls the method. + t.is(await retP, 'Hello, World!'); + + // Sad path: basicE(whenable)[method](...args) rejects. + await t.throwsAsync( + // @ts-expect-error hello is not accessible via basicE + () => basicE(whenable).hello('World'), + { + message: /target has no method "hello"/, + }, + ); + + // Happy path: await E.when unwraps the whenable. + t.is(await E.when(whenable), greeter); + + // Sad path: await by itself gives the raw whenable. + const w = await whenable; + t.not(w, greeter); + t.truthy(w.payload); +}); diff --git a/packages/internal/whenable.js b/packages/internal/whenable.js index d82b695908b..f4bc9fd9df1 100644 --- a/packages/internal/whenable.js +++ b/packages/internal/whenable.js @@ -1,7 +1,6 @@ /* global globalThis */ - -import { makeHeapZone } from '@agoric/base-zone/heap'; -import { prepareWhenableModule as wrappedPrepareWhenableModule } from '@agoric/whenable'; +import { prepareWhenableModule as rawPrepareWhenableModule } from '@agoric/whenable'; +import { makeHeapZone } from '@agoric/base-zone/heap.js'; import { isUpgradeDisconnection } from './src/upgrade-api.js'; const vatData = /** @type {any} */ (globalThis).VatData; @@ -16,16 +15,14 @@ const watchPromise = vatData && vatData.watchPromise; */ const rejectionMeansRetry = reason => isUpgradeDisconnection(reason); -/** @type {typeof wrappedPrepareWhenableModule} */ -export const prepareWhenableModule = (zone, powers) => - wrappedPrepareWhenableModule(zone, { - rejectionMeansRetry, - watchPromise, - ...powers, - }); -harden(prepareWhenableModule); +export const defaultPowers = harden({ + rejectionMeansRetry, + watchPromise, +}); + +export const prepareWhenableModule = (zone, powers = {}) => + rawPrepareWhenableModule(zone, { ...defaultPowers, ...powers }); -// Heap-based whenable support for migration to durable objects. -const { watch, when, makeWhenableKit, makeWhenablePromiseKit } = - prepareWhenableModule(makeHeapZone()); -export { watch, when, makeWhenableKit, makeWhenablePromiseKit }; +export const { E, watch, when, makeWhenableKit } = prepareWhenableModule( + makeHeapZone(), +); diff --git a/packages/whenable/package.json b/packages/whenable/package.json index 6b73bf360b2..466919f020a 100755 --- a/packages/whenable/package.json +++ b/packages/whenable/package.json @@ -21,14 +21,14 @@ }, "dependencies": { "@agoric/base-zone": "^0.1.0", - "@agoric/internal": "^0.3.2", "@endo/far": "^1.0.1", "@endo/pass-style": "^1.0.1", "@endo/patterns": "^1.0.1", "@endo/promise-kit": "^1.0.1" }, "devDependencies": { - "@endo/init": "^1.0.1", + "@endo/far": "^1.0.2", + "@endo/init": "^1.0.2", "ava": "^5.3.0" }, "ava": { diff --git a/packages/whenable/src/heap.js b/packages/whenable/src/heap.js deleted file mode 100644 index a5f89c0b35a..00000000000 --- a/packages/whenable/src/heap.js +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-check -import { makeHeapZone } from '@agoric/base-zone/heap.js'; -import { wrappedPrepareWhenableModule } from './module.js'; - -// Heap-based whenable support is exported to assist in migration. -export const { when, makeWhenableKit } = wrappedPrepareWhenableModule( - makeHeapZone(), -); diff --git a/packages/whenable/src/index.js b/packages/whenable/src/index.js index 850d3e02e36..672080c7ee1 100644 --- a/packages/whenable/src/index.js +++ b/packages/whenable/src/index.js @@ -1,8 +1,5 @@ // @ts-check -export * from './when.js'; -export * from './whenable.js'; export * from './module.js'; -export * from './heap.js'; // eslint-disable-next-line import/export export * from './types.js'; diff --git a/scripts/check-dependency-cycles.sh b/scripts/check-dependency-cycles.sh index 055ec15531b..4478d4476c0 100755 --- a/scripts/check-dependency-cycles.sh +++ b/scripts/check-dependency-cycles.sh @@ -4,9 +4,15 @@ set -ueo pipefail MAX_EDGES=${1-0} -CYCLIC_EDGE_COUNT=$(scripts/graph.sh | wc -l) +graphout=$(scripts/graph.sh) +if test -z "$graphout"; then + CYCLIC_EDGE_COUNT=0 +else + echo "$graphout" + CYCLIC_EDGE_COUNT=$(echo "$graphout" | wc -l) +fi -echo CYCLIC_EDGE_COUNT $CYCLIC_EDGE_COUNT +echo CYCLIC_EDGE_COUNT "$CYCLIC_EDGE_COUNT" if [[ $CYCLIC_EDGE_COUNT -gt $MAX_EDGES ]]; then From 86ee08243e3f3691042147905b0573810fccf0ed Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Thu, 18 Jan 2024 10:44:56 -0600 Subject: [PATCH 09/19] fix(whenable): properly chain `watch`ed whenables --- packages/whenable/src/module.js | 4 +- packages/whenable/src/types.js | 9 +- packages/whenable/src/watch.js | 211 ++++++++++++---------- packages/whenable/src/when.js | 4 +- packages/whenable/src/whenable-utils.js | 46 +++-- packages/whenable/src/whenable.js | 4 +- packages/whenable/test/test-disconnect.js | 4 +- packages/whenable/test/test-watch.js | 52 ++++++ 8 files changed, 215 insertions(+), 119 deletions(-) create mode 100644 packages/whenable/test/test-watch.js diff --git a/packages/whenable/src/module.js b/packages/whenable/src/module.js index 89e7a52e0c8..82cc0470745 100644 --- a/packages/whenable/src/module.js +++ b/packages/whenable/src/module.js @@ -9,7 +9,7 @@ import { prepareWatch } from './watch.js'; * @param {(reason: any) => boolean} [powers.rejectionMeansRetry] * @param {(p: PromiseLike, watcher: import('./watch.js').PromiseWatcher, ...args: unknown[]) => void} [powers.watchPromise] */ -export const prepareWhenableModule = (zone, powers) => { +export const wrappedPrepareWhenableModule = (zone, powers) => { const { rejectionMeansRetry = _reason => false, watchPromise } = powers || {}; const { makeWhenableKit, makeWhenablePromiseKit } = prepareWhenableKits(zone); const when = prepareWhen(zone, makeWhenablePromiseKit, rejectionMeansRetry); @@ -21,4 +21,4 @@ export const prepareWhenableModule = (zone, powers) => { ); return harden({ watch, when, makeWhenableKit, makeWhenablePromiseKit }); }; -harden(prepareWhenableModule); +harden(wrappedPrepareWhenableModule); diff --git a/packages/whenable/src/types.js b/packages/whenable/src/types.js index 89ea543c6a8..a08ce892f5d 100644 --- a/packages/whenable/src/types.js +++ b/packages/whenable/src/types.js @@ -1,3 +1,4 @@ +// @ts-check export {}; /** @@ -5,11 +6,15 @@ export {}; * @typedef {PromiseLike>} PromiseWhenable */ +/** + * @template [T=any] + * @typedef {{ whenable0: { shorten(): Promise>} }} WhenablePayload + */ + /** * @template [T=any] * @typedef {import('@endo/pass-style').CopyTagged< - * 'Whenable', - * { whenable0: { shorten(): Promise>} } + * 'Whenable', WhenablePayload * >} Whenable */ diff --git a/packages/whenable/src/watch.js b/packages/whenable/src/watch.js index 9fa4a0ae794..114d11f02a9 100644 --- a/packages/whenable/src/watch.js +++ b/packages/whenable/src/watch.js @@ -2,7 +2,7 @@ import { E } from '@endo/far'; import { M } from '@endo/patterns'; -import { getWhenablePayload, getFirstWhenable } from './whenable-utils.js'; +import { getWhenablePayload, unwrapPromise } from './whenable-utils.js'; const { Fail } = assert; @@ -59,22 +59,15 @@ const watchPromiseShim = (p, watcher, ...watcherArgs) => { }; /** - * @param {import('@agoric/base-zone').Zone} zone - * @param {() => import('./types.js').WhenableKit} makeWhenableKit - * @param {typeof watchPromiseShim} [watchPromise] - * @param {(reason: any) => boolean} [rejectionMeansRetry] + * @param {typeof watchPromiseShim} watchPromise */ -export const prepareWatch = ( - zone, - makeWhenableKit, - watchPromise = watchPromiseShim, - rejectionMeansRetry = () => false, -) => { +const makeWatchWhenable = + watchPromise => /** * @param {any} specimen - * @param {PromiseWatcher} watcher + * @param {PromiseWatcher} promiseWatcher */ - const watchWhenable = (specimen, watcher) => { + (specimen, promiseWatcher) => { let promise; const payload = getWhenablePayload(specimen); if (payload) { @@ -82,98 +75,132 @@ export const prepareWatch = ( } else { promise = E.resolve(specimen); } - watchPromise(promise, watcher); + watchPromise(promise, promiseWatcher); }; - /** - * @param {import('./types.js').Settler} settler - * @param {import('./types.js').Watcher} watcher - * @param {keyof Required} wcb - * @param {unknown} value - */ - const settle = (settler, watcher, wcb, value) => { - try { - let chainedValue = value; - const w = watcher[wcb]; - if (w) { - // - chainedValue = apply(w, watcher, [value]); - } else if (wcb === 'onRejected') { - throw value; - } - settler && settler.resolve(chainedValue); - } catch (e) { - if (settler) { - settler.reject(e); - } else { - throw e; - } +/** + * @param {import('./types.js').Settler} settler + * @param {import('./types.js').Watcher} watcher + * @param {keyof Required} wcb + * @param {unknown} value + */ +const settle = (settler, watcher, wcb, value) => { + try { + let chainedValue = value; + const w = watcher[wcb]; + if (w) { + chainedValue = apply(w, watcher, [value]); + } else if (wcb === 'onRejected') { + throw value; } - }; - const makeReconnectWatcher = zone.exoClass( - 'ReconnectWatcher', - PromiseWatcherI, + settler && settler.resolve(chainedValue); + } catch (e) { + if (settler) { + settler.reject(e); + } else { + throw e; + } + } +}; + +/** + * @param {import('@agoric/base-zone').Zone} zone + * @param {(reason: any) => boolean} rejectionMeansRetry + * @param {ReturnType} watchWhenable + */ +const prepareWatcherKit = (zone, rejectionMeansRetry, watchWhenable) => + zone.exoClassKit( + 'PromiseWatcher', + { + promiseWatcher: PromiseWatcherI, + whenableSetter: M.interface('whenableSetter', { + setWhenable: M.call(M.any()).returns(), + }), + }, /** * @template [T=any] * @template [TResult1=T] * @template [TResult2=never] - * @param {import('./types.js').Whenable} whenable * @param {import('./types.js').Settler} settler * @param {import('./types.js').Watcher} [watcher] */ - (whenable, settler, watcher) => { + (settler, watcher) => { const state = { - whenable, + whenable: undefined, settler, watcher, }; return /** @type {Partial} */ (state); }, { - /** @type {Required['onFulfilled']} */ - onFulfilled(value) { - const { watcher, settler } = this.state; - if (getWhenablePayload(value)) { - // We've been shortened, so reflect our state accordingly, and go again. - const whenable = /** @type {import('./types.js').Whenable} */ ( - value - ); + whenableSetter: { + /** @param {any} whenable */ + setWhenable(whenable) { this.state.whenable = whenable; - watchWhenable(value, this.self); - return undefined; - } - this.state.watcher = undefined; - this.state.settler = undefined; - if (!settler) { - return undefined; - } else if (watcher) { - settle(settler, watcher, 'onFulfilled', value); - } else { - settler.resolve(value); - } + }, }, - /** @type {Required['onRejected']} */ - onRejected(reason) { - const { watcher, settler } = this.state; - if (rejectionMeansRetry(reason)) { - watchWhenable(this.state.whenable, this.self); - return; - } - this.state.settler = undefined; - this.state.watcher = undefined; - if (!watcher) { - settler && settler.reject(reason); - } else if (!settler) { - throw reason; // for host's unhandled rejection handler to catch - } else if (watcher.onRejected) { - settle(settler, watcher, 'onRejected', reason); - } else { - settler.reject(reason); - } + promiseWatcher: { + /** @type {Required['onFulfilled']} */ + onFulfilled(value) { + const { watcher, settler } = this.state; + if (getWhenablePayload(value)) { + // We've been shortened, so reflect our state accordingly, and go again. + this.facets.whenableSetter.setWhenable(value); + watchWhenable(value, this.facets.promiseWatcher); + return undefined; + } + this.state.watcher = undefined; + this.state.settler = undefined; + if (!settler) { + return undefined; + } else if (watcher) { + settle(settler, watcher, 'onFulfilled', value); + } else { + settler.resolve(value); + } + }, + /** @type {Required['onRejected']} */ + onRejected(reason) { + const { watcher, settler } = this.state; + if (rejectionMeansRetry(reason)) { + watchWhenable(this.state.whenable, this.facets.promiseWatcher); + return; + } + this.state.settler = undefined; + this.state.watcher = undefined; + if (!watcher) { + settler && settler.reject(reason); + } else if (!settler) { + throw reason; // for host's unhandled rejection handler to catch + } else if (watcher.onRejected) { + settle(settler, watcher, 'onRejected', reason); + } else { + settler.reject(reason); + } + }, }, }, ); +/** + * @param {import('@agoric/base-zone').Zone} zone + * @param {() => import('./types.js').WhenableKit} makeWhenableKit + * @param {typeof watchPromiseShim} [watchPromise] + * @param {(reason: any) => boolean} [rejectionMeansRetry] + */ +export const prepareWatch = ( + zone, + makeWhenableKit, + watchPromise = watchPromiseShim, + rejectionMeansRetry = _reason => false, +) => { + const watchWhenable = makeWatchWhenable(watchPromise); + const makeWatcherKit = prepareWatcherKit( + zone, + rejectionMeansRetry, + watchWhenable, + ); + /** * @template T * @param {any} specimenP @@ -181,18 +208,20 @@ export const prepareWatch = ( */ const watch = (specimenP, watcher) => { const { settler, whenable } = makeWhenableKit(); + + const { promiseWatcher, whenableSetter } = makeWatcherKit(settler, watcher); + // Ensure we have a presence that won't be disconnected later. - getFirstWhenable(specimenP, (specimen, whenable0) => { - if (!whenable0) { - // We're already as short as we can get. - settler.resolve(specimen); + unwrapPromise(specimenP, (specimen, payload) => { + whenableSetter.setWhenable(specimen); + // Persistently watch the specimen. + if (!payload) { + // Specimen is not a whenable. + promiseWatcher.onFulfilled(specimen); return; } - - // Persistently watch the specimen. - const reconnectWatcher = makeReconnectWatcher(specimen, settler, watcher); - watchWhenable(specimen, reconnectWatcher); - }).catch(e => settler.reject(e)); + watchWhenable(specimen, promiseWatcher); + }).catch(e => promiseWatcher.onRejected(e)); return whenable; }; diff --git a/packages/whenable/src/when.js b/packages/whenable/src/when.js index f5a0ccb28e3..8f6747152d7 100644 --- a/packages/whenable/src/when.js +++ b/packages/whenable/src/when.js @@ -1,7 +1,7 @@ // @ts-check import { E } from '@endo/far'; -import { getFirstWhenable, getWhenablePayload } from './whenable-utils.js'; +import { unwrapPromise, getWhenablePayload } from './whenable-utils.js'; /** * @param {import('@agoric/base-zone').Zone} zone @@ -19,7 +19,7 @@ export const prepareWhen = ( const when = specimenP => { const { settler, promise } = makeWhenablePromiseKit(); // Ensure we have a presence that won't be disconnected later. - getFirstWhenable(specimenP, async (specimen, payload) => { + unwrapPromise(specimenP, async (specimen, payload) => { // Shorten the whenable chain without a watcher. await null; while (payload) { diff --git a/packages/whenable/src/whenable-utils.js b/packages/whenable/src/whenable-utils.js index d962849cb3e..dc2bd9a3b31 100644 --- a/packages/whenable/src/whenable-utils.js +++ b/packages/whenable/src/whenable-utils.js @@ -1,18 +1,25 @@ +// @ts-check import { getTag } from '@endo/pass-style'; /** * @template T - * @param {T} specimen - * @returns {T extends import('./types').Whenable ? - * import('./types').Whenable['payload'] : - * undefined - * } + * @param {any} specimen + * @returns {import('./types').WhenablePayload | undefined} */ -export const getWhenablePayload = specimen => - typeof specimen === 'object' && - specimen !== null && - getTag(specimen) === 'Whenable' && - specimen.payload; +export const getWhenablePayload = specimen => { + const isWhenable = + typeof specimen === 'object' && + specimen !== null && + getTag(specimen) === 'Whenable'; + if (!isWhenable) { + return undefined; + } + + const whenable = /** @type {import('./types').Whenable} */ ( + /** @type {unknown} */ (specimen) + ); + return whenable.payload; +}; /** A unique object identity just for internal use. */ const ALREADY_WHENABLE = harden({}); @@ -21,22 +28,25 @@ const ALREADY_WHENABLE = harden({}); * @template T * @template U * @param {T} specimenP - * @param {(specimen: Awaited, payload: import('./types').Whenable['payload']) => U} cb - * @returns {Promise>} + * @param {(unwrapped: Awaited, payload?: import('./types').WhenablePayload) => U} cb + * @returns {Promise} */ -export const getFirstWhenable = async (specimenP, cb) => { +export const unwrapPromise = async (specimenP, cb) => { let payload = getWhenablePayload(specimenP); // Take exactly 1 turn to find the first whenable, if any. - let specimen = await (payload ? ALREADY_WHENABLE : specimenP); - if (specimen === ALREADY_WHENABLE) { + const awaited = await (payload ? ALREADY_WHENABLE : specimenP); + /** @type {unknown} */ + let unwrapped; + if (awaited === ALREADY_WHENABLE) { // The fact that we have a whenable payload means it's not actually a // promise. - specimen = specimenP; + unwrapped = specimenP; } else { // Check if the awaited specimen is a whenable. - payload = getWhenablePayload(specimen); + unwrapped = awaited; + payload = getWhenablePayload(unwrapped); } - return cb(specimen, payload); + return cb(/** @type {Awaited} */ (unwrapped), payload); }; diff --git a/packages/whenable/src/whenable.js b/packages/whenable/src/whenable.js index c92bb957440..7760e111bc5 100644 --- a/packages/whenable/src/whenable.js +++ b/packages/whenable/src/whenable.js @@ -13,7 +13,7 @@ export const prepareWhenableKits = zone => { /** * Get the current incarnation's promise kit associated with a whenable0. * - * @param {import('./types.js').Whenable['payload']['whenable0']} whenable0 + * @param {import('./types.js').WhenablePayload['whenable0']} whenable0 * @returns {import('@endo/promise-kit').PromiseKit} */ const findCurrentKit = whenable0 => { @@ -30,7 +30,7 @@ export const prepareWhenableKits = zone => { /** * @param {'resolve' | 'reject'} kind - * @param {import('./types.js').Whenable['payload']['whenable0']} whenable0 + * @param {import('./types.js').WhenablePayload['whenable0']} whenable0 * @param {unknown} value */ const settle = (kind, whenable0, value) => { diff --git a/packages/whenable/test/test-disconnect.js b/packages/whenable/test/test-disconnect.js index c04ab6cd65c..4e62ff5a742 100644 --- a/packages/whenable/test/test-disconnect.js +++ b/packages/whenable/test/test-disconnect.js @@ -3,7 +3,7 @@ import test from 'ava'; import { makeHeapZone } from '@agoric/base-zone/heap.js'; import { makeTagged } from '@endo/pass-style'; -import { prepareWhenableModule } from '../src/module.js'; +import { wrappedPrepareWhenableModule } from '../src/module.js'; /** * @param {import('@agoric/base-zone').Zone} zone @@ -12,7 +12,7 @@ import { prepareWhenableModule } from '../src/module.js'; const testRetryOnDisconnect = zone => async t => { const rejectionMeansRetry = e => e && e.message === 'disconnected'; - const { watch, when } = prepareWhenableModule(zone, { + const { watch, when } = wrappedPrepareWhenableModule(zone, { rejectionMeansRetry, }); const makeTestWhenable0 = zone.exoClass( diff --git a/packages/whenable/test/test-watch.js b/packages/whenable/test/test-watch.js new file mode 100644 index 00000000000..ba211e7bce4 --- /dev/null +++ b/packages/whenable/test/test-watch.js @@ -0,0 +1,52 @@ +// @ts-check +import test from 'ava'; + +import { makeHeapZone } from '@agoric/base-zone/heap.js'; +import { prepareWhenableModule } from '../src/module.js'; + +/** + * @param {import('@agoric/base-zone').Zone} zone + * @param {import('ava').ExecutionContext} t + */ +const prepareAckWatcher = (zone, t) => { + return zone.exoClass('AckWatcher', undefined, packet => ({ packet }), { + onFulfilled(ack) { + t.is(ack, 'ack'); + return 'fulfilled'; + }, + onRejected(reason) { + t.truthy(reason instanceof Error); + return 'rejected'; + }, + }); +}; + +test('ack watcher', async t => { + const zone = makeHeapZone(); + const { watch, when, makeWhenableKit } = prepareWhenableModule(zone); + const makeAckWatcher = prepareAckWatcher(zone, t); + + const packet = harden({ portId: 'port-1', channelId: 'channel-1' }); + + const connSend = Promise.resolve('ack'); + t.is(await when(watch(connSend, makeAckWatcher(packet))), 'fulfilled'); + + const connError = Promise.reject(Error('disconnected')); + t.is(await when(watch(connError, makeAckWatcher(packet))), 'rejected'); + + const { whenable, settler } = makeWhenableKit(); + const connWhenable = Promise.resolve(whenable); + settler.resolve('ack'); + t.is(await when(watch(connWhenable, makeAckWatcher(packet))), 'fulfilled'); + + const { whenable: whenable2, settler: settler2 } = makeWhenableKit(); + const connWhenable2 = Promise.resolve(whenable2); + settler2.resolve(whenable); + t.is(await when(watch(connWhenable2, makeAckWatcher(packet))), 'fulfilled'); + + const { whenable: whenable3, settler: settler3 } = makeWhenableKit(); + const connWhenable3 = Promise.resolve(whenable3); + settler3.reject(Error('disco2')); + settler3.resolve(whenable2); + t.is(await when(watch(connWhenable3, makeAckWatcher(packet))), 'rejected'); +}); From 7086fe9cf2cf34030af1f9adeef852b7363ed969 Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Thu, 18 Jan 2024 21:00:47 -0600 Subject: [PATCH 10/19] build(whenable): update Endo dependencies --- packages/whenable/package.json | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/whenable/package.json b/packages/whenable/package.json index 466919f020a..9970b66fbb5 100755 --- a/packages/whenable/package.json +++ b/packages/whenable/package.json @@ -21,13 +21,12 @@ }, "dependencies": { "@agoric/base-zone": "^0.1.0", - "@endo/far": "^1.0.1", - "@endo/pass-style": "^1.0.1", - "@endo/patterns": "^1.0.1", - "@endo/promise-kit": "^1.0.1" + "@endo/far": "^1.0.2", + "@endo/pass-style": "^1.1.0", + "@endo/patterns": "^1.1.0", + "@endo/promise-kit": "^1.0.2" }, "devDependencies": { - "@endo/far": "^1.0.2", "@endo/init": "^1.0.2", "ava": "^5.3.0" }, From 04557ca5ad61dec8c8299f68d568d88541e31651 Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Sat, 20 Jan 2024 16:14:15 -0600 Subject: [PATCH 11/19] fix(whenable): better fidelity of shimmed `watchPromise` --- packages/whenable/src/watch.js | 6 ++++-- packages/whenable/test/test-watch.js | 27 +++++++++++++++------------ 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/whenable/src/watch.js b/packages/whenable/src/watch.js index 114d11f02a9..04d592f388b 100644 --- a/packages/whenable/src/watch.js +++ b/packages/whenable/src/watch.js @@ -27,9 +27,10 @@ export const PromiseWatcherI = M.interface('PromiseWatcher', { */ const callMeMaybe = (that, prop, postArgs) => { const fn = that[prop]; - if (typeof fn !== 'function') { + if (!fn) { return undefined; } + assert.typeof(fn, 'function'); /** * @param {unknown} arg value or reason */ @@ -44,12 +45,13 @@ const callMeMaybe = (that, prop, postArgs) => { /** * Shim the promise watcher behaviour when VatData.watchPromise is not available. * - * @param {PromiseLike} p + * @param {Promise} p * @param {PromiseWatcher} watcher * @param {...unknown[]} watcherArgs * @returns {void} */ const watchPromiseShim = (p, watcher, ...watcherArgs) => { + Promise.resolve(p) === p || Fail`watchPromise only watches promises`; const onFulfilled = callMeMaybe(watcher, 'onFulfilled', watcherArgs); const onRejected = callMeMaybe(watcher, 'onRejected', watcherArgs); onFulfilled || diff --git a/packages/whenable/test/test-watch.js b/packages/whenable/test/test-watch.js index ba211e7bce4..441187c5826 100644 --- a/packages/whenable/test/test-watch.js +++ b/packages/whenable/test/test-watch.js @@ -21,32 +21,35 @@ const prepareAckWatcher = (zone, t) => { }); }; -test('ack watcher', async t => { +const runTests = async t => { const zone = makeHeapZone(); const { watch, when, makeWhenableKit } = prepareWhenableModule(zone); const makeAckWatcher = prepareAckWatcher(zone, t); const packet = harden({ portId: 'port-1', channelId: 'channel-1' }); - const connSend = Promise.resolve('ack'); - t.is(await when(watch(connSend, makeAckWatcher(packet))), 'fulfilled'); + const connSendP = Promise.resolve('ack'); + t.is(await when(watch(connSendP, makeAckWatcher(packet))), 'fulfilled'); - const connError = Promise.reject(Error('disconnected')); - t.is(await when(watch(connError, makeAckWatcher(packet))), 'rejected'); + const connErrorP = Promise.reject(Error('disconnected')); + t.is(await when(watch(connErrorP, makeAckWatcher(packet))), 'rejected'); const { whenable, settler } = makeWhenableKit(); - const connWhenable = Promise.resolve(whenable); + const connWhenableP = Promise.resolve(whenable); settler.resolve('ack'); - t.is(await when(watch(connWhenable, makeAckWatcher(packet))), 'fulfilled'); + t.is(await when(watch(connWhenableP, makeAckWatcher(packet))), 'fulfilled'); + t.is(await when(watch(whenable, makeAckWatcher(packet))), 'fulfilled'); const { whenable: whenable2, settler: settler2 } = makeWhenableKit(); - const connWhenable2 = Promise.resolve(whenable2); + const connWhenable2P = Promise.resolve(whenable2); settler2.resolve(whenable); - t.is(await when(watch(connWhenable2, makeAckWatcher(packet))), 'fulfilled'); + t.is(await when(watch(connWhenable2P, makeAckWatcher(packet))), 'fulfilled'); const { whenable: whenable3, settler: settler3 } = makeWhenableKit(); - const connWhenable3 = Promise.resolve(whenable3); + const connWhenable3P = Promise.resolve(whenable3); settler3.reject(Error('disco2')); settler3.resolve(whenable2); - t.is(await when(watch(connWhenable3, makeAckWatcher(packet))), 'rejected'); -}); + t.is(await when(watch(connWhenable3P, makeAckWatcher(packet))), 'rejected'); +}; + +test('ack watcher - shim', runTests); From 8097d9473acd6e0ace023b36401af070f727f39b Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Mon, 29 Jan 2024 11:49:02 -0600 Subject: [PATCH 12/19] chore(whenable): `whenable0` -> `whenableV0` --- packages/whenable/src/types.js | 19 ++++++++- packages/whenable/src/watch.js | 2 +- packages/whenable/src/when.js | 2 +- packages/whenable/src/whenable.js | 52 +++++++++++------------ packages/whenable/test/test-disconnect.js | 6 +-- 5 files changed, 48 insertions(+), 33 deletions(-) diff --git a/packages/whenable/src/types.js b/packages/whenable/src/types.js index a08ce892f5d..45e44938764 100644 --- a/packages/whenable/src/types.js +++ b/packages/whenable/src/types.js @@ -8,13 +8,28 @@ export {}; /** * @template [T=any] - * @typedef {{ whenable0: { shorten(): Promise>} }} WhenablePayload + * @typedef {object} WhenableV0 The first version of the whenable implementation + * object. CAVEAT: These methods must never be changed or added to, to provide + * forward/backward compatibility. Create a new object and bump its version + * number instead. + * + * @property {() => Promise>} shorten Return a promise that + * attempts to unwrap all whenables in this promise chain, and return a promise + * for the final value. A rejection may indicate a temporary routing failure + * requiring a retry, otherwise that the decider of the terminal promise + * rejected it. + */ + +/** + * @template [T=any] + * @typedef {object} WhenablePayload + * @property {import('@endo/far').FarRef>} whenableV0 */ /** * @template [T=any] * @typedef {import('@endo/pass-style').CopyTagged< - * 'Whenable', WhenablePayload + * 'Whenable', WhenablePayload * >} Whenable */ diff --git a/packages/whenable/src/watch.js b/packages/whenable/src/watch.js index 04d592f388b..34d5282582d 100644 --- a/packages/whenable/src/watch.js +++ b/packages/whenable/src/watch.js @@ -73,7 +73,7 @@ const makeWatchWhenable = let promise; const payload = getWhenablePayload(specimen); if (payload) { - promise = E(payload.whenable0).shorten(); + promise = E(payload.whenableV0).shorten(); } else { promise = E.resolve(specimen); } diff --git a/packages/whenable/src/when.js b/packages/whenable/src/when.js index 8f6747152d7..95da09c1b92 100644 --- a/packages/whenable/src/when.js +++ b/packages/whenable/src/when.js @@ -23,7 +23,7 @@ export const prepareWhen = ( // Shorten the whenable chain without a watcher. await null; while (payload) { - specimen = await E(payload.whenable0) + specimen = await E(payload.whenableV0) .shorten() .catch(e => { if (rejectionMeansRetry(e)) { diff --git a/packages/whenable/src/whenable.js b/packages/whenable/src/whenable.js index 7760e111bc5..5c8d63c3a45 100644 --- a/packages/whenable/src/whenable.js +++ b/packages/whenable/src/whenable.js @@ -8,45 +8,45 @@ import { makeTagged } from '@endo/pass-style'; */ export const prepareWhenableKits = zone => { /** WeakMap */ - const whenable0ToEphemeral = new WeakMap(); + const whenableV0ToEphemeral = new WeakMap(); /** - * Get the current incarnation's promise kit associated with a whenable0. + * Get the current incarnation's promise kit associated with a whenableV0. * - * @param {import('./types.js').WhenablePayload['whenable0']} whenable0 + * @param {import('./types.js').WhenablePayload['whenableV0']} whenableV0 * @returns {import('@endo/promise-kit').PromiseKit} */ - const findCurrentKit = whenable0 => { - let pk = whenable0ToEphemeral.get(whenable0); + const findCurrentKit = whenableV0 => { + let pk = whenableV0ToEphemeral.get(whenableV0); if (pk) { return pk; } pk = makePromiseKit(); pk.promise.catch(() => {}); // silence unhandled rejection - whenable0ToEphemeral.set(whenable0, pk); + whenableV0ToEphemeral.set(whenableV0, pk); return pk; }; /** * @param {'resolve' | 'reject'} kind - * @param {import('./types.js').WhenablePayload['whenable0']} whenable0 + * @param {import('./types.js').WhenablePayload['whenableV0']} whenableV0 * @param {unknown} value */ - const settle = (kind, whenable0, value) => { - const kit = findCurrentKit(whenable0); + const settle = (kind, whenableV0, value) => { + const kit = findCurrentKit(whenableV0); const cb = kit[kind]; if (!cb) { return; } - whenable0ToEphemeral.set(whenable0, harden({ promise: kit.promise })); + whenableV0ToEphemeral.set(whenableV0, harden({ promise: kit.promise })); cb(value); }; - const rawMakeWhenableKit = zone.exoClassKit( - 'Whenable0Kit', + const makeWhenableInternalsKit = zone.exoClassKit( + 'WhenableInternalsKit', { - whenable0: M.interface('Whenable0', { + whenableV0: M.interface('WhenableV0', { shorten: M.call().returns(M.promise()), }), settler: M.interface('Settler', { @@ -56,12 +56,12 @@ export const prepareWhenableKits = zone => { }, () => ({}), { - whenable0: { + whenableV0: { /** * @returns {Promise} */ shorten() { - return findCurrentKit(this.facets.whenable0).promise; + return findCurrentKit(this.facets.whenableV0).promise; }, }, settler: { @@ -69,15 +69,15 @@ export const prepareWhenableKits = zone => { * @param {any} [value] */ resolve(value) { - const { whenable0 } = this.facets; - settle('resolve', whenable0, value); + const { whenableV0 } = this.facets; + settle('resolve', whenableV0, value); }, /** * @param {any} [reason] */ reject(reason) { - const { whenable0 } = this.facets; - settle('reject', whenable0, reason); + const { whenableV0 } = this.facets; + settle('reject', whenableV0, reason); }, }, }, @@ -88,8 +88,8 @@ export const prepareWhenableKits = zone => { * @returns {import('./types.js').WhenableKit} */ const makeWhenableKit = () => { - const { settler, whenable0 } = rawMakeWhenableKit(); - const whenable = makeTagged('Whenable', harden({ whenable0 })); + const { settler, whenableV0 } = makeWhenableInternalsKit(); + const whenable = makeTagged('Whenable', harden({ whenableV0 })); return harden({ settler, whenable }); }; @@ -98,8 +98,8 @@ export const prepareWhenableKits = zone => { * @returns {import('./types.js').WhenablePromiseKit} */ const makeWhenablePromiseKit = () => { - const { settler, whenable0 } = rawMakeWhenableKit(); - const whenable = makeTagged('Whenable', harden({ whenable0 })); + const { settler, whenableV0 } = makeWhenableInternalsKit(); + const whenable = makeTagged('Whenable', harden({ whenableV0 })); /** * It would be nice to fully type this, but TypeScript gives: @@ -110,15 +110,15 @@ export const prepareWhenableKits = zone => { then(onFulfilled, onRejected) { // This promise behaviour is ephemeral. If you want a persistent // subscription, you must use `when(p, watcher)`. - const { promise } = findCurrentKit(whenable0); + const { promise } = findCurrentKit(whenableV0); return promise.then(onFulfilled, onRejected); }, catch(onRejected) { - const { promise } = findCurrentKit(whenable0); + const { promise } = findCurrentKit(whenableV0); return promise.catch(onRejected); }, finally(onFinally) { - const { promise } = findCurrentKit(whenable0); + const { promise } = findCurrentKit(whenableV0); return promise.finally(onFinally); }, }; diff --git a/packages/whenable/test/test-disconnect.js b/packages/whenable/test/test-disconnect.js index 4e62ff5a742..92185ee6ece 100644 --- a/packages/whenable/test/test-disconnect.js +++ b/packages/whenable/test/test-disconnect.js @@ -15,8 +15,8 @@ const testRetryOnDisconnect = zone => async t => { const { watch, when } = wrappedPrepareWhenableModule(zone, { rejectionMeansRetry, }); - const makeTestWhenable0 = zone.exoClass( - 'TestWhenable0', + const makeTestWhenableV0 = zone.exoClass( + 'TestWhenableV0', undefined, plan => ({ plan }), { @@ -62,7 +62,7 @@ const testRetryOnDisconnect = zone => async t => { /** @type {import('../src/types.js').Whenable} */ const whenable = makeTagged('Whenable', { - whenable0: makeTestWhenable0(plan), + whenableV0: makeTestWhenableV0(plan), }); let resultP; From a4c2ae151db720db6e4ec3812ab87d55fb3c2f06 Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Mon, 29 Jan 2024 15:02:29 -0600 Subject: [PATCH 13/19] docs(whenable): add some --- packages/whenable/README.md | 55 +++++++++++++++++++++++++++++++++++++ packages/whenable/heap.js | 5 ++++ 2 files changed, 60 insertions(+) create mode 100644 packages/whenable/README.md create mode 100644 packages/whenable/heap.js diff --git a/packages/whenable/README.md b/packages/whenable/README.md new file mode 100644 index 00000000000..ad6f05f2521 --- /dev/null +++ b/packages/whenable/README.md @@ -0,0 +1,55 @@ +# Whenables + +Native promises are not compatible with `@agoric/store`, which means that on the Agoric platform, such promises are disconnected when their creator vat is upgraded. Whenables are storable objects that represent a promise that tolerates disconnections. + +## Whenable Consumer + +If your vat is a consumer of promises that are unexpectedly fulfilling to a Whenable (something like): + +```js +await w; +Object [Whenable] { + payload: { whenableV0: Object [Alleged: WhenableInternalsKit whenableV0] {} } +} +``` + +you can change the `await w` into `await when(w)` to convert a chain of whenables to its final settlement: + +```js +import { when } from '@agoric/whenable/heap.js'; +[...] +await when(w); +'Hello, patient world!' +``` + +## Whenable Producer + +Use the following to create and resolve a whenable: + +```js +import { makeWhenableKit } from '@agoric/whenable/heap.js'; +[...] +const { settler, whenable } = makeWhenableKit(); +// Send whenable to a potentially different vat. +E(outsideReference).performSomeMethod(whenable); +// some time later... +settler.resolve('now you know the answer'); +``` + +## Durability + +The whenable package supports Zones, which are used to integrate Agoric's vat +upgrade mechanism. To create durable whenable functions: + +```js +import { prepareWhenableModule } from '@agoric/whenable'; +import { makeDurableZone } from '@agoric/zone'; + +// Only do the following once at the start of a new vat incarnation: +const zone = makeDurableZone(baggage); +const whenableZone = zone.subZone('WhenableModule'); +const { when, watch, makeWhenableKit } = prepareWhenableModule(whenableZone); + +// Now you the functions have been bound to the durable baggage. +// Whenables and settlers you create can be saved in durable stores. +``` diff --git a/packages/whenable/heap.js b/packages/whenable/heap.js new file mode 100644 index 00000000000..d9df5c6cca1 --- /dev/null +++ b/packages/whenable/heap.js @@ -0,0 +1,5 @@ +import { makeHeapZone } from '@agoric/base-zone/heap.js'; +import { prepareWhenableModule } from './src/module.js'; + +export const { makeWhenableKit, makeWhenablePromiseKit, when, watch } = + prepareWhenableModule(makeHeapZone()); From c6bc209153e9898cf2434d14309e79e149361381 Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Mon, 29 Jan 2024 16:27:14 -0600 Subject: [PATCH 14/19] chore(whenable): copy `E.js` from `@endo/eventual-send` --- packages/whenable/src/E.js | 408 +++++++++++++++++++++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 packages/whenable/src/E.js diff --git a/packages/whenable/src/E.js b/packages/whenable/src/E.js new file mode 100644 index 00000000000..f04c0423f66 --- /dev/null +++ b/packages/whenable/src/E.js @@ -0,0 +1,408 @@ +import { trackTurns } from './track-turns.js'; +import { makeMessageBreakpointTester } from './message-breakpoints.js'; + +const { details: X, quote: q, Fail } = assert; +const { assign, create } = Object; + +const onSend = makeMessageBreakpointTester('ENDO_SEND_BREAKPOINTS'); + +/** @type {ProxyHandler} */ +const baseFreezableProxyHandler = { + set(_target, _prop, _value) { + return false; + }, + isExtensible(_target) { + return false; + }, + setPrototypeOf(_target, _value) { + return false; + }, + deleteProperty(_target, _prop) { + return false; + }, +}; + +// E Proxy handlers pretend that any property exists on the target and returns +// a function for their value. While this function is "bound" by context, it is +// meant to be called as a method. For that reason, the returned function +// includes a check that the `this` argument corresponds to the initial +// receiver when the function was retrieved. +// E Proxy handlers also forward direct calls to the target in case the remote +// is a function instead of an object. No such receiver checks are necessary in +// that case. + +/** + * A Proxy handler for E(x). + * + * @param {any} recipient Any value passed to E(x) + * @param {import('./types').HandledPromiseConstructor} HandledPromise + * @returns {ProxyHandler} the Proxy handler + */ +const makeEProxyHandler = (recipient, HandledPromise) => + harden({ + ...baseFreezableProxyHandler, + get: (_target, propertyKey, receiver) => { + return harden( + { + // This function purposely checks the `this` value (see above) + // In order to be `this` sensitive it is defined using concise method + // syntax rather than as an arrow function. To ensure the function + // is not constructable, it also avoids the `function` syntax. + [propertyKey](...args) { + if (this !== receiver) { + // Reject the async function call + return HandledPromise.reject( + assert.error( + X`Unexpected receiver for "${q(propertyKey)}" method of E(${q( + recipient, + )})`, + ), + ); + } + + if (onSend && onSend.shouldBreakpoint(recipient, propertyKey)) { + // eslint-disable-next-line no-debugger + debugger; // LOOK UP THE STACK + // Stopped at a breakpoint on eventual-send of a method-call + // message, + // so that you can walk back on the stack to see how we came to + // make this eventual-send + } + return HandledPromise.applyMethod(recipient, propertyKey, args); + }, + // @ts-expect-error https://github.com/microsoft/TypeScript/issues/50319 + }[propertyKey], + ); + }, + apply: (_target, _thisArg, argArray = []) => { + if (onSend && onSend.shouldBreakpoint(recipient, undefined)) { + // eslint-disable-next-line no-debugger + debugger; // LOOK UP THE STACK + // Stopped at a breakpoint on eventual-send of a function-call message, + // so that you can walk back on the stack to see how we came to + // make this eventual-send + } + return HandledPromise.applyFunction(recipient, argArray); + }, + has: (_target, _p) => { + // We just pretend everything exists. + return true; + }, + }); + +/** + * A Proxy handler for E.sendOnly(x) + * It is a variant on the E(x) Proxy handler. + * + * @param {any} recipient Any value passed to E.sendOnly(x) + * @param {import('./types').HandledPromiseConstructor} HandledPromise + * @returns {ProxyHandler} the Proxy handler + */ +const makeESendOnlyProxyHandler = (recipient, HandledPromise) => + harden({ + ...baseFreezableProxyHandler, + get: (_target, propertyKey, receiver) => { + return harden( + { + // This function purposely checks the `this` value (see above) + // In order to be `this` sensitive it is defined using concise method + // syntax rather than as an arrow function. To ensure the function + // is not constructable, it also avoids the `function` syntax. + [propertyKey](...args) { + // Throw since the function returns nothing + this === receiver || + Fail`Unexpected receiver for "${q( + propertyKey, + )}" method of E.sendOnly(${q(recipient)})`; + if (onSend && onSend.shouldBreakpoint(recipient, propertyKey)) { + // eslint-disable-next-line no-debugger + debugger; // LOOK UP THE STACK + // Stopped at a breakpoint on eventual-send of a method-call + // message, + // so that you can walk back on the stack to see how we came to + // make this eventual-send + } + HandledPromise.applyMethodSendOnly(recipient, propertyKey, args); + return undefined; + }, + // @ts-expect-error https://github.com/microsoft/TypeScript/issues/50319 + }[propertyKey], + ); + }, + apply: (_target, _thisArg, argsArray = []) => { + if (onSend && onSend.shouldBreakpoint(recipient, undefined)) { + // eslint-disable-next-line no-debugger + debugger; // LOOK UP THE STACK + // Stopped at a breakpoint on eventual-send of a function-call message, + // so that you can walk back on the stack to see how we came to + // make this eventual-send + } + HandledPromise.applyFunctionSendOnly(recipient, argsArray); + return undefined; + }, + has: (_target, _p) => { + // We just pretend that everything exists. + return true; + }, + }); + +/** + * A Proxy handler for E.get(x) + * It is a variant on the E(x) Proxy handler. + * + * @param {any} x Any value passed to E.get(x) + * @param {import('./types').HandledPromiseConstructor} HandledPromise + * @returns {ProxyHandler} the Proxy handler + */ +const makeEGetProxyHandler = (x, HandledPromise) => + harden({ + ...baseFreezableProxyHandler, + has: (_target, _prop) => true, + get: (_target, prop) => HandledPromise.get(x, prop), + }); + +/** + * @param {import('./types').HandledPromiseConstructor} HandledPromise + */ +const makeE = HandledPromise => { + return harden( + assign( + /** + * E(x) returns a proxy on which you can call arbitrary methods. Each of these + * method calls returns a promise. The method will be invoked on whatever + * 'x' designates (or resolves to) in a future turn, not this one. + * + * @template T + * @param {T} x target for method/function call + * @returns {ECallableOrMethods>} method/function call proxy + */ + x => harden(new Proxy(() => {}, makeEProxyHandler(x, HandledPromise))), + { + /** + * E.get(x) returns a proxy on which you can get arbitrary properties. + * Each of these properties returns a promise for the property. The promise + * value will be the property fetched from whatever 'x' designates (or + * resolves to) in a future turn, not this one. + * + * @template T + * @param {T} x target for property get + * @returns {EGetters>} property get proxy + * @readonly + */ + get: x => + harden( + new Proxy(create(null), makeEGetProxyHandler(x, HandledPromise)), + ), + + /** + * E.resolve(x) converts x to a handled promise. It is + * shorthand for HandledPromise.resolve(x) + * + * @template T + * @param {T} x value to convert to a handled promise + * @returns {Promise>} handled promise for x + * @readonly + */ + resolve: HandledPromise.resolve, + + /** + * E.sendOnly returns a proxy similar to E, but for which the results + * are ignored (undefined is returned). + * + * @template T + * @param {T} x target for method/function call + * @returns {ESendOnlyCallableOrMethods>} method/function call proxy + * @readonly + */ + sendOnly: x => + harden( + new Proxy(() => {}, makeESendOnlyProxyHandler(x, HandledPromise)), + ), + + /** + * E.when(x, res, rej) is equivalent to + * HandledPromise.resolve(x).then(res, rej) + * + * @template T + * @template [U = T] + * @param {T|PromiseLike} x value to convert to a handled promise + * @param {(value: T) => ERef} [onfulfilled] + * @param {(reason: any) => ERef} [onrejected] + * @returns {Promise} + * @readonly + */ + when: (x, onfulfilled, onrejected) => + HandledPromise.resolve(x).then( + ...trackTurns([onfulfilled, onrejected]), + ), + }, + ), + ); +}; + +export default makeE; + +/** @typedef {ReturnType} EProxy */ + +/** + * Creates a type that accepts both near and marshalled references that were + * returned from `Remotable` or `Far`, and also promises for such references. + * + * @template Primary The type of the primary reference. + * @template [Local=DataOnly] The local properties of the object. + * @typedef {ERef>} FarRef + */ + +/** + * `DataOnly` means to return a record type `T2` consisting only of + * properties that are *not* functions. + * + * @template T The type to be filtered. + * @typedef {Omit>} DataOnly + */ + +/** + * @see {@link https://github.com/microsoft/TypeScript/issues/31394} + * @template T + * @typedef {PromiseLike | T} ERef + */ + +/** + * @template {import('./types').Callable} T + * @typedef {( + * ReturnType extends PromiseLike // if function returns a promise + * ? T // return the function + * : (...args: Parameters) => Promise>> // make it return a promise + * )} ECallable + */ + +/** + * @template T + * @typedef {{ + * readonly [P in keyof T]: T[P] extends import('./types').Callable + * ? ECallable + * : never; + * }} EMethods + */ + +/** + * @template T + * @typedef {{ + * readonly [P in keyof T]: T[P] extends PromiseLike + * ? T[P] + * : Promise>; + * }} EGetters + */ + +/** + * @template {import('./types').Callable} T + * @typedef {(...args: Parameters) => Promise} ESendOnlyCallable + */ + +/** + * @template T + * @typedef {{ + * readonly [P in keyof T]: T[P] extends import('./types').Callable + * ? ESendOnlyCallable + * : never; + * }} ESendOnlyMethods + */ + +/** + * @template T + * @typedef {( + * T extends import('./types').Callable + * ? ESendOnlyCallable & ESendOnlyMethods> + * : ESendOnlyMethods> + * )} ESendOnlyCallableOrMethods + */ + +/** + * @template T + * @typedef {( + * T extends import('./types').Callable + * ? ECallable & EMethods> + * : EMethods> + * )} ECallableOrMethods + */ + +/** + * Return a union of property names/symbols/numbers P for which the record element T[P]'s type extends U. + * + * Given const x = { a: 123, b: 'hello', c: 42, 49: () => {}, 53: 67 }, + * + * FilteredKeys is the type 'a' | 'c' | 53. + * FilteredKeys is the type 'b'. + * FilteredKeys is the type 'c' | 53. + * FilteredKeys is the type never. + * + * @template T + * @template U + * @typedef {{ [P in keyof T]: T[P] extends U ? P : never; }[keyof T]} FilteredKeys + */ + +/** + * `PickCallable` means to return a single root callable or a record type + * consisting only of properties that are functions. + * + * @template T + * @typedef {( + * T extends import('./types').Callable + * ? (...args: Parameters) => ReturnType // a root callable, no methods + * : Pick> // any callable methods + * )} PickCallable + */ + +/** + * `RemoteFunctions` means to return the functions and properties that are remotely callable. + * + * @template T + * @typedef {( + * T extends import('./types').RemotableBrand // if a given T is some remote interface R + * ? PickCallable // then return the callable properties of R + * : Awaited extends import('./types').RemotableBrand // otherwise, if the final resolution of T is some remote interface R + * ? PickCallable // then return the callable properties of R + * : T extends PromiseLike // otherwise, if T is a promise + * ? Awaited // then return resolved value T + * : T // otherwise, return T + * )} RemoteFunctions + */ + +/** + * @template T + * @typedef {( + * T extends import('./types').RemotableBrand + * ? L + * : Awaited extends import('./types').RemotableBrand + * ? L + * : T extends PromiseLike + * ? Awaited + * : T + * )} LocalRecord + */ + +/** + * @template [R = unknown] + * @typedef {{ + * promise: Promise; + * settler: import('./types').Settler; + * }} EPromiseKit + */ + +/** + * Type for an object that must only be invoked with E. It supports a given + * interface but declares all the functions as asyncable. + * + * @template T + * @typedef {( + * T extends import('./types').Callable + * ? (...args: Parameters) => ERef>>> + * : T extends Record + * ? { + * [K in keyof T]: T[K] extends import('./types').Callable + * ? (...args: Parameters) => ERef>>> + * : T[K]; + * } + * : T + * )} EOnly + */ From f649b524fc9a29cd20347bc1e4e0e4beb8daa9a1 Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Mon, 29 Jan 2024 22:11:50 -0600 Subject: [PATCH 15/19] feat(whenable): working version of `E` --- packages/whenable/README.md | 36 ++-- packages/whenable/heap.js | 2 +- packages/whenable/package.json | 5 +- packages/whenable/src/E.js | 179 ++++++++++++------- packages/whenable/src/message-breakpoints.js | 179 +++++++++++++++++++ packages/whenable/src/module.js | 15 +- packages/whenable/src/track-turns.js | 113 ++++++++++++ packages/whenable/src/types.js | 25 ++- packages/whenable/src/watch.js | 18 +- packages/whenable/src/when.js | 26 +-- packages/whenable/src/whenable-utils.js | 12 +- packages/whenable/src/whenable.js | 24 +-- packages/whenable/test/test-disconnect.js | 2 +- packages/whenable/test/test-watch.js | 2 +- 14 files changed, 501 insertions(+), 137 deletions(-) create mode 100644 packages/whenable/src/message-breakpoints.js create mode 100644 packages/whenable/src/track-turns.js diff --git a/packages/whenable/README.md b/packages/whenable/README.md index ad6f05f2521..df122566b0c 100644 --- a/packages/whenable/README.md +++ b/packages/whenable/README.md @@ -1,25 +1,41 @@ # Whenables -Native promises are not compatible with `@agoric/store`, which means that on the Agoric platform, such promises are disconnected when their creator vat is upgraded. Whenables are storable objects that represent a promise that tolerates disconnections. +Native promises are not compatible with Agoric's durable stores, which means that on the Agoric platform, such promises disconnect their clients when their creator vat is upgraded. Whenables are objects that represent promises that can be stored durably, this package also provides a `when` operator to allow clients to tolerate upgrades of whenable-hosting vats, as well as a `watch` operator to subscribe to a whenable in a way that survives upgrades of both the creator and subscribing client vats. ## Whenable Consumer If your vat is a consumer of promises that are unexpectedly fulfilling to a Whenable (something like): ```js -await w; -Object [Whenable] { - payload: { whenableV0: Object [Alleged: WhenableInternalsKit whenableV0] {} } +import { E } from '@endo/far'; + +const a = await w; +const b = await E(w).something(...args); +console.log('Here they are:', { a, b }); +``` + +Produces output like: +```console +Here they are: { + a: Object [Whenable] { + payload: { whenableV0: Object [Alleged: WhenableInternalsKit whenableV0] {} } + }, + b: Object [Whenable] { + payload: { whenableV0: Object [Alleged: WhenableInternalsKit whenableV0] {} } + } } ``` -you can change the `await w` into `await when(w)` to convert a chain of whenables to its final settlement: +you can use the exported `E` and change the `await w` into `await E.when(w)` in +order to convert a chain of whenables to a promise for its final settlement, and +to do implicit unwrapping of results that are whenables: ```js -import { when } from '@agoric/whenable/heap.js'; +import { E } from '@agoric/internal/whenable.js'; [...] -await when(w); -'Hello, patient world!' +const a = await E.when(w); +const b = await E(w).something(...args); +// Produces the expected results. ``` ## Whenable Producer @@ -48,8 +64,8 @@ import { makeDurableZone } from '@agoric/zone'; // Only do the following once at the start of a new vat incarnation: const zone = makeDurableZone(baggage); const whenableZone = zone.subZone('WhenableModule'); -const { when, watch, makeWhenableKit } = prepareWhenableModule(whenableZone); +const { E, when, watch, makeWhenableKit } = prepareWhenableModule(whenableZone); -// Now you the functions have been bound to the durable baggage. +// Now the functions have been bound to the durable baggage. // Whenables and settlers you create can be saved in durable stores. ``` diff --git a/packages/whenable/heap.js b/packages/whenable/heap.js index d9df5c6cca1..d4269f00935 100644 --- a/packages/whenable/heap.js +++ b/packages/whenable/heap.js @@ -1,5 +1,5 @@ import { makeHeapZone } from '@agoric/base-zone/heap.js'; import { prepareWhenableModule } from './src/module.js'; -export const { makeWhenableKit, makeWhenablePromiseKit, when, watch } = +export const { E, makeWhenableKit, makeWhenablePromiseKit, when, watch } = prepareWhenableModule(makeHeapZone()); diff --git a/packages/whenable/package.json b/packages/whenable/package.json index 9970b66fbb5..0522ffffa16 100755 --- a/packages/whenable/package.json +++ b/packages/whenable/package.json @@ -21,12 +21,15 @@ }, "dependencies": { "@agoric/base-zone": "^0.1.0", - "@endo/far": "^1.0.2", + "@endo/env-options": "^1.1.0", + "@endo/eventual-send": "^1.1.0", "@endo/pass-style": "^1.1.0", "@endo/patterns": "^1.1.0", "@endo/promise-kit": "^1.0.2" }, "devDependencies": { + "@agoric/internal": "^0.3.2", + "@endo/far": "^1.0.2", "@endo/init": "^1.0.2", "ava": "^5.3.0" }, diff --git a/packages/whenable/src/E.js b/packages/whenable/src/E.js index f04c0423f66..e0945fff365 100644 --- a/packages/whenable/src/E.js +++ b/packages/whenable/src/E.js @@ -1,3 +1,4 @@ +// @ts-check import { trackTurns } from './track-turns.js'; import { makeMessageBreakpointTester } from './message-breakpoints.js'; @@ -6,6 +7,8 @@ const { assign, create } = Object; const onSend = makeMessageBreakpointTester('ENDO_SEND_BREAKPOINTS'); +/** @typedef {(...args: any[]) => any} Callable */ + /** @type {ProxyHandler} */ const baseFreezableProxyHandler = { set(_target, _prop, _value) { @@ -35,19 +38,23 @@ const baseFreezableProxyHandler = { * A Proxy handler for E(x). * * @param {any} recipient Any value passed to E(x) - * @param {import('./types').HandledPromiseConstructor} HandledPromise - * @returns {ProxyHandler} the Proxy handler + * @param {import('@endo/eventual-send').HandledPromiseConstructor} HandledPromise + * @param {(x: any) => Promise} unwrap + * @returns {ProxyHandler} the Proxy handler */ -const makeEProxyHandler = (recipient, HandledPromise) => +const makeEProxyHandler = (recipient, HandledPromise, unwrap) => harden({ ...baseFreezableProxyHandler, get: (_target, propertyKey, receiver) => { return harden( { - // This function purposely checks the `this` value (see above) - // In order to be `this` sensitive it is defined using concise method - // syntax rather than as an arrow function. To ensure the function - // is not constructable, it also avoids the `function` syntax. + /** + * This function purposely checks the `this` value (see above) + * In order to be `this` sensitive it is defined using concise method + * syntax rather than as an arrow function. To ensure the function + * is not constructable, it also avoids the `function` syntax. + * @param {...any[]} args + */ [propertyKey](...args) { if (this !== receiver) { // Reject the async function call @@ -63,12 +70,13 @@ const makeEProxyHandler = (recipient, HandledPromise) => if (onSend && onSend.shouldBreakpoint(recipient, propertyKey)) { // eslint-disable-next-line no-debugger debugger; // LOOK UP THE STACK - // Stopped at a breakpoint on eventual-send of a method-call - // message, + // Stopped at a breakpoint on eventual-send of a method-call message, // so that you can walk back on the stack to see how we came to // make this eventual-send } - return HandledPromise.applyMethod(recipient, propertyKey, args); + return unwrap( + HandledPromise.applyMethod(unwrap(recipient), propertyKey, args), + ); }, // @ts-expect-error https://github.com/microsoft/TypeScript/issues/50319 }[propertyKey], @@ -82,7 +90,7 @@ const makeEProxyHandler = (recipient, HandledPromise) => // so that you can walk back on the stack to see how we came to // make this eventual-send } - return HandledPromise.applyFunction(recipient, argArray); + return unwrap(HandledPromise.applyFunction(unwrap(recipient), argArray)); }, has: (_target, _p) => { // We just pretend everything exists. @@ -95,19 +103,23 @@ const makeEProxyHandler = (recipient, HandledPromise) => * It is a variant on the E(x) Proxy handler. * * @param {any} recipient Any value passed to E.sendOnly(x) - * @param {import('./types').HandledPromiseConstructor} HandledPromise - * @returns {ProxyHandler} the Proxy handler + * @param {import('@endo/eventual-send').HandledPromiseConstructor} HandledPromise + * @param {(x: any) => Promise} unwrap + * @returns {ProxyHandler} the Proxy handler */ -const makeESendOnlyProxyHandler = (recipient, HandledPromise) => +const makeESendOnlyProxyHandler = (recipient, HandledPromise, unwrap) => harden({ ...baseFreezableProxyHandler, get: (_target, propertyKey, receiver) => { return harden( { - // This function purposely checks the `this` value (see above) - // In order to be `this` sensitive it is defined using concise method - // syntax rather than as an arrow function. To ensure the function - // is not constructable, it also avoids the `function` syntax. + /** + * This function purposely checks the `this` value (see above) + * In order to be `this` sensitive it is defined using concise method + * syntax rather than as an arrow function. To ensure the function + * is not constructable, it also avoids the `function` syntax. + * @param {...any[]} args + */ [propertyKey](...args) { // Throw since the function returns nothing this === receiver || @@ -117,12 +129,15 @@ const makeESendOnlyProxyHandler = (recipient, HandledPromise) => if (onSend && onSend.shouldBreakpoint(recipient, propertyKey)) { // eslint-disable-next-line no-debugger debugger; // LOOK UP THE STACK - // Stopped at a breakpoint on eventual-send of a method-call - // message, + // Stopped at a breakpoint on eventual-send of a method-call message, // so that you can walk back on the stack to see how we came to // make this eventual-send } - HandledPromise.applyMethodSendOnly(recipient, propertyKey, args); + HandledPromise.applyMethodSendOnly( + unwrap(recipient), + propertyKey, + args, + ); return undefined; }, // @ts-expect-error https://github.com/microsoft/TypeScript/issues/50319 @@ -137,7 +152,7 @@ const makeESendOnlyProxyHandler = (recipient, HandledPromise) => // so that you can walk back on the stack to see how we came to // make this eventual-send } - HandledPromise.applyFunctionSendOnly(recipient, argsArray); + HandledPromise.applyFunctionSendOnly(unwrap(recipient), argsArray); return undefined; }, has: (_target, _p) => { @@ -151,20 +166,32 @@ const makeESendOnlyProxyHandler = (recipient, HandledPromise) => * It is a variant on the E(x) Proxy handler. * * @param {any} x Any value passed to E.get(x) - * @param {import('./types').HandledPromiseConstructor} HandledPromise - * @returns {ProxyHandler} the Proxy handler + * @param {import('@endo/eventual-send').HandledPromiseConstructor} HandledPromise + * @param {(x: any) => Promise} unwrap + * @returns {ProxyHandler} the Proxy handler */ -const makeEGetProxyHandler = (x, HandledPromise) => +const makeEGetProxyHandler = (x, HandledPromise, unwrap) => harden({ ...baseFreezableProxyHandler, has: (_target, _prop) => true, - get: (_target, prop) => HandledPromise.get(x, prop), + get: (_target, prop) => HandledPromise.get(unwrap(x), prop), }); +/** @param {any} x */ +const resolve = x => HandledPromise.resolve(x); + /** - * @param {import('./types').HandledPromiseConstructor} HandledPromise + * @template [A={}] + * @template {(x: any) => Promise} [U=(x: any) => Promise] + * @param {import('@endo/eventual-send').HandledPromiseConstructor} HandledPromise + * @param {object} powers + * @param {U} powers.unwrap + * @param {A} powers.additional */ -const makeE = HandledPromise => { +const makeE = ( + HandledPromise, + { additional = /** @type {A} */ ({}), unwrap = /** @type {U} */ (resolve) }, +) => { return harden( assign( /** @@ -176,7 +203,10 @@ const makeE = HandledPromise => { * @param {T} x target for method/function call * @returns {ECallableOrMethods>} method/function call proxy */ - x => harden(new Proxy(() => {}, makeEProxyHandler(x, HandledPromise))), + x => + harden( + new Proxy(() => {}, makeEProxyHandler(x, HandledPromise, unwrap)), + ), { /** * E.get(x) returns a proxy on which you can get arbitrary properties. @@ -191,7 +221,10 @@ const makeE = HandledPromise => { */ get: x => harden( - new Proxy(create(null), makeEGetProxyHandler(x, HandledPromise)), + new Proxy( + create(null), + makeEGetProxyHandler(x, HandledPromise, unwrap), + ), ), /** @@ -203,7 +236,7 @@ const makeE = HandledPromise => { * @returns {Promise>} handled promise for x * @readonly */ - resolve: HandledPromise.resolve, + resolve: x => resolve(unwrap(x)), /** * E.sendOnly returns a proxy similar to E, but for which the results @@ -216,26 +249,36 @@ const makeE = HandledPromise => { */ sendOnly: x => harden( - new Proxy(() => {}, makeESendOnlyProxyHandler(x, HandledPromise)), + new Proxy( + () => {}, + makeESendOnlyProxyHandler(x, HandledPromise, unwrap), + ), ), /** * E.when(x, res, rej) is equivalent to - * HandledPromise.resolve(x).then(res, rej) + * unwrapped(x).then(onfulfilled, onrejected) * * @template T - * @template [U = T] - * @param {T|PromiseLike} x value to convert to a handled promise - * @param {(value: T) => ERef} [onfulfilled] - * @param {(reason: any) => ERef} [onrejected] - * @returns {Promise} + * @template [TResult1=Unwrap] + * @template [TResult2=never] + * @param {ERef} x value to convert to a handled promise + * @param {(value: Unwrap) => ERef} [onfulfilled] + * @param {(reason: any) => ERef} [onrejected] + * @returns {Promise} * @readonly */ - when: (x, onfulfilled, onrejected) => - HandledPromise.resolve(x).then( - ...trackTurns([onfulfilled, onrejected]), - ), + when: (x, onfulfilled, onrejected) => { + const unwrapped = resolve(unwrap(x)); + if (onfulfilled == null && onrejected == null) { + return unwrapped; + } + return unwrapped.then( + ...trackTurns(/** @type {const} */ ([onfulfilled, onrejected])), + ); + }, }, + additional, ), ); }; @@ -244,21 +287,12 @@ export default makeE; /** @typedef {ReturnType} EProxy */ -/** - * Creates a type that accepts both near and marshalled references that were - * returned from `Remotable` or `Far`, and also promises for such references. - * - * @template Primary The type of the primary reference. - * @template [Local=DataOnly] The local properties of the object. - * @typedef {ERef>} FarRef - */ - /** * `DataOnly` means to return a record type `T2` consisting only of * properties that are *not* functions. * * @template T The type to be filtered. - * @typedef {Omit>} DataOnly + * @typedef {Omit>} DataOnly */ /** @@ -268,7 +302,7 @@ export default makeE; */ /** - * @template {import('./types').Callable} T + * @template {Callable} T * @typedef {( * ReturnType extends PromiseLike // if function returns a promise * ? T // return the function @@ -279,7 +313,7 @@ export default makeE; /** * @template T * @typedef {{ - * readonly [P in keyof T]: T[P] extends import('./types').Callable + * readonly [P in keyof T]: T[P] extends Callable * ? ECallable * : never; * }} EMethods @@ -295,14 +329,14 @@ export default makeE; */ /** - * @template {import('./types').Callable} T + * @template {Callable} T * @typedef {(...args: Parameters) => Promise} ESendOnlyCallable */ /** * @template T * @typedef {{ - * readonly [P in keyof T]: T[P] extends import('./types').Callable + * readonly [P in keyof T]: T[P] extends Callable * ? ESendOnlyCallable * : never; * }} ESendOnlyMethods @@ -311,7 +345,7 @@ export default makeE; /** * @template T * @typedef {( - * T extends import('./types').Callable + * T extends Callable * ? ESendOnlyCallable & ESendOnlyMethods> * : ESendOnlyMethods> * )} ESendOnlyCallableOrMethods @@ -320,7 +354,7 @@ export default makeE; /** * @template T * @typedef {( - * T extends import('./types').Callable + * T extends Callable * ? ECallable & EMethods> * : EMethods> * )} ECallableOrMethods @@ -347,9 +381,9 @@ export default makeE; * * @template T * @typedef {( - * T extends import('./types').Callable + * T extends Callable * ? (...args: Parameters) => ReturnType // a root callable, no methods - * : Pick> // any callable methods + * : Pick> // any callable methods * )} PickCallable */ @@ -358,23 +392,32 @@ export default makeE; * * @template T * @typedef {( - * T extends import('./types').RemotableBrand // if a given T is some remote interface R + * T extends import('@endo/eventual-send').RemotableBrand // if a given T is some remote interface R * ? PickCallable // then return the callable properties of R - * : Awaited extends import('./types').RemotableBrand // otherwise, if the final resolution of T is some remote interface R + * : Awaited extends import('@endo/eventual-send').RemotableBrand // otherwise, if the final resolution of T is some remote interface R * ? PickCallable // then return the callable properties of R + * : Awaited extends import('./types').Whenable + * ? RemoteFunctions // then extract the remotable functions of U * : T extends PromiseLike // otherwise, if T is a promise * ? Awaited // then return resolved value T * : T // otherwise, return T * )} RemoteFunctions */ +/** + * @template T + * @typedef {Awaited extends import('./types').Whenable ? Unwrap : Awaited} Unwrap + */ + /** * @template T * @typedef {( - * T extends import('./types').RemotableBrand + * T extends import('@endo/eventual-send').RemotableBrand * ? L - * : Awaited extends import('./types').RemotableBrand + * : Awaited extends import('@endo/eventual-send').RemotableBrand * ? L + * : Awaited extends import('./types').Whenable + * ? LocalRecord * : T extends PromiseLike * ? Awaited * : T @@ -385,7 +428,7 @@ export default makeE; * @template [R = unknown] * @typedef {{ * promise: Promise; - * settler: import('./types').Settler; + * settler: import('@endo/eventual-send').Settler; * }} EPromiseKit */ @@ -395,11 +438,11 @@ export default makeE; * * @template T * @typedef {( - * T extends import('./types').Callable + * T extends Callable * ? (...args: Parameters) => ERef>>> - * : T extends Record + * : T extends Record * ? { - * [K in keyof T]: T[K] extends import('./types').Callable + * [K in keyof T]: T[K] extends Callable * ? (...args: Parameters) => ERef>>> * : T[K]; * } diff --git a/packages/whenable/src/message-breakpoints.js b/packages/whenable/src/message-breakpoints.js new file mode 100644 index 00000000000..0278591f101 --- /dev/null +++ b/packages/whenable/src/message-breakpoints.js @@ -0,0 +1,179 @@ +import { getEnvironmentOption } from '@endo/env-options'; + +const { quote: q, Fail } = assert; + +const { hasOwn, freeze, entries } = Object; + +/** + * @typedef {string | '*'} MatchStringTag + * A star `'*'` matches any recipient. Otherwise, the string is + * matched against the value of a recipient's `@@toStringTag` + * after stripping out any leading `'Alleged: '` or `'DebugName: '` + * prefix. For objects defined with `Far` this is the first argument, + * known as the `farName`. For exos, this is the tag. + */ +/** + * @typedef {string | '*'} MatchMethodName + * A star `'*'` matches any method name. Otherwise, the string is + * matched against the method name. Currently, this is only an exact match. + * However, beware that we may introduce a string syntax for + * symbol method names. + */ +/** + * @typedef {number | '*'} MatchCountdown + * A star `'*'` will always breakpoint. Otherwise, the string + * must be a non-negative integer. Once that is zero, always breakpoint. + * Otherwise decrement by one each time it matches until it reaches zero. + * In other words, the countdown represents the number of + * breakpoint occurrences to skip before actually breakpointing. + */ + +/** + * This is the external JSON representation, in which + * - the outer property name is the class-like tag or '*', + * - the inner property name is the method name or '*', + * - the value is a non-negative integer countdown or '*'. + * + * @typedef {Record>} MessageBreakpoints + */ + +/** + * This is the internal JSON representation, in which + * - the outer property name is the method name or '*', + * - the inner property name is the class-like tag or '*', + * - the value is a non-negative integer countdown or '*'. + * + * @typedef {Record>} BreakpointTable + */ + +/** + * @typedef {object} MessageBreakpointTester + * @property {() => MessageBreakpoints} getBreakpoints + * @property {(newBreakpoints?: MessageBreakpoints) => void} setBreakpoints + * @property {( + * recipient: object, + * methodName: string | symbol | undefined + * ) => boolean} shouldBreakpoint + */ + +/** + * @param {any} val + * @returns {val is Record} + */ +const isJSONRecord = val => + typeof val === 'object' && val !== null && !Array.isArray(val); + +/** + * Return `tag` after stripping off any `'Alleged: '` or `'DebugName: '` + * prefix if present. + * ```js + * simplifyTag('Alleged: moola issuer') === 'moola issuer' + * ``` + * If there are multiple such prefixes, only the outer one is removed. + * + * @param {string} tag + * @returns {string} + */ +const simplifyTag = tag => { + for (const prefix of ['Alleged: ', 'DebugName: ']) { + if (tag.startsWith(prefix)) { + return tag.slice(prefix.length); + } + } + return tag; +}; + +/** + * @param {string} optionName + * @returns {MessageBreakpointTester | undefined} + */ +export const makeMessageBreakpointTester = optionName => { + let breakpoints = JSON.parse(getEnvironmentOption(optionName, 'null')); + + if (breakpoints === null) { + return undefined; + } + + /** @type {BreakpointTable} */ + let breakpointsTable; + + const getBreakpoints = () => breakpoints; + freeze(getBreakpoints); + + const setBreakpoints = (newBreakpoints = breakpoints) => { + isJSONRecord(newBreakpoints) || + Fail`Expected ${q(optionName)} option to be a JSON breakpoints record`; + + /** @type {BreakpointTable} */ + // @ts-expect-error confused by __proto__ + const newBreakpointsTable = { __proto__: null }; + + for (const [tag, methodBPs] of entries(newBreakpoints)) { + tag === simplifyTag(tag) || + Fail`Just use simple tag ${q(simplifyTag(tag))} rather than ${q(tag)}`; + isJSONRecord(methodBPs) || + Fail`Expected ${q(optionName)} option's ${q( + tag, + )} to be a JSON methods breakpoints record`; + for (const [methodName, count] of entries(methodBPs)) { + count === '*' || + (typeof count === 'number' && + Number.isSafeInteger(count) && + count >= 0) || + Fail`Expected ${q(optionName)} option's ${q(tag)}.${q( + methodName, + )} to be "*" or a non-negative integer`; + + const classBPs = hasOwn(newBreakpointsTable, methodName) + ? newBreakpointsTable[methodName] + : (newBreakpointsTable[methodName] = { + // @ts-expect-error confused by __proto__ + __proto__: null, + }); + classBPs[tag] = count; + } + } + breakpoints = newBreakpoints; + breakpointsTable = newBreakpointsTable; + }; + freeze(setBreakpoints); + + const shouldBreakpoint = (recipient, methodName) => { + if (methodName === undefined || methodName === null) { + // TODO enable function breakpointing + return false; + } + const classBPs = breakpointsTable[methodName] || breakpointsTable['*']; + if (classBPs === undefined) { + return false; + } + let tag = simplifyTag(recipient[Symbol.toStringTag]); + let count = classBPs[tag]; + if (count === undefined) { + tag = '*'; + count = classBPs[tag]; + if (count === undefined) { + return false; + } + } + if (count === '*') { + return true; + } + if (count === 0) { + return true; + } + assert(typeof count === 'number' && count >= 1); + classBPs[tag] = count - 1; + return false; + }; + freeze(shouldBreakpoint); + + const breakpointTester = freeze({ + getBreakpoints, + setBreakpoints, + shouldBreakpoint, + }); + breakpointTester.setBreakpoints(); + return breakpointTester; +}; +freeze(makeMessageBreakpointTester); diff --git a/packages/whenable/src/module.js b/packages/whenable/src/module.js index 82cc0470745..ab2e9929a21 100644 --- a/packages/whenable/src/module.js +++ b/packages/whenable/src/module.js @@ -1,7 +1,9 @@ +/* global globalThis */ // @ts-check -import { prepareWhen } from './when.js'; +import { makeWhen } from './when.js'; import { prepareWhenableKits } from './whenable.js'; import { prepareWatch } from './watch.js'; +import makeE from './E.js'; /** * @param {import('@agoric/base-zone').Zone} zone @@ -12,13 +14,20 @@ import { prepareWatch } from './watch.js'; export const wrappedPrepareWhenableModule = (zone, powers) => { const { rejectionMeansRetry = _reason => false, watchPromise } = powers || {}; const { makeWhenableKit, makeWhenablePromiseKit } = prepareWhenableKits(zone); - const when = prepareWhen(zone, makeWhenablePromiseKit, rejectionMeansRetry); + const when = makeWhen(makeWhenablePromiseKit, rejectionMeansRetry); const watch = prepareWatch( zone, makeWhenableKit, watchPromise, rejectionMeansRetry, ); - return harden({ watch, when, makeWhenableKit, makeWhenablePromiseKit }); + const E = makeE( + globalThis.HandledPromise, + harden({ + unwrap: when, + additional: { watch }, + }), + ); + return harden({ E, when, watch, makeWhenableKit }); }; harden(wrappedPrepareWhenableModule); diff --git a/packages/whenable/src/track-turns.js b/packages/whenable/src/track-turns.js new file mode 100644 index 00000000000..06b3d324e65 --- /dev/null +++ b/packages/whenable/src/track-turns.js @@ -0,0 +1,113 @@ +/* global globalThis */ +import { + getEnvironmentOption, + environmentOptionsListHas, +} from '@endo/env-options'; + +// NOTE: We can't import these because they're not in scope before lockdown. +// import { assert, details as X, Fail } from '@agoric/assert'; + +// WARNING: Global Mutable State! +// This state is communicated to `assert` that makes it available to the +// causal console, which affects the console log output. Normally we +// regard the ability to see console log output as a meta-level privilege +// analogous to the ability to debug. Aside from that, this module should +// not have any observably mutable state. + +let hiddenPriorError; +let hiddenCurrentTurn = 0; +let hiddenCurrentEvent = 0; + +// Turn on if you seem to be losing error logging at the top of the event loop +const VERBOSE = environmentOptionsListHas('DEBUG', 'track-turns'); + +// Track-turns is disabled by default and can be enabled by an environment +// option. +const ENABLED = + getEnvironmentOption('TRACK_TURNS', 'disabled', ['enabled']) === 'enabled'; + +// We hoist the following functions out of trackTurns() to discourage the +// closures from holding onto 'args' or 'func' longer than necessary, +// which we've seen cause HandledPromise arguments to be retained for +// a surprisingly long time. + +const addRejectionNote = detailsNote => reason => { + if (reason instanceof Error) { + assert.note(reason, detailsNote); + } + if (VERBOSE) { + console.log('REJECTED at top of event loop', reason); + } +}; + +const wrapFunction = + (func, sendingError, X) => + (...args) => { + hiddenPriorError = sendingError; + hiddenCurrentTurn += 1; + hiddenCurrentEvent = 0; + try { + let result; + try { + result = func(...args); + } catch (err) { + if (err instanceof Error) { + assert.note( + err, + X`Thrown from: ${hiddenPriorError}:${hiddenCurrentTurn}.${hiddenCurrentEvent}`, + ); + } + if (VERBOSE) { + console.log('THROWN to top of event loop', err); + } + throw err; + } + // Must capture this now, not when the catch triggers. + const detailsNote = X`Rejection from: ${hiddenPriorError}:${hiddenCurrentTurn}.${hiddenCurrentEvent}`; + Promise.resolve(result).catch(addRejectionNote(detailsNote)); + return result; + } finally { + hiddenPriorError = undefined; + } + }; + +/** + * Given a list of `TurnStarterFn`s, returns a list of `TurnStarterFn`s whose + * `this`-free call behaviors are not observably different to those that + * cannot see console output. The only purpose is to cause additional + * information to appear on the console. + * + * The call to `trackTurns` is itself a sending event, that occurs in some call + * stack in some turn number at some event number within that turn. Each call + * to any of the returned `TurnStartFn`s is a receiving event that begins a new + * turn. This sending event caused each of those receiving events. + * + * @template {TurnStarterFn[]} T + * @param {T} funcs + * @returns {T} + */ +export const trackTurns = funcs => { + if (!ENABLED || typeof globalThis === 'undefined' || !globalThis.assert) { + return funcs; + } + const { details: X } = assert; + + hiddenCurrentEvent += 1; + const sendingError = Error( + `Event: ${hiddenCurrentTurn}.${hiddenCurrentEvent}`, + ); + if (hiddenPriorError !== undefined) { + assert.note(sendingError, X`Caused by: ${hiddenPriorError}`); + } + + return /** @type {T} */ ( + funcs.map(func => func && wrapFunction(func, sendingError, X)) + ); +}; + +/** + * An optional function that is not this-sensitive, expected to be called at + * bottom of stack to start a new turn. + * + * @typedef {((...args: any[]) => any) | undefined} TurnStarterFn + */ diff --git a/packages/whenable/src/types.js b/packages/whenable/src/types.js index 45e44938764..66767a254c7 100644 --- a/packages/whenable/src/types.js +++ b/packages/whenable/src/types.js @@ -6,6 +6,20 @@ export {}; * @typedef {PromiseLike>} PromiseWhenable */ +/** + * @template T + * @typedef {import('./E').ERef} ERef + */ + +/** + * Creates a type that accepts both near and marshalled references that were + * returned from `Remotable` or `Far`, and also promises for such references. + * + * @template Primary The type of the primary reference. + * @template [Local=import('./E').DataOnly] The local properties of the object. + * @typedef {ERef>} FarRef + */ + /** * @template [T=any] * @typedef {object} WhenableV0 The first version of the whenable implementation @@ -13,17 +27,16 @@ export {}; * forward/backward compatibility. Create a new object and bump its version * number instead. * - * @property {() => Promise>} shorten Return a promise that - * attempts to unwrap all whenables in this promise chain, and return a promise - * for the final value. A rejection may indicate a temporary routing failure - * requiring a retry, otherwise that the decider of the terminal promise - * rejected it. + * @property {() => Promise} shorten Attempt to unwrap all whenables in this + * promise chain, returning a promise for the final value. A rejection may + * indicate a temporary routing failure requiring a retry, otherwise that the + * decider of the terminal promise rejected it. */ /** * @template [T=any] * @typedef {object} WhenablePayload - * @property {import('@endo/far').FarRef>} whenableV0 + * @property {import('@endo/eventual-send').FarRef>} whenableV0 */ /** diff --git a/packages/whenable/src/watch.js b/packages/whenable/src/watch.js index 34d5282582d..179a119ba87 100644 --- a/packages/whenable/src/watch.js +++ b/packages/whenable/src/watch.js @@ -1,8 +1,7 @@ // @ts-check -import { E } from '@endo/far'; import { M } from '@endo/patterns'; -import { getWhenablePayload, unwrapPromise } from './whenable-utils.js'; +import { getWhenablePayload, unwrapPromise, basicE } from './whenable-utils.js'; const { Fail } = assert; @@ -57,7 +56,7 @@ const watchPromiseShim = (p, watcher, ...watcherArgs) => { onFulfilled || onRejected || Fail`promise watcher must implement at least one handler method`; - void E.when(p, onFulfilled, onRejected); + void basicE.when(p, onFulfilled, onRejected); }; /** @@ -73,9 +72,9 @@ const makeWatchWhenable = let promise; const payload = getWhenablePayload(specimen); if (payload) { - promise = E(payload.whenableV0).shorten(); + promise = basicE(payload.whenableV0).shorten(); } else { - promise = E.resolve(specimen); + promise = basicE.resolve(specimen); } watchPromise(promise, promiseWatcher); }; @@ -204,11 +203,14 @@ export const prepareWatch = ( ); /** - * @template T - * @param {any} specimenP - * @param {import('./types.js').Watcher} [watcher] + * @template [T=any] + * @template [TResult1=T] + * @template [TResult2=T] + * @param {import('./types.js').ERef>} specimenP + * @param {import('./types.js').Watcher} [watcher] */ const watch = (specimenP, watcher) => { + /** @type {import('./types.js').WhenableKit} */ const { settler, whenable } = makeWhenableKit(); const { promiseWatcher, whenableSetter } = makeWatcherKit(settler, watcher); diff --git a/packages/whenable/src/when.js b/packages/whenable/src/when.js index 95da09c1b92..bf998b60fd3 100644 --- a/packages/whenable/src/when.js +++ b/packages/whenable/src/when.js @@ -1,45 +1,45 @@ // @ts-check -import { E } from '@endo/far'; - -import { unwrapPromise, getWhenablePayload } from './whenable-utils.js'; +import { unwrapPromise, getWhenablePayload, basicE } from './whenable-utils.js'; /** - * @param {import('@agoric/base-zone').Zone} zone * @param {() => import('./types.js').WhenablePromiseKit} makeWhenablePromiseKit * @param {(reason: any) => boolean} [rejectionMeansRetry] */ -export const prepareWhen = ( - zone, +export const makeWhen = ( makeWhenablePromiseKit, rejectionMeansRetry = () => false, ) => { /** - * @param {any} specimenP + * @template T + * @param {import('./types.js').ERef>} specimenP */ const when = specimenP => { + /** @type {import('./types.js').WhenablePromiseKit} */ const { settler, promise } = makeWhenablePromiseKit(); // Ensure we have a presence that won't be disconnected later. unwrapPromise(specimenP, async (specimen, payload) => { // Shorten the whenable chain without a watcher. await null; + /** @type {any} */ + let result = specimen; while (payload) { - specimen = await E(payload.whenableV0) + result = await basicE(payload.whenableV0) .shorten() .catch(e => { if (rejectionMeansRetry(e)) { // Shorten the same specimen to try again. - return specimen; + return result; } throw e; }); // Advance to the next whenable. - const nextPayload = getWhenablePayload(specimen); + const nextPayload = getWhenablePayload(result); if (!nextPayload) { break; } payload = nextPayload; } - settler.resolve(specimen); + settler.resolve(result); }).catch(e => settler.reject(e)); return promise; @@ -49,6 +49,6 @@ export const prepareWhen = ( return when; }; -harden(prepareWhen); +harden(makeWhen); -/** @typedef {ReturnType} When */ +/** @typedef {ReturnType} When */ diff --git a/packages/whenable/src/whenable-utils.js b/packages/whenable/src/whenable-utils.js index dc2bd9a3b31..c629b9612a8 100644 --- a/packages/whenable/src/whenable-utils.js +++ b/packages/whenable/src/whenable-utils.js @@ -1,5 +1,11 @@ // @ts-check -import { getTag } from '@endo/pass-style'; +import { E as basicE } from '@endo/eventual-send'; +import { getTag, passStyleOf } from '@endo/pass-style'; + +// TODO: `isPassable` should come from @endo/pass-style +import { isPassable } from '@agoric/base-zone'; + +export { basicE }; /** * @template T @@ -8,8 +14,8 @@ import { getTag } from '@endo/pass-style'; */ export const getWhenablePayload = specimen => { const isWhenable = - typeof specimen === 'object' && - specimen !== null && + isPassable(specimen) && + passStyleOf(specimen) === 'tagged' && getTag(specimen) === 'Whenable'; if (!isWhenable) { return undefined; diff --git a/packages/whenable/src/whenable.js b/packages/whenable/src/whenable.js index 5c8d63c3a45..04bf52adac6 100644 --- a/packages/whenable/src/whenable.js +++ b/packages/whenable/src/whenable.js @@ -101,28 +101,8 @@ export const prepareWhenableKits = zone => { const { settler, whenableV0 } = makeWhenableInternalsKit(); const whenable = makeTagged('Whenable', harden({ whenableV0 })); - /** - * It would be nice to fully type this, but TypeScript gives: - * TS1320: Type of 'await' operand must either be a valid promise or must not contain a callable 'then' member. - * @type {unknown} - */ - const whenablePromiseLike = { - then(onFulfilled, onRejected) { - // This promise behaviour is ephemeral. If you want a persistent - // subscription, you must use `when(p, watcher)`. - const { promise } = findCurrentKit(whenableV0); - return promise.then(onFulfilled, onRejected); - }, - catch(onRejected) { - const { promise } = findCurrentKit(whenableV0); - return promise.catch(onRejected); - }, - finally(onFinally) { - const { promise } = findCurrentKit(whenableV0); - return promise.finally(onFinally); - }, - }; - const promise = /** @type {Promise} */ (whenablePromiseLike); + /** @type {{ promise: Promise }} */ + const { promise } = findCurrentKit(whenableV0); return harden({ settler, whenable, promise }); }; return { makeWhenableKit, makeWhenablePromiseKit }; diff --git a/packages/whenable/test/test-disconnect.js b/packages/whenable/test/test-disconnect.js index 92185ee6ece..6a80b180cad 100644 --- a/packages/whenable/test/test-disconnect.js +++ b/packages/whenable/test/test-disconnect.js @@ -60,7 +60,7 @@ const testRetryOnDisconnect = zone => async t => { for await (const [final, ...plan] of PLANS) { t.log(`testing (plan=${plan}, watchWhenable=${watchWhenable})`); - /** @type {import('../src/types.js').Whenable} */ + /** @type {import('../src/types.js').Whenable} */ const whenable = makeTagged('Whenable', { whenableV0: makeTestWhenableV0(plan), }); diff --git a/packages/whenable/test/test-watch.js b/packages/whenable/test/test-watch.js index 441187c5826..6ac0f6ec4f0 100644 --- a/packages/whenable/test/test-watch.js +++ b/packages/whenable/test/test-watch.js @@ -15,7 +15,7 @@ const prepareAckWatcher = (zone, t) => { return 'fulfilled'; }, onRejected(reason) { - t.truthy(reason instanceof Error); + t.true(reason instanceof Error); return 'rejected'; }, }); From 871306bbb54e6db8b5c2d6cab0ff531899576852 Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Wed, 31 Jan 2024 12:57:29 -0600 Subject: [PATCH 16/19] chore(whenable): `prepareWhenableModule` -> `prepareWhenableTools` --- packages/internal/whenable.js | 20 ++++++++++++++----- packages/whenable/README.md | 8 ++++---- packages/whenable/src/E.js | 11 ++++++---- packages/whenable/src/index.js | 3 ++- packages/whenable/src/{module.js => tools.js} | 17 ++++------------ packages/whenable/test/test-disconnect.js | 4 ++-- packages/whenable/test/test-watch.js | 4 ++-- 7 files changed, 36 insertions(+), 31 deletions(-) rename packages/whenable/src/{module.js => tools.js} (62%) diff --git a/packages/internal/whenable.js b/packages/internal/whenable.js index f4bc9fd9df1..7e3a0325aa4 100644 --- a/packages/internal/whenable.js +++ b/packages/internal/whenable.js @@ -1,11 +1,18 @@ /* global globalThis */ -import { prepareWhenableModule as rawPrepareWhenableModule } from '@agoric/whenable'; +// @ts-check +import { prepareWhenableTools as rawPrepareWhenableTools } from '@agoric/whenable'; import { makeHeapZone } from '@agoric/base-zone/heap.js'; import { isUpgradeDisconnection } from './src/upgrade-api.js'; const vatData = /** @type {any} */ (globalThis).VatData; -/** @type {(p: PromiseLike, watcher: PromiseWatcher, ...args: unknown[]) => void} */ +/** + * @type {undefined | (( + * p: PromiseLike, + * watcher: import('@agoric/whenable/src/watch.js').PromiseWatcher, + * ...args: unknown[] + * ) => void)} + */ const watchPromise = vatData && vatData.watchPromise; /** @@ -20,9 +27,12 @@ export const defaultPowers = harden({ watchPromise, }); -export const prepareWhenableModule = (zone, powers = {}) => - rawPrepareWhenableModule(zone, { ...defaultPowers, ...powers }); +/** + * @type {typeof rawPrepareWhenableTools} + */ +export const prepareWhenableTools = (zone, powers = {}) => + rawPrepareWhenableTools(zone, { ...defaultPowers, ...powers }); -export const { E, watch, when, makeWhenableKit } = prepareWhenableModule( +export const { E, watch, when, makeWhenableKit } = prepareWhenableTools( makeHeapZone(), ); diff --git a/packages/whenable/README.md b/packages/whenable/README.md index df122566b0c..81988e26485 100644 --- a/packages/whenable/README.md +++ b/packages/whenable/README.md @@ -55,16 +55,16 @@ settler.resolve('now you know the answer'); ## Durability The whenable package supports Zones, which are used to integrate Agoric's vat -upgrade mechanism. To create durable whenable functions: +upgrade mechanism. To create whenable tools that deal with durable objects: ```js -import { prepareWhenableModule } from '@agoric/whenable'; +import { prepareWhenableTools } from '@agoric/internal/whenable.js'; import { makeDurableZone } from '@agoric/zone'; // Only do the following once at the start of a new vat incarnation: const zone = makeDurableZone(baggage); -const whenableZone = zone.subZone('WhenableModule'); -const { E, when, watch, makeWhenableKit } = prepareWhenableModule(whenableZone); +const whenableZone = zone.subZone('WhenableTools'); +const { E, when, watch, makeWhenableKit } = prepareWhenableTools(whenableZone); // Now the functions have been bound to the durable baggage. // Whenables and settlers you create can be saved in durable stores. diff --git a/packages/whenable/src/E.js b/packages/whenable/src/E.js index e0945fff365..fe7ca3d9047 100644 --- a/packages/whenable/src/E.js +++ b/packages/whenable/src/E.js @@ -184,13 +184,16 @@ const resolve = x => HandledPromise.resolve(x); * @template [A={}] * @template {(x: any) => Promise} [U=(x: any) => Promise] * @param {import('@endo/eventual-send').HandledPromiseConstructor} HandledPromise - * @param {object} powers - * @param {U} powers.unwrap - * @param {A} powers.additional + * @param {object} [powers] + * @param {U} [powers.unwrap] + * @param {A} [powers.additional] */ const makeE = ( HandledPromise, - { additional = /** @type {A} */ ({}), unwrap = /** @type {U} */ (resolve) }, + { + additional = /** @type {A} */ ({}), + unwrap = /** @type {U} */ (resolve), + } = {}, ) => { return harden( assign( diff --git a/packages/whenable/src/index.js b/packages/whenable/src/index.js index 672080c7ee1..775f899f9af 100644 --- a/packages/whenable/src/index.js +++ b/packages/whenable/src/index.js @@ -1,5 +1,6 @@ // @ts-check -export * from './module.js'; +export * from './tools.js'; +export { default as makeE } from './E.js'; // eslint-disable-next-line import/export export * from './types.js'; diff --git a/packages/whenable/src/module.js b/packages/whenable/src/tools.js similarity index 62% rename from packages/whenable/src/module.js rename to packages/whenable/src/tools.js index ab2e9929a21..21ce492a3aa 100644 --- a/packages/whenable/src/module.js +++ b/packages/whenable/src/tools.js @@ -1,9 +1,7 @@ -/* global globalThis */ // @ts-check import { makeWhen } from './when.js'; import { prepareWhenableKits } from './whenable.js'; import { prepareWatch } from './watch.js'; -import makeE from './E.js'; /** * @param {import('@agoric/base-zone').Zone} zone @@ -11,8 +9,8 @@ import makeE from './E.js'; * @param {(reason: any) => boolean} [powers.rejectionMeansRetry] * @param {(p: PromiseLike, watcher: import('./watch.js').PromiseWatcher, ...args: unknown[]) => void} [powers.watchPromise] */ -export const wrappedPrepareWhenableModule = (zone, powers) => { - const { rejectionMeansRetry = _reason => false, watchPromise } = powers || {}; +export const prepareWhenableTools = (zone, powers) => { + const { rejectionMeansRetry = () => false, watchPromise } = powers || {}; const { makeWhenableKit, makeWhenablePromiseKit } = prepareWhenableKits(zone); const when = makeWhen(makeWhenablePromiseKit, rejectionMeansRetry); const watch = prepareWatch( @@ -21,13 +19,6 @@ export const wrappedPrepareWhenableModule = (zone, powers) => { watchPromise, rejectionMeansRetry, ); - const E = makeE( - globalThis.HandledPromise, - harden({ - unwrap: when, - additional: { watch }, - }), - ); - return harden({ E, when, watch, makeWhenableKit }); + return harden({ when, watch, makeWhenableKit }); }; -harden(wrappedPrepareWhenableModule); +harden(prepareWhenableTools); diff --git a/packages/whenable/test/test-disconnect.js b/packages/whenable/test/test-disconnect.js index 6a80b180cad..d7e24172772 100644 --- a/packages/whenable/test/test-disconnect.js +++ b/packages/whenable/test/test-disconnect.js @@ -3,7 +3,7 @@ import test from 'ava'; import { makeHeapZone } from '@agoric/base-zone/heap.js'; import { makeTagged } from '@endo/pass-style'; -import { wrappedPrepareWhenableModule } from '../src/module.js'; +import { prepareWhenableTools } from '../src/tools.js'; /** * @param {import('@agoric/base-zone').Zone} zone @@ -12,7 +12,7 @@ import { wrappedPrepareWhenableModule } from '../src/module.js'; const testRetryOnDisconnect = zone => async t => { const rejectionMeansRetry = e => e && e.message === 'disconnected'; - const { watch, when } = wrappedPrepareWhenableModule(zone, { + const { watch, when } = prepareWhenableTools(zone, { rejectionMeansRetry, }); const makeTestWhenableV0 = zone.exoClass( diff --git a/packages/whenable/test/test-watch.js b/packages/whenable/test/test-watch.js index 6ac0f6ec4f0..9325ceb4113 100644 --- a/packages/whenable/test/test-watch.js +++ b/packages/whenable/test/test-watch.js @@ -2,7 +2,7 @@ import test from 'ava'; import { makeHeapZone } from '@agoric/base-zone/heap.js'; -import { prepareWhenableModule } from '../src/module.js'; +import { prepareWhenableTools } from '../src/tools.js'; /** * @param {import('@agoric/base-zone').Zone} zone @@ -23,7 +23,7 @@ const prepareAckWatcher = (zone, t) => { const runTests = async t => { const zone = makeHeapZone(); - const { watch, when, makeWhenableKit } = prepareWhenableModule(zone); + const { watch, when, makeWhenableKit } = prepareWhenableTools(zone); const makeAckWatcher = prepareAckWatcher(zone, t); const packet = harden({ portId: 'port-1', channelId: 'channel-1' }); From 9dfa8610c65a30ef33ddf2c67134ec553c6fb76d Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Thu, 1 Feb 2024 17:44:32 -0600 Subject: [PATCH 17/19] chore(vat-data): adopt `@agoric/internal/whenable.js` --- packages/internal/package.json | 1 - packages/internal/whenable.js | 38 -------------- packages/vat-data/package.json | 5 +- packages/vat-data/src/vat-data-bindings.js | 26 +++++----- .../test/test-whenable.js | 18 +++---- packages/vat-data/whenable.js | 52 +++++++++++++++++++ packages/whenable/README.md | 33 +++++++----- packages/whenable/test/test-disconnect.js | 11 ++-- 8 files changed, 101 insertions(+), 83 deletions(-) delete mode 100644 packages/internal/whenable.js rename packages/{internal => vat-data}/test/test-whenable.js (55%) create mode 100644 packages/vat-data/whenable.js diff --git a/packages/internal/package.json b/packages/internal/package.json index e16aada8c75..f45ee3367e9 100755 --- a/packages/internal/package.json +++ b/packages/internal/package.json @@ -22,7 +22,6 @@ "dependencies": { "@agoric/assert": "^0.6.0", "@agoric/base-zone": "^0.1.0", - "@agoric/whenable": "^0.1.0", "@endo/common": "^1.0.2", "@endo/far": "^1.0.2", "@endo/init": "^1.0.2", diff --git a/packages/internal/whenable.js b/packages/internal/whenable.js deleted file mode 100644 index 7e3a0325aa4..00000000000 --- a/packages/internal/whenable.js +++ /dev/null @@ -1,38 +0,0 @@ -/* global globalThis */ -// @ts-check -import { prepareWhenableTools as rawPrepareWhenableTools } from '@agoric/whenable'; -import { makeHeapZone } from '@agoric/base-zone/heap.js'; -import { isUpgradeDisconnection } from './src/upgrade-api.js'; - -const vatData = /** @type {any} */ (globalThis).VatData; - -/** - * @type {undefined | (( - * p: PromiseLike, - * watcher: import('@agoric/whenable/src/watch.js').PromiseWatcher, - * ...args: unknown[] - * ) => void)} - */ -const watchPromise = vatData && vatData.watchPromise; - -/** - * Return truthy if a rejection reason should result in a retry. - * @param {any} reason - * @returns {boolean} - */ -const rejectionMeansRetry = reason => isUpgradeDisconnection(reason); - -export const defaultPowers = harden({ - rejectionMeansRetry, - watchPromise, -}); - -/** - * @type {typeof rawPrepareWhenableTools} - */ -export const prepareWhenableTools = (zone, powers = {}) => - rawPrepareWhenableTools(zone, { ...defaultPowers, ...powers }); - -export const { E, watch, when, makeWhenableKit } = prepareWhenableTools( - makeHeapZone(), -); diff --git a/packages/vat-data/package.json b/packages/vat-data/package.json index 879d47e41ff..9be7d16df35 100644 --- a/packages/vat-data/package.json +++ b/packages/vat-data/package.json @@ -20,12 +20,15 @@ "license": "Apache-2.0", "dependencies": { "@agoric/assert": "^0.6.0", + "@agoric/base-zone": "^0.1.0", "@agoric/internal": "^0.3.2", "@agoric/store": "^0.9.2", - "@agoric/swingset-liveslots": "^0.10.2" + "@agoric/swingset-liveslots": "^0.10.2", + "@agoric/whenable": "^0.1.0" }, "devDependencies": { "@endo/init": "^1.0.2", + "@endo/far": "^1.0.2", "@endo/ses-ava": "^1.1.0", "ava": "^5.3.0", "tsd": "^0.30.4" diff --git a/packages/vat-data/src/vat-data-bindings.js b/packages/vat-data/src/vat-data-bindings.js index 97fa478a5f9..d5b214de7c0 100644 --- a/packages/vat-data/src/vat-data-bindings.js +++ b/packages/vat-data/src/vat-data-bindings.js @@ -12,20 +12,20 @@ if ('VatData' in globalThis) { // XXX this module has been known to get imported (transitively) in cases that // never use it so we make a version that will satisfy module resolution but // fail at runtime. - const unvailable = () => Fail`VatData unavailable`; + const unavailable = () => Fail`VatData unavailable`; VatDataGlobal = { - defineKind: unvailable, - defineKindMulti: unvailable, - defineDurableKind: unvailable, - defineDurableKindMulti: unvailable, - makeKindHandle: unvailable, - providePromiseWatcher: unvailable, - watchPromise: unvailable, - makeScalarBigMapStore: unvailable, - makeScalarBigWeakMapStore: unvailable, - makeScalarBigSetStore: unvailable, - makeScalarBigWeakSetStore: unvailable, - canBeDurable: unvailable, + defineKind: unavailable, + defineKindMulti: unavailable, + defineDurableKind: unavailable, + defineDurableKindMulti: unavailable, + makeKindHandle: unavailable, + providePromiseWatcher: unavailable, + watchPromise: unavailable, + makeScalarBigMapStore: unavailable, + makeScalarBigWeakMapStore: unavailable, + makeScalarBigSetStore: unavailable, + makeScalarBigWeakSetStore: unavailable, + canBeDurable: unavailable, }; } diff --git a/packages/internal/test/test-whenable.js b/packages/vat-data/test/test-whenable.js similarity index 55% rename from packages/internal/test/test-whenable.js rename to packages/vat-data/test/test-whenable.js index 92e339593ba..35630f1a172 100644 --- a/packages/internal/test/test-whenable.js +++ b/packages/vat-data/test/test-whenable.js @@ -1,33 +1,33 @@ // @ts-check import test from 'ava'; -import { E as basicE, Far } from '@endo/far'; +import { E, Far } from '@endo/far'; -import { E, makeWhenableKit } from '../whenable.js'; +import { V, makeWhenableKit } from '../whenable.js'; test('heap messages', async t => { const greeter = Far('Greeter', { hello: /** @param {string} name */ name => `Hello, ${name}!`, }); - /** @type {import('@agoric/whenable').WhenableKit} */ + /** @type {ReturnType>} */ const { whenable, settler } = makeWhenableKit(); - const retP = E(whenable).hello('World'); + const retP = V(whenable).hello('World'); settler.resolve(greeter); - // Happy path: E(whenable)[method](...args) calls the method. + // Happy path: WE(whenable)[method](...args) calls the method. t.is(await retP, 'Hello, World!'); - // Sad path: basicE(whenable)[method](...args) rejects. + // Sad path: E(whenable)[method](...args) rejects. await t.throwsAsync( // @ts-expect-error hello is not accessible via basicE - () => basicE(whenable).hello('World'), + () => E(whenable).hello('World'), { message: /target has no method "hello"/, }, ); - // Happy path: await E.when unwraps the whenable. - t.is(await E.when(whenable), greeter); + // Happy path: await WE.when unwraps the whenable. + t.is(await V.when(whenable), greeter); // Sad path: await by itself gives the raw whenable. const w = await whenable; diff --git a/packages/vat-data/whenable.js b/packages/vat-data/whenable.js new file mode 100644 index 00000000000..2bd5d9c5354 --- /dev/null +++ b/packages/vat-data/whenable.js @@ -0,0 +1,52 @@ +/* global globalThis */ +// @ts-check +import { + makeE, + prepareWhenableTools as rawPrepareWhenableTools, +} from '@agoric/whenable'; +import { makeHeapZone } from '@agoric/base-zone/heap.js'; +import { isUpgradeDisconnection } from '@agoric/internal/src/upgrade-api.js'; + +/** @type {any} */ +const vatData = globalThis.VatData; + +/** + * Manually-extracted watchPromise so we don't accidentally get the 'unavailable' + * version. If it is `undefined`, `@agoric/whenable` will shim it. + * @type {undefined | (( + * p: Promise, + * watcher: import('@agoric/whenable/src/watch.js').PromiseWatcher, + * ...args: unknown[] + * ) => void)} + */ +const watchPromise = vatData && vatData.watchPromise; + +/** + * Return truthy if a rejection reason should result in a retry. + * @param {any} reason + * @returns {boolean} + */ +const rejectionMeansRetry = reason => isUpgradeDisconnection(reason); + +export const defaultPowers = harden({ + rejectionMeansRetry, + watchPromise, +}); + +/** + * @type {typeof rawPrepareWhenableTools} + */ +export const prepareWhenableTools = (zone, powers = {}) => + rawPrepareWhenableTools(zone, { ...defaultPowers, ...powers }); + +export const { watch, when, makeWhenableKit } = prepareWhenableTools( + makeHeapZone(), +); + +/** + * An whenable-shortening E. CAVEAT: This produces long-lived ephemeral + * promises that encapsulate the shortening behaviour, and so provides no way + * for `watch` to durably shorten. Use the standard `import('@endo/far').E` if + * you need to `watch` its resulting promises. + */ +export const V = makeE(globalThis.HandledPromise, { unwrap: when }); diff --git a/packages/whenable/README.md b/packages/whenable/README.md index 81988e26485..2588d715b44 100644 --- a/packages/whenable/README.md +++ b/packages/whenable/README.md @@ -9,8 +9,8 @@ If your vat is a consumer of promises that are unexpectedly fulfilling to a When ```js import { E } from '@endo/far'; -const a = await w; -const b = await E(w).something(...args); +const a = await w1; +const b = await E(w2).something(...args); console.log('Here they are:', { a, b }); ``` @@ -26,24 +26,26 @@ Here they are: { } ``` -you can use the exported `E` and change the `await w` into `await E.when(w)` in -order to convert a chain of whenables to a promise for its final settlement, and -to do implicit unwrapping of results that are whenables: +On Agoric, you can use `V` exported from `@agoric/vat-data/whenable.js`, which +converts a chain of promises and whenables to a promise for its final +fulfilment, by unwrapping any intermediate whenables: ```js -import { E } from '@agoric/internal/whenable.js'; +import { V as E } from '@agoric/vat-data/whenable.js'; [...] -const a = await E.when(w); -const b = await E(w).something(...args); +const a = await E.when(w1); +const b = await E(w2).something(...args); // Produces the expected results. ``` ## Whenable Producer -Use the following to create and resolve a whenable: +On Agoric, use the following to create and resolve a whenable: ```js -import { makeWhenableKit } from '@agoric/whenable/heap.js'; +// CAVEAT: `V` uses internal ephemeral promises, so while it is convenient, +// it cannot be used by upgradable vats. See "Durability" below: +import { V as E, makeWhenableKit } from '@agoric/vat-data/whenable.js'; [...] const { settler, whenable } = makeWhenableKit(); // Send whenable to a potentially different vat. @@ -55,16 +57,21 @@ settler.resolve('now you know the answer'); ## Durability The whenable package supports Zones, which are used to integrate Agoric's vat -upgrade mechanism. To create whenable tools that deal with durable objects: +upgrade mechanism and `watchPromise`. To create whenable tools that deal with +durable objects: ```js -import { prepareWhenableTools } from '@agoric/internal/whenable.js'; +// NOTE: Cannot use `V` as it has non-durable internal state when unwrapping +// whenables. Instead, use the default whenable-exposing `E` with the `watch` +// operator. +import { E } from '@endo/far'; +import { prepareWhenableTools } from '@agoric/vat-data/whenable.js'; import { makeDurableZone } from '@agoric/zone'; // Only do the following once at the start of a new vat incarnation: const zone = makeDurableZone(baggage); const whenableZone = zone.subZone('WhenableTools'); -const { E, when, watch, makeWhenableKit } = prepareWhenableTools(whenableZone); +const { watch, makeWhenableKit } = prepareWhenableTools(whenableZone); // Now the functions have been bound to the durable baggage. // Whenables and settlers you create can be saved in durable stores. diff --git a/packages/whenable/test/test-disconnect.js b/packages/whenable/test/test-disconnect.js index d7e24172772..beade5b15a4 100644 --- a/packages/whenable/test/test-disconnect.js +++ b/packages/whenable/test/test-disconnect.js @@ -5,11 +5,8 @@ import { makeHeapZone } from '@agoric/base-zone/heap.js'; import { makeTagged } from '@endo/pass-style'; import { prepareWhenableTools } from '../src/tools.js'; -/** - * @param {import('@agoric/base-zone').Zone} zone - * @returns {import('ava').ImplementationFn<[]>} - */ -const testRetryOnDisconnect = zone => async t => { +test('retry on disconnection', async t => { + const zone = makeHeapZone(); const rejectionMeansRetry = e => e && e.message === 'disconnected'; const { watch, when } = prepareWhenableTools(zone, { @@ -108,6 +105,4 @@ const testRetryOnDisconnect = zone => async t => { } } } -}; - -test('retry on disconnection', testRetryOnDisconnect(makeHeapZone())); +}); From b5885f7bcc049decf611e577d5a65a42e688d613 Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Thu, 8 Feb 2024 16:09:38 -0600 Subject: [PATCH 18/19] chore(vow): rename `Whenable` -> `Vow` --- .github/workflows/test-all-packages.yml | 4 +- packages/agoric-cli/src/sdk-package-names.js | 2 +- packages/vat-data/package.json | 2 +- packages/vat-data/test/test-vow.js | 36 ++++++ packages/vat-data/test/test-whenable.js | 36 ------ packages/vat-data/{whenable.js => vow.js} | 21 ++-- packages/vow/CHANGELOG.md | 0 packages/vow/README.md | 78 ++++++++++++ packages/{whenable => vow}/package.json | 2 +- packages/{whenable => vow}/src/E.js | 6 +- packages/{whenable => vow}/src/index.js | 0 .../src/message-breakpoints.js | 0 packages/{whenable => vow}/src/tools.js | 14 +-- packages/{whenable => vow}/src/track-turns.js | 0 packages/{whenable => vow}/src/types.js | 28 ++--- .../src/vow-utils.js} | 32 ++--- packages/vow/src/vow.js | 111 ++++++++++++++++++ packages/{whenable => vow}/src/watch.js | 62 +++++----- packages/{whenable => vow}/src/when.js | 20 ++-- .../{whenable => vow}/test/test-disconnect.js | 30 ++--- packages/{whenable => vow}/test/test-watch.js | 28 ++--- .../{whenable => vow}/tsconfig.build.json | 0 packages/{whenable => vow}/tsconfig.json | 2 +- packages/whenable/README.md | 78 ------------ packages/whenable/heap.js | 5 - packages/whenable/src/whenable.js | 111 ------------------ 26 files changed, 347 insertions(+), 361 deletions(-) create mode 100644 packages/vat-data/test/test-vow.js delete mode 100644 packages/vat-data/test/test-whenable.js rename packages/vat-data/{whenable.js => vow.js} (64%) create mode 100644 packages/vow/CHANGELOG.md create mode 100644 packages/vow/README.md rename packages/{whenable => vow}/package.json (97%) rename packages/{whenable => vow}/src/E.js (98%) rename packages/{whenable => vow}/src/index.js (100%) rename packages/{whenable => vow}/src/message-breakpoints.js (100%) rename packages/{whenable => vow}/src/tools.js (60%) rename packages/{whenable => vow}/src/track-turns.js (100%) rename packages/{whenable => vow}/src/types.js (63%) rename packages/{whenable/src/whenable-utils.js => vow/src/vow-utils.js} (50%) create mode 100644 packages/vow/src/vow.js rename packages/{whenable => vow}/src/watch.js (78%) rename packages/{whenable => vow}/src/when.js (65%) rename packages/{whenable => vow}/test/test-disconnect.js (73%) rename packages/{whenable => vow}/test/test-watch.js (55%) rename packages/{whenable => vow}/tsconfig.build.json (100%) rename packages/{whenable => vow}/tsconfig.json (82%) delete mode 100644 packages/whenable/README.md delete mode 100644 packages/whenable/heap.js delete mode 100644 packages/whenable/src/whenable.js diff --git a/.github/workflows/test-all-packages.yml b/.github/workflows/test-all-packages.yml index 98ffe2490ea..2db8320c36e 100644 --- a/.github/workflows/test-all-packages.yml +++ b/.github/workflows/test-all-packages.yml @@ -163,9 +163,9 @@ jobs: - name: yarn test (cosmic-proto) if: (success() || failure()) run: cd packages/cosmic-proto && yarn ${{ steps.vars.outputs.test }} | $TEST_COLLECT - - name: yarn test (whenable) + - name: yarn test (vow) if: (success() || failure()) - run: cd packages/whenable && yarn ${{ steps.vars.outputs.test }} | $TEST_COLLECT + run: cd packages/vow && yarn ${{ steps.vars.outputs.test }} | $TEST_COLLECT - name: notify on failure if: failure() && github.event_name != 'pull_request' uses: ./.github/actions/notify-status diff --git a/packages/agoric-cli/src/sdk-package-names.js b/packages/agoric-cli/src/sdk-package-names.js index 79784c1f02e..543a039398d 100644 --- a/packages/agoric-cli/src/sdk-package-names.js +++ b/packages/agoric-cli/src/sdk-package-names.js @@ -44,7 +44,7 @@ export default [ "@agoric/vm-config", "@agoric/wallet", "@agoric/wallet-backend", - "@agoric/whenable", + "@agoric/vow", "@agoric/xsnap", "@agoric/xsnap-lockdown", "@agoric/zoe", diff --git a/packages/vat-data/package.json b/packages/vat-data/package.json index 9be7d16df35..a6b794f30a3 100644 --- a/packages/vat-data/package.json +++ b/packages/vat-data/package.json @@ -24,7 +24,7 @@ "@agoric/internal": "^0.3.2", "@agoric/store": "^0.9.2", "@agoric/swingset-liveslots": "^0.10.2", - "@agoric/whenable": "^0.1.0" + "@agoric/vow": "^0.1.0" }, "devDependencies": { "@endo/init": "^1.0.2", diff --git a/packages/vat-data/test/test-vow.js b/packages/vat-data/test/test-vow.js new file mode 100644 index 00000000000..284d1f41c84 --- /dev/null +++ b/packages/vat-data/test/test-vow.js @@ -0,0 +1,36 @@ +// @ts-check +import test from 'ava'; +import { E, Far } from '@endo/far'; + +import { V, makeVowKit } from '../vow.js'; + +test('heap messages', async t => { + const greeter = Far('Greeter', { + hello: /** @param {string} name */ name => `Hello, ${name}!`, + }); + + /** @type {ReturnType>} */ + const { vow, settler } = makeVowKit(); + const retP = V(vow).hello('World'); + settler.resolve(greeter); + + // Happy path: WE(vow)[method](...args) calls the method. + t.is(await retP, 'Hello, World!'); + + // Sad path: E(vow)[method](...args) rejects. + await t.throwsAsync( + // @ts-expect-error hello is not accessible via basicE + () => E(vow).hello('World'), + { + message: /target has no method "hello"/, + }, + ); + + // Happy path: await WE.when unwraps the vow. + t.is(await V.when(vow), greeter); + + // Sad path: await by itself gives the raw vow. + const w = await vow; + t.not(w, greeter); + t.truthy(w.payload); +}); diff --git a/packages/vat-data/test/test-whenable.js b/packages/vat-data/test/test-whenable.js deleted file mode 100644 index 35630f1a172..00000000000 --- a/packages/vat-data/test/test-whenable.js +++ /dev/null @@ -1,36 +0,0 @@ -// @ts-check -import test from 'ava'; -import { E, Far } from '@endo/far'; - -import { V, makeWhenableKit } from '../whenable.js'; - -test('heap messages', async t => { - const greeter = Far('Greeter', { - hello: /** @param {string} name */ name => `Hello, ${name}!`, - }); - - /** @type {ReturnType>} */ - const { whenable, settler } = makeWhenableKit(); - const retP = V(whenable).hello('World'); - settler.resolve(greeter); - - // Happy path: WE(whenable)[method](...args) calls the method. - t.is(await retP, 'Hello, World!'); - - // Sad path: E(whenable)[method](...args) rejects. - await t.throwsAsync( - // @ts-expect-error hello is not accessible via basicE - () => E(whenable).hello('World'), - { - message: /target has no method "hello"/, - }, - ); - - // Happy path: await WE.when unwraps the whenable. - t.is(await V.when(whenable), greeter); - - // Sad path: await by itself gives the raw whenable. - const w = await whenable; - t.not(w, greeter); - t.truthy(w.payload); -}); diff --git a/packages/vat-data/whenable.js b/packages/vat-data/vow.js similarity index 64% rename from packages/vat-data/whenable.js rename to packages/vat-data/vow.js index 2bd5d9c5354..50b2c5e9f9f 100644 --- a/packages/vat-data/whenable.js +++ b/packages/vat-data/vow.js @@ -1,9 +1,6 @@ /* global globalThis */ // @ts-check -import { - makeE, - prepareWhenableTools as rawPrepareWhenableTools, -} from '@agoric/whenable'; +import { makeE, prepareVowTools as rawPrepareVowTools } from '@agoric/vow'; import { makeHeapZone } from '@agoric/base-zone/heap.js'; import { isUpgradeDisconnection } from '@agoric/internal/src/upgrade-api.js'; @@ -12,10 +9,10 @@ const vatData = globalThis.VatData; /** * Manually-extracted watchPromise so we don't accidentally get the 'unavailable' - * version. If it is `undefined`, `@agoric/whenable` will shim it. + * version. If it is `undefined`, `@agoric/vow` will shim it. * @type {undefined | (( * p: Promise, - * watcher: import('@agoric/whenable/src/watch.js').PromiseWatcher, + * watcher: import('@agoric/vow/src/watch.js').PromiseWatcher, * ...args: unknown[] * ) => void)} */ @@ -34,17 +31,15 @@ export const defaultPowers = harden({ }); /** - * @type {typeof rawPrepareWhenableTools} + * @type {typeof rawPrepareVowTools} */ -export const prepareWhenableTools = (zone, powers = {}) => - rawPrepareWhenableTools(zone, { ...defaultPowers, ...powers }); +export const prepareVowTools = (zone, powers = {}) => + rawPrepareVowTools(zone, { ...defaultPowers, ...powers }); -export const { watch, when, makeWhenableKit } = prepareWhenableTools( - makeHeapZone(), -); +export const { watch, when, makeVowKit } = prepareVowTools(makeHeapZone()); /** - * An whenable-shortening E. CAVEAT: This produces long-lived ephemeral + * An vow-shortening E. CAVEAT: This produces long-lived ephemeral * promises that encapsulate the shortening behaviour, and so provides no way * for `watch` to durably shorten. Use the standard `import('@endo/far').E` if * you need to `watch` its resulting promises. diff --git a/packages/vow/CHANGELOG.md b/packages/vow/CHANGELOG.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/vow/README.md b/packages/vow/README.md new file mode 100644 index 00000000000..a7c3f1c0a18 --- /dev/null +++ b/packages/vow/README.md @@ -0,0 +1,78 @@ +# Vows + +Native promises are not compatible with Agoric's durable stores, which means that on the Agoric platform, such promises disconnect their clients when their creator vat is upgraded. Vows are objects that represent promises that can be stored durably, this package also provides a `when` operator to allow clients to tolerate upgrades of vow-hosting vats, as well as a `watch` operator to subscribe to a vow in a way that survives upgrades of both the creator and subscribing client vats. + +## Vow Consumer + +If your vat is a consumer of promises that are unexpectedly fulfilling to a Vow (something like): + +```js +import { E } from '@endo/far'; + +const a = await w1; +const b = await E(w2).something(...args); +console.log('Here they are:', { a, b }); +``` + +Produces output like: +```console +Here they are: { + a: Object [Vow] { + payload: { vowV0: Object [Alleged: VowInternalsKit vowV0] {} } + }, + b: Object [Vow] { + payload: { vowV0: Object [Alleged: VowInternalsKit vowV0] {} } + } +} +``` + +On Agoric, you can use `V` exported from `@agoric/vat-data/vow.js`, which +converts a chain of promises and vows to a promise for its final +fulfilment, by unwrapping any intermediate vows: + +```js +import { V as E } from '@agoric/vat-data/vow.js'; +[...] +const a = await E.when(w1); +const b = await E(w2).something(...args); +// Produces the expected results. +``` + +## Vow Producer + +On Agoric, use the following to create and resolve a vow: + +```js +// CAVEAT: `V` uses internal ephemeral promises, so while it is convenient, +// it cannot be used by upgradable vats. See "Durability" below: +import { V as E, makeVowKit } from '@agoric/vat-data/vow.js'; +[...] +const { settler, vow } = makeVowKit(); +// Send vow to a potentially different vat. +E(outsideReference).performSomeMethod(vow); +// some time later... +settler.resolve('now you know the answer'); +``` + +## Durability + +The vow package supports Zones, which are used to integrate Agoric's vat +upgrade mechanism and `watchPromise`. To create vow tools that deal with +durable objects: + +```js +// NOTE: Cannot use `V` as it has non-durable internal state when unwrapping +// vows. Instead, use the default vow-exposing `E` with the `watch` +// operator. +import { E } from '@endo/far'; +import { prepareVowTools } from '@agoric/vat-data/vow.js'; +import { makeDurableZone } from '@agoric/zone'; + +// Only do the following once at the start of a new vat incarnation: +const zone = makeDurableZone(baggage); +const vowZone = zone.subZone('VowTools'); +const { watch, makeVowKit } = prepareVowTools(vowZone); + +// Now the functions have been bound to the durable baggage. +// Vows and settlers you create can be saved in durable stores. +``` diff --git a/packages/whenable/package.json b/packages/vow/package.json similarity index 97% rename from packages/whenable/package.json rename to packages/vow/package.json index 0522ffffa16..aa0dd02e36a 100755 --- a/packages/whenable/package.json +++ b/packages/vow/package.json @@ -1,5 +1,5 @@ { - "name": "@agoric/whenable", + "name": "@agoric/vow", "version": "0.1.0", "description": "Remote (shortening and disconnection-tolerant) Promise-likes", "type": "module", diff --git a/packages/whenable/src/E.js b/packages/vow/src/E.js similarity index 98% rename from packages/whenable/src/E.js rename to packages/vow/src/E.js index fe7ca3d9047..5b6f4049bab 100644 --- a/packages/whenable/src/E.js +++ b/packages/vow/src/E.js @@ -399,7 +399,7 @@ export default makeE; * ? PickCallable // then return the callable properties of R * : Awaited extends import('@endo/eventual-send').RemotableBrand // otherwise, if the final resolution of T is some remote interface R * ? PickCallable // then return the callable properties of R - * : Awaited extends import('./types').Whenable + * : Awaited extends import('./types').Vow * ? RemoteFunctions // then extract the remotable functions of U * : T extends PromiseLike // otherwise, if T is a promise * ? Awaited // then return resolved value T @@ -409,7 +409,7 @@ export default makeE; /** * @template T - * @typedef {Awaited extends import('./types').Whenable ? Unwrap : Awaited} Unwrap + * @typedef {Awaited extends import('./types').Vow ? Unwrap : Awaited} Unwrap */ /** @@ -419,7 +419,7 @@ export default makeE; * ? L * : Awaited extends import('@endo/eventual-send').RemotableBrand * ? L - * : Awaited extends import('./types').Whenable + * : Awaited extends import('./types').Vow * ? LocalRecord * : T extends PromiseLike * ? Awaited diff --git a/packages/whenable/src/index.js b/packages/vow/src/index.js similarity index 100% rename from packages/whenable/src/index.js rename to packages/vow/src/index.js diff --git a/packages/whenable/src/message-breakpoints.js b/packages/vow/src/message-breakpoints.js similarity index 100% rename from packages/whenable/src/message-breakpoints.js rename to packages/vow/src/message-breakpoints.js diff --git a/packages/whenable/src/tools.js b/packages/vow/src/tools.js similarity index 60% rename from packages/whenable/src/tools.js rename to packages/vow/src/tools.js index 21ce492a3aa..03ecfe94f04 100644 --- a/packages/whenable/src/tools.js +++ b/packages/vow/src/tools.js @@ -1,6 +1,6 @@ // @ts-check import { makeWhen } from './when.js'; -import { prepareWhenableKits } from './whenable.js'; +import { prepareVowKits } from './vow.js'; import { prepareWatch } from './watch.js'; /** @@ -9,16 +9,16 @@ import { prepareWatch } from './watch.js'; * @param {(reason: any) => boolean} [powers.rejectionMeansRetry] * @param {(p: PromiseLike, watcher: import('./watch.js').PromiseWatcher, ...args: unknown[]) => void} [powers.watchPromise] */ -export const prepareWhenableTools = (zone, powers) => { +export const prepareVowTools = (zone, powers) => { const { rejectionMeansRetry = () => false, watchPromise } = powers || {}; - const { makeWhenableKit, makeWhenablePromiseKit } = prepareWhenableKits(zone); - const when = makeWhen(makeWhenablePromiseKit, rejectionMeansRetry); + const { makeVowKit, makeVowPromiseKit } = prepareVowKits(zone); + const when = makeWhen(makeVowPromiseKit, rejectionMeansRetry); const watch = prepareWatch( zone, - makeWhenableKit, + makeVowKit, watchPromise, rejectionMeansRetry, ); - return harden({ when, watch, makeWhenableKit }); + return harden({ when, watch, makeVowKit }); }; -harden(prepareWhenableTools); +harden(prepareVowTools); diff --git a/packages/whenable/src/track-turns.js b/packages/vow/src/track-turns.js similarity index 100% rename from packages/whenable/src/track-turns.js rename to packages/vow/src/track-turns.js diff --git a/packages/whenable/src/types.js b/packages/vow/src/types.js similarity index 63% rename from packages/whenable/src/types.js rename to packages/vow/src/types.js index 66767a254c7..2646a28afcc 100644 --- a/packages/whenable/src/types.js +++ b/packages/vow/src/types.js @@ -3,7 +3,7 @@ export {}; /** * @template T - * @typedef {PromiseLike>} PromiseWhenable + * @typedef {PromiseLike>} PromiseVow */ /** @@ -22,12 +22,12 @@ export {}; /** * @template [T=any] - * @typedef {object} WhenableV0 The first version of the whenable implementation + * @typedef {object} VowV0 The first version of the vow implementation * object. CAVEAT: These methods must never be changed or added to, to provide * forward/backward compatibility. Create a new object and bump its version * number instead. * - * @property {() => Promise} shorten Attempt to unwrap all whenables in this + * @property {() => Promise} shorten Attempt to unwrap all vows in this * promise chain, returning a promise for the final value. A rejection may * indicate a temporary routing failure requiring a retry, otherwise that the * decider of the terminal promise rejected it. @@ -35,37 +35,37 @@ export {}; /** * @template [T=any] - * @typedef {object} WhenablePayload - * @property {import('@endo/eventual-send').FarRef>} whenableV0 + * @typedef {object} VowPayload + * @property {import('@endo/eventual-send').FarRef>} vowV0 */ /** * @template [T=any] * @typedef {import('@endo/pass-style').CopyTagged< - * 'Whenable', WhenablePayload - * >} Whenable + * 'Vow', VowPayload + * >} Vow */ /** * @template [T=any] * @typedef {{ - * whenable: Whenable, + * vow: Vow, * settler: Settler, - * }} WhenableKit + * }} VowKit */ /** * @template [T=any] * @typedef {{ - * whenable: Whenable, + * vow: Vow, * settler: Settler, * promise: Promise - * }} WhenablePromiseKit + * }} VowPromiseKit */ /** * @template [T=any] - * @typedef {{ resolve(value?: T | PromiseWhenable): void, reject(reason?: any): void }} Settler + * @typedef {{ resolve(value?: T | PromiseVow): void, reject(reason?: any): void }} Settler */ /** @@ -73,6 +73,6 @@ export {}; * @template [TResult1=T] * @template [TResult2=T] * @typedef {object} Watcher - * @property {(value: T) => Whenable | PromiseWhenable | TResult1} [onFulfilled] - * @property {(reason: any) => Whenable | PromiseWhenable | TResult2} [onRejected] + * @property {(value: T) => Vow | PromiseVow | TResult1} [onFulfilled] + * @property {(reason: any) => Vow | PromiseVow | TResult2} [onRejected] */ diff --git a/packages/whenable/src/whenable-utils.js b/packages/vow/src/vow-utils.js similarity index 50% rename from packages/whenable/src/whenable-utils.js rename to packages/vow/src/vow-utils.js index c629b9612a8..a532887debc 100644 --- a/packages/whenable/src/whenable-utils.js +++ b/packages/vow/src/vow-utils.js @@ -10,48 +10,48 @@ export { basicE }; /** * @template T * @param {any} specimen - * @returns {import('./types').WhenablePayload | undefined} + * @returns {import('./types').VowPayload | undefined} */ -export const getWhenablePayload = specimen => { - const isWhenable = +export const getVowPayload = specimen => { + const isVow = isPassable(specimen) && passStyleOf(specimen) === 'tagged' && - getTag(specimen) === 'Whenable'; - if (!isWhenable) { + getTag(specimen) === 'Vow'; + if (!isVow) { return undefined; } - const whenable = /** @type {import('./types').Whenable} */ ( + const vow = /** @type {import('./types').Vow} */ ( /** @type {unknown} */ (specimen) ); - return whenable.payload; + return vow.payload; }; /** A unique object identity just for internal use. */ -const ALREADY_WHENABLE = harden({}); +const ALREADY_VOW = harden({}); /** * @template T * @template U * @param {T} specimenP - * @param {(unwrapped: Awaited, payload?: import('./types').WhenablePayload) => U} cb + * @param {(unwrapped: Awaited, payload?: import('./types').VowPayload) => U} cb * @returns {Promise} */ export const unwrapPromise = async (specimenP, cb) => { - let payload = getWhenablePayload(specimenP); + let payload = getVowPayload(specimenP); - // Take exactly 1 turn to find the first whenable, if any. - const awaited = await (payload ? ALREADY_WHENABLE : specimenP); + // Take exactly 1 turn to find the first vow, if any. + const awaited = await (payload ? ALREADY_VOW : specimenP); /** @type {unknown} */ let unwrapped; - if (awaited === ALREADY_WHENABLE) { - // The fact that we have a whenable payload means it's not actually a + if (awaited === ALREADY_VOW) { + // The fact that we have a vow payload means it's not actually a // promise. unwrapped = specimenP; } else { - // Check if the awaited specimen is a whenable. + // Check if the awaited specimen is a vow. unwrapped = awaited; - payload = getWhenablePayload(unwrapped); + payload = getVowPayload(unwrapped); } return cb(/** @type {Awaited} */ (unwrapped), payload); diff --git a/packages/vow/src/vow.js b/packages/vow/src/vow.js new file mode 100644 index 00000000000..f157bcc9dd0 --- /dev/null +++ b/packages/vow/src/vow.js @@ -0,0 +1,111 @@ +// @ts-check +import { makePromiseKit } from '@endo/promise-kit'; +import { M } from '@endo/patterns'; +import { makeTagged } from '@endo/pass-style'; + +/** + * @param {import('@agoric/base-zone').Zone} zone + */ +export const prepareVowKits = zone => { + /** WeakMap */ + const vowV0ToEphemeral = new WeakMap(); + + /** + * Get the current incarnation's promise kit associated with a vowV0. + * + * @param {import('./types.js').VowPayload['vowV0']} vowV0 + * @returns {import('@endo/promise-kit').PromiseKit} + */ + const findCurrentKit = vowV0 => { + let pk = vowV0ToEphemeral.get(vowV0); + if (pk) { + return pk; + } + + pk = makePromiseKit(); + pk.promise.catch(() => {}); // silence unhandled rejection + vowV0ToEphemeral.set(vowV0, pk); + return pk; + }; + + /** + * @param {'resolve' | 'reject'} kind + * @param {import('./types.js').VowPayload['vowV0']} vowV0 + * @param {unknown} value + */ + const settle = (kind, vowV0, value) => { + const kit = findCurrentKit(vowV0); + const cb = kit[kind]; + if (!cb) { + return; + } + vowV0ToEphemeral.set(vowV0, harden({ promise: kit.promise })); + cb(value); + }; + + const makeVowInternalsKit = zone.exoClassKit( + 'VowInternalsKit', + { + vowV0: M.interface('VowV0', { + shorten: M.call().returns(M.promise()), + }), + settler: M.interface('Settler', { + resolve: M.call().optional(M.any()).returns(), + reject: M.call().optional(M.any()).returns(), + }), + }, + () => ({}), + { + vowV0: { + /** + * @returns {Promise} + */ + shorten() { + return findCurrentKit(this.facets.vowV0).promise; + }, + }, + settler: { + /** + * @param {any} [value] + */ + resolve(value) { + const { vowV0 } = this.facets; + settle('resolve', vowV0, value); + }, + /** + * @param {any} [reason] + */ + reject(reason) { + const { vowV0 } = this.facets; + settle('reject', vowV0, reason); + }, + }, + }, + ); + + /** + * @template T + * @returns {import('./types.js').VowKit} + */ + const makeVowKit = () => { + const { settler, vowV0 } = makeVowInternalsKit(); + const vow = makeTagged('Vow', harden({ vowV0 })); + return harden({ settler, vow }); + }; + + /** + * @template T + * @returns {import('./types.js').VowPromiseKit} + */ + const makeVowPromiseKit = () => { + const { settler, vowV0 } = makeVowInternalsKit(); + const vow = makeTagged('Vow', harden({ vowV0 })); + + /** @type {{ promise: Promise }} */ + const { promise } = findCurrentKit(vowV0); + return harden({ settler, vow, promise }); + }; + return { makeVowKit, makeVowPromiseKit }; +}; + +harden(prepareVowKits); diff --git a/packages/whenable/src/watch.js b/packages/vow/src/watch.js similarity index 78% rename from packages/whenable/src/watch.js rename to packages/vow/src/watch.js index 179a119ba87..58ed1046229 100644 --- a/packages/whenable/src/watch.js +++ b/packages/vow/src/watch.js @@ -1,7 +1,7 @@ // @ts-check import { M } from '@endo/patterns'; -import { getWhenablePayload, unwrapPromise, basicE } from './whenable-utils.js'; +import { getVowPayload, unwrapPromise, basicE } from './vow-utils.js'; const { Fail } = assert; @@ -62,7 +62,7 @@ const watchPromiseShim = (p, watcher, ...watcherArgs) => { /** * @param {typeof watchPromiseShim} watchPromise */ -const makeWatchWhenable = +const makeWatchVow = watchPromise => /** * @param {any} specimen @@ -70,9 +70,9 @@ const makeWatchWhenable = */ (specimen, promiseWatcher) => { let promise; - const payload = getWhenablePayload(specimen); + const payload = getVowPayload(specimen); if (payload) { - promise = basicE(payload.whenableV0).shorten(); + promise = basicE(payload.vowV0).shorten(); } else { promise = basicE.resolve(specimen); } @@ -107,15 +107,15 @@ const settle = (settler, watcher, wcb, value) => { /** * @param {import('@agoric/base-zone').Zone} zone * @param {(reason: any) => boolean} rejectionMeansRetry - * @param {ReturnType} watchWhenable + * @param {ReturnType} watchVow */ -const prepareWatcherKit = (zone, rejectionMeansRetry, watchWhenable) => +const prepareWatcherKit = (zone, rejectionMeansRetry, watchVow) => zone.exoClassKit( 'PromiseWatcher', { promiseWatcher: PromiseWatcherI, - whenableSetter: M.interface('whenableSetter', { - setWhenable: M.call(M.any()).returns(), + vowSetter: M.interface('vowSetter', { + setVow: M.call(M.any()).returns(), }), }, /** @@ -127,27 +127,27 @@ const prepareWatcherKit = (zone, rejectionMeansRetry, watchWhenable) => */ (settler, watcher) => { const state = { - whenable: undefined, + vow: undefined, settler, watcher, }; return /** @type {Partial} */ (state); }, { - whenableSetter: { - /** @param {any} whenable */ - setWhenable(whenable) { - this.state.whenable = whenable; + vowSetter: { + /** @param {any} vow */ + setVow(vow) { + this.state.vow = vow; }, }, promiseWatcher: { /** @type {Required['onFulfilled']} */ onFulfilled(value) { const { watcher, settler } = this.state; - if (getWhenablePayload(value)) { + if (getVowPayload(value)) { // We've been shortened, so reflect our state accordingly, and go again. - this.facets.whenableSetter.setWhenable(value); - watchWhenable(value, this.facets.promiseWatcher); + this.facets.vowSetter.setVow(value); + watchVow(value, this.facets.promiseWatcher); return undefined; } this.state.watcher = undefined; @@ -164,7 +164,7 @@ const prepareWatcherKit = (zone, rejectionMeansRetry, watchWhenable) => onRejected(reason) { const { watcher, settler } = this.state; if (rejectionMeansRetry(reason)) { - watchWhenable(this.state.whenable, this.facets.promiseWatcher); + watchVow(this.state.vow, this.facets.promiseWatcher); return; } this.state.settler = undefined; @@ -185,49 +185,45 @@ const prepareWatcherKit = (zone, rejectionMeansRetry, watchWhenable) => /** * @param {import('@agoric/base-zone').Zone} zone - * @param {() => import('./types.js').WhenableKit} makeWhenableKit + * @param {() => import('./types.js').VowKit} makeVowKit * @param {typeof watchPromiseShim} [watchPromise] * @param {(reason: any) => boolean} [rejectionMeansRetry] */ export const prepareWatch = ( zone, - makeWhenableKit, + makeVowKit, watchPromise = watchPromiseShim, rejectionMeansRetry = _reason => false, ) => { - const watchWhenable = makeWatchWhenable(watchPromise); - const makeWatcherKit = prepareWatcherKit( - zone, - rejectionMeansRetry, - watchWhenable, - ); + const watchVow = makeWatchVow(watchPromise); + const makeWatcherKit = prepareWatcherKit(zone, rejectionMeansRetry, watchVow); /** * @template [T=any] * @template [TResult1=T] * @template [TResult2=T] - * @param {import('./types.js').ERef>} specimenP + * @param {import('./types.js').ERef>} specimenP * @param {import('./types.js').Watcher} [watcher] */ const watch = (specimenP, watcher) => { - /** @type {import('./types.js').WhenableKit} */ - const { settler, whenable } = makeWhenableKit(); + /** @type {import('./types.js').VowKit} */ + const { settler, vow } = makeVowKit(); - const { promiseWatcher, whenableSetter } = makeWatcherKit(settler, watcher); + const { promiseWatcher, vowSetter } = makeWatcherKit(settler, watcher); // Ensure we have a presence that won't be disconnected later. unwrapPromise(specimenP, (specimen, payload) => { - whenableSetter.setWhenable(specimen); + vowSetter.setVow(specimen); // Persistently watch the specimen. if (!payload) { - // Specimen is not a whenable. + // Specimen is not a vow. promiseWatcher.onFulfilled(specimen); return; } - watchWhenable(specimen, promiseWatcher); + watchVow(specimen, promiseWatcher); }).catch(e => promiseWatcher.onRejected(e)); - return whenable; + return vow; }; harden(watch); diff --git a/packages/whenable/src/when.js b/packages/vow/src/when.js similarity index 65% rename from packages/whenable/src/when.js rename to packages/vow/src/when.js index bf998b60fd3..932fdecbc3a 100644 --- a/packages/whenable/src/when.js +++ b/packages/vow/src/when.js @@ -1,29 +1,29 @@ // @ts-check -import { unwrapPromise, getWhenablePayload, basicE } from './whenable-utils.js'; +import { unwrapPromise, getVowPayload, basicE } from './vow-utils.js'; /** - * @param {() => import('./types.js').WhenablePromiseKit} makeWhenablePromiseKit + * @param {() => import('./types.js').VowPromiseKit} makeVowPromiseKit * @param {(reason: any) => boolean} [rejectionMeansRetry] */ export const makeWhen = ( - makeWhenablePromiseKit, + makeVowPromiseKit, rejectionMeansRetry = () => false, ) => { /** * @template T - * @param {import('./types.js').ERef>} specimenP + * @param {import('./types.js').ERef>} specimenP */ const when = specimenP => { - /** @type {import('./types.js').WhenablePromiseKit} */ - const { settler, promise } = makeWhenablePromiseKit(); + /** @type {import('./types.js').VowPromiseKit} */ + const { settler, promise } = makeVowPromiseKit(); // Ensure we have a presence that won't be disconnected later. unwrapPromise(specimenP, async (specimen, payload) => { - // Shorten the whenable chain without a watcher. + // Shorten the vow chain without a watcher. await null; /** @type {any} */ let result = specimen; while (payload) { - result = await basicE(payload.whenableV0) + result = await basicE(payload.vowV0) .shorten() .catch(e => { if (rejectionMeansRetry(e)) { @@ -32,8 +32,8 @@ export const makeWhen = ( } throw e; }); - // Advance to the next whenable. - const nextPayload = getWhenablePayload(result); + // Advance to the next vow. + const nextPayload = getVowPayload(result); if (!nextPayload) { break; } diff --git a/packages/whenable/test/test-disconnect.js b/packages/vow/test/test-disconnect.js similarity index 73% rename from packages/whenable/test/test-disconnect.js rename to packages/vow/test/test-disconnect.js index beade5b15a4..714f9dc11cd 100644 --- a/packages/whenable/test/test-disconnect.js +++ b/packages/vow/test/test-disconnect.js @@ -3,17 +3,17 @@ import test from 'ava'; import { makeHeapZone } from '@agoric/base-zone/heap.js'; import { makeTagged } from '@endo/pass-style'; -import { prepareWhenableTools } from '../src/tools.js'; +import { prepareVowTools } from '../src/tools.js'; test('retry on disconnection', async t => { const zone = makeHeapZone(); const rejectionMeansRetry = e => e && e.message === 'disconnected'; - const { watch, when } = prepareWhenableTools(zone, { + const { watch, when } = prepareVowTools(zone, { rejectionMeansRetry, }); - const makeTestWhenableV0 = zone.exoClass( - 'TestWhenableV0', + const makeTestVowV0 = zone.exoClass( + 'TestVowV0', undefined, plan => ({ plan }), { @@ -52,19 +52,19 @@ test('retry on disconnection', async t => { [2, 'disco', 'disco', 'sad'], ]; - for await (const watchWhenable of [false, true]) { - t.log('testing watchWhenable', watchWhenable); + for await (const watchVow of [false, true]) { + t.log('testing watchVow', watchVow); for await (const [final, ...plan] of PLANS) { - t.log(`testing (plan=${plan}, watchWhenable=${watchWhenable})`); + t.log(`testing (plan=${plan}, watchVow=${watchVow})`); - /** @type {import('../src/types.js').Whenable} */ - const whenable = makeTagged('Whenable', { - whenableV0: makeTestWhenableV0(plan), + /** @type {import('../src/types.js').Vow} */ + const vow = makeTagged('Vow', { + vowV0: makeTestVowV0(plan), }); let resultP; - if (watchWhenable) { - const resultW = watch(whenable, { + if (watchVow) { + const resultW = watch(vow, { onFulfilled(value) { t.is(plan[final], 'happy'); t.is(value, 'resolved'); @@ -79,7 +79,7 @@ test('retry on disconnection', async t => { t.is('then' in resultW, false, 'watch resultW.then is undefined'); resultP = when(resultW); } else { - resultP = when(whenable).catch(e => ['rejected', e]); + resultP = when(vow).catch(e => ['rejected', e]); } switch (plan[final]) { @@ -87,7 +87,7 @@ test('retry on disconnection', async t => { t.is( await resultP, 'resolved', - `resolve expected (plan=${plan}, watchWhenable=${watchWhenable})`, + `resolve expected (plan=${plan}, watchVow=${watchVow})`, ); break; } @@ -95,7 +95,7 @@ test('retry on disconnection', async t => { t.like( await resultP, ['rejected', Error('dejected')], - `reject expected (plan=${plan}, watchWhenable=${watchWhenable})`, + `reject expected (plan=${plan}, watchVow=${watchVow})`, ); break; } diff --git a/packages/whenable/test/test-watch.js b/packages/vow/test/test-watch.js similarity index 55% rename from packages/whenable/test/test-watch.js rename to packages/vow/test/test-watch.js index 9325ceb4113..64ff0d6f17f 100644 --- a/packages/whenable/test/test-watch.js +++ b/packages/vow/test/test-watch.js @@ -2,7 +2,7 @@ import test from 'ava'; import { makeHeapZone } from '@agoric/base-zone/heap.js'; -import { prepareWhenableTools } from '../src/tools.js'; +import { prepareVowTools } from '../src/tools.js'; /** * @param {import('@agoric/base-zone').Zone} zone @@ -23,7 +23,7 @@ const prepareAckWatcher = (zone, t) => { const runTests = async t => { const zone = makeHeapZone(); - const { watch, when, makeWhenableKit } = prepareWhenableTools(zone); + const { watch, when, makeVowKit } = prepareVowTools(zone); const makeAckWatcher = prepareAckWatcher(zone, t); const packet = harden({ portId: 'port-1', channelId: 'channel-1' }); @@ -34,22 +34,22 @@ const runTests = async t => { const connErrorP = Promise.reject(Error('disconnected')); t.is(await when(watch(connErrorP, makeAckWatcher(packet))), 'rejected'); - const { whenable, settler } = makeWhenableKit(); - const connWhenableP = Promise.resolve(whenable); + const { vow, settler } = makeVowKit(); + const connVowP = Promise.resolve(vow); settler.resolve('ack'); - t.is(await when(watch(connWhenableP, makeAckWatcher(packet))), 'fulfilled'); - t.is(await when(watch(whenable, makeAckWatcher(packet))), 'fulfilled'); + t.is(await when(watch(connVowP, makeAckWatcher(packet))), 'fulfilled'); + t.is(await when(watch(vow, makeAckWatcher(packet))), 'fulfilled'); - const { whenable: whenable2, settler: settler2 } = makeWhenableKit(); - const connWhenable2P = Promise.resolve(whenable2); - settler2.resolve(whenable); - t.is(await when(watch(connWhenable2P, makeAckWatcher(packet))), 'fulfilled'); + const { vow: vow2, settler: settler2 } = makeVowKit(); + const connVow2P = Promise.resolve(vow2); + settler2.resolve(vow); + t.is(await when(watch(connVow2P, makeAckWatcher(packet))), 'fulfilled'); - const { whenable: whenable3, settler: settler3 } = makeWhenableKit(); - const connWhenable3P = Promise.resolve(whenable3); + const { vow: vow3, settler: settler3 } = makeVowKit(); + const connVow3P = Promise.resolve(vow3); settler3.reject(Error('disco2')); - settler3.resolve(whenable2); - t.is(await when(watch(connWhenable3P, makeAckWatcher(packet))), 'rejected'); + settler3.resolve(vow2); + t.is(await when(watch(connVow3P, makeAckWatcher(packet))), 'rejected'); }; test('ack watcher - shim', runTests); diff --git a/packages/whenable/tsconfig.build.json b/packages/vow/tsconfig.build.json similarity index 100% rename from packages/whenable/tsconfig.build.json rename to packages/vow/tsconfig.build.json diff --git a/packages/whenable/tsconfig.json b/packages/vow/tsconfig.json similarity index 82% rename from packages/whenable/tsconfig.json rename to packages/vow/tsconfig.json index ef4aaafb0a2..b7269984d3c 100644 --- a/packages/whenable/tsconfig.json +++ b/packages/vow/tsconfig.json @@ -2,7 +2,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "maxNodeModuleJsDepth": 2, // as in jsconfig's default + "maxNodeModuleJsDepth": 3, "checkJs": false }, "include": [ diff --git a/packages/whenable/README.md b/packages/whenable/README.md deleted file mode 100644 index 2588d715b44..00000000000 --- a/packages/whenable/README.md +++ /dev/null @@ -1,78 +0,0 @@ -# Whenables - -Native promises are not compatible with Agoric's durable stores, which means that on the Agoric platform, such promises disconnect their clients when their creator vat is upgraded. Whenables are objects that represent promises that can be stored durably, this package also provides a `when` operator to allow clients to tolerate upgrades of whenable-hosting vats, as well as a `watch` operator to subscribe to a whenable in a way that survives upgrades of both the creator and subscribing client vats. - -## Whenable Consumer - -If your vat is a consumer of promises that are unexpectedly fulfilling to a Whenable (something like): - -```js -import { E } from '@endo/far'; - -const a = await w1; -const b = await E(w2).something(...args); -console.log('Here they are:', { a, b }); -``` - -Produces output like: -```console -Here they are: { - a: Object [Whenable] { - payload: { whenableV0: Object [Alleged: WhenableInternalsKit whenableV0] {} } - }, - b: Object [Whenable] { - payload: { whenableV0: Object [Alleged: WhenableInternalsKit whenableV0] {} } - } -} -``` - -On Agoric, you can use `V` exported from `@agoric/vat-data/whenable.js`, which -converts a chain of promises and whenables to a promise for its final -fulfilment, by unwrapping any intermediate whenables: - -```js -import { V as E } from '@agoric/vat-data/whenable.js'; -[...] -const a = await E.when(w1); -const b = await E(w2).something(...args); -// Produces the expected results. -``` - -## Whenable Producer - -On Agoric, use the following to create and resolve a whenable: - -```js -// CAVEAT: `V` uses internal ephemeral promises, so while it is convenient, -// it cannot be used by upgradable vats. See "Durability" below: -import { V as E, makeWhenableKit } from '@agoric/vat-data/whenable.js'; -[...] -const { settler, whenable } = makeWhenableKit(); -// Send whenable to a potentially different vat. -E(outsideReference).performSomeMethod(whenable); -// some time later... -settler.resolve('now you know the answer'); -``` - -## Durability - -The whenable package supports Zones, which are used to integrate Agoric's vat -upgrade mechanism and `watchPromise`. To create whenable tools that deal with -durable objects: - -```js -// NOTE: Cannot use `V` as it has non-durable internal state when unwrapping -// whenables. Instead, use the default whenable-exposing `E` with the `watch` -// operator. -import { E } from '@endo/far'; -import { prepareWhenableTools } from '@agoric/vat-data/whenable.js'; -import { makeDurableZone } from '@agoric/zone'; - -// Only do the following once at the start of a new vat incarnation: -const zone = makeDurableZone(baggage); -const whenableZone = zone.subZone('WhenableTools'); -const { watch, makeWhenableKit } = prepareWhenableTools(whenableZone); - -// Now the functions have been bound to the durable baggage. -// Whenables and settlers you create can be saved in durable stores. -``` diff --git a/packages/whenable/heap.js b/packages/whenable/heap.js deleted file mode 100644 index d4269f00935..00000000000 --- a/packages/whenable/heap.js +++ /dev/null @@ -1,5 +0,0 @@ -import { makeHeapZone } from '@agoric/base-zone/heap.js'; -import { prepareWhenableModule } from './src/module.js'; - -export const { E, makeWhenableKit, makeWhenablePromiseKit, when, watch } = - prepareWhenableModule(makeHeapZone()); diff --git a/packages/whenable/src/whenable.js b/packages/whenable/src/whenable.js deleted file mode 100644 index 04bf52adac6..00000000000 --- a/packages/whenable/src/whenable.js +++ /dev/null @@ -1,111 +0,0 @@ -// @ts-check -import { makePromiseKit } from '@endo/promise-kit'; -import { M } from '@endo/patterns'; -import { makeTagged } from '@endo/pass-style'; - -/** - * @param {import('@agoric/base-zone').Zone} zone - */ -export const prepareWhenableKits = zone => { - /** WeakMap */ - const whenableV0ToEphemeral = new WeakMap(); - - /** - * Get the current incarnation's promise kit associated with a whenableV0. - * - * @param {import('./types.js').WhenablePayload['whenableV0']} whenableV0 - * @returns {import('@endo/promise-kit').PromiseKit} - */ - const findCurrentKit = whenableV0 => { - let pk = whenableV0ToEphemeral.get(whenableV0); - if (pk) { - return pk; - } - - pk = makePromiseKit(); - pk.promise.catch(() => {}); // silence unhandled rejection - whenableV0ToEphemeral.set(whenableV0, pk); - return pk; - }; - - /** - * @param {'resolve' | 'reject'} kind - * @param {import('./types.js').WhenablePayload['whenableV0']} whenableV0 - * @param {unknown} value - */ - const settle = (kind, whenableV0, value) => { - const kit = findCurrentKit(whenableV0); - const cb = kit[kind]; - if (!cb) { - return; - } - whenableV0ToEphemeral.set(whenableV0, harden({ promise: kit.promise })); - cb(value); - }; - - const makeWhenableInternalsKit = zone.exoClassKit( - 'WhenableInternalsKit', - { - whenableV0: M.interface('WhenableV0', { - shorten: M.call().returns(M.promise()), - }), - settler: M.interface('Settler', { - resolve: M.call().optional(M.any()).returns(), - reject: M.call().optional(M.any()).returns(), - }), - }, - () => ({}), - { - whenableV0: { - /** - * @returns {Promise} - */ - shorten() { - return findCurrentKit(this.facets.whenableV0).promise; - }, - }, - settler: { - /** - * @param {any} [value] - */ - resolve(value) { - const { whenableV0 } = this.facets; - settle('resolve', whenableV0, value); - }, - /** - * @param {any} [reason] - */ - reject(reason) { - const { whenableV0 } = this.facets; - settle('reject', whenableV0, reason); - }, - }, - }, - ); - - /** - * @template T - * @returns {import('./types.js').WhenableKit} - */ - const makeWhenableKit = () => { - const { settler, whenableV0 } = makeWhenableInternalsKit(); - const whenable = makeTagged('Whenable', harden({ whenableV0 })); - return harden({ settler, whenable }); - }; - - /** - * @template T - * @returns {import('./types.js').WhenablePromiseKit} - */ - const makeWhenablePromiseKit = () => { - const { settler, whenableV0 } = makeWhenableInternalsKit(); - const whenable = makeTagged('Whenable', harden({ whenableV0 })); - - /** @type {{ promise: Promise }} */ - const { promise } = findCurrentKit(whenableV0); - return harden({ settler, whenable, promise }); - }; - return { makeWhenableKit, makeWhenablePromiseKit }; -}; - -harden(prepareWhenableKits); From 4d9371cb7d450e25146787474760b4c00b11e405 Mon Sep 17 00:00:00 2001 From: Michael FIG Date: Thu, 22 Feb 2024 17:34:48 -0600 Subject: [PATCH 19/19] fix(vow): persistent resolution, settler->resolver --- packages/agoric-cli/src/sdk-package-names.js | 2 +- packages/vat-data/test/test-vow.js | 4 +- packages/vat-data/vow.js | 2 +- packages/vow/README.md | 6 +- packages/vow/src/E.js | 8 -- packages/vow/src/tools.js | 17 ++- packages/vow/src/types.js | 6 +- packages/vow/src/vow.js | 114 ++++++++++++------- packages/vow/src/watch-promise.js | 61 ++++++++++ packages/vow/src/watch.js | 103 ++++------------- packages/vow/src/when.js | 15 ++- packages/vow/test/test-watch.js | 14 +-- 12 files changed, 197 insertions(+), 155 deletions(-) create mode 100644 packages/vow/src/watch-promise.js diff --git a/packages/agoric-cli/src/sdk-package-names.js b/packages/agoric-cli/src/sdk-package-names.js index 543a039398d..9992b022ed2 100644 --- a/packages/agoric-cli/src/sdk-package-names.js +++ b/packages/agoric-cli/src/sdk-package-names.js @@ -42,9 +42,9 @@ export default [ "@agoric/vat-data", "@agoric/vats", "@agoric/vm-config", + "@agoric/vow", "@agoric/wallet", "@agoric/wallet-backend", - "@agoric/vow", "@agoric/xsnap", "@agoric/xsnap-lockdown", "@agoric/zoe", diff --git a/packages/vat-data/test/test-vow.js b/packages/vat-data/test/test-vow.js index 284d1f41c84..a14510ae952 100644 --- a/packages/vat-data/test/test-vow.js +++ b/packages/vat-data/test/test-vow.js @@ -10,9 +10,9 @@ test('heap messages', async t => { }); /** @type {ReturnType>} */ - const { vow, settler } = makeVowKit(); + const { vow, resolver } = makeVowKit(); const retP = V(vow).hello('World'); - settler.resolve(greeter); + resolver.resolve(greeter); // Happy path: WE(vow)[method](...args) calls the method. t.is(await retP, 'Hello, World!'); diff --git a/packages/vat-data/vow.js b/packages/vat-data/vow.js index 50b2c5e9f9f..bec74356802 100644 --- a/packages/vat-data/vow.js +++ b/packages/vat-data/vow.js @@ -12,7 +12,7 @@ const vatData = globalThis.VatData; * version. If it is `undefined`, `@agoric/vow` will shim it. * @type {undefined | (( * p: Promise, - * watcher: import('@agoric/vow/src/watch.js').PromiseWatcher, + * watcher: import('@agoric/vow/src/watch-promise.js').PromiseWatcher, * ...args: unknown[] * ) => void)} */ diff --git a/packages/vow/README.md b/packages/vow/README.md index a7c3f1c0a18..507a5a95fea 100644 --- a/packages/vow/README.md +++ b/packages/vow/README.md @@ -47,11 +47,11 @@ On Agoric, use the following to create and resolve a vow: // it cannot be used by upgradable vats. See "Durability" below: import { V as E, makeVowKit } from '@agoric/vat-data/vow.js'; [...] -const { settler, vow } = makeVowKit(); +const { resolver, vow } = makeVowKit(); // Send vow to a potentially different vat. E(outsideReference).performSomeMethod(vow); // some time later... -settler.resolve('now you know the answer'); +resolver.resolve('now you know the answer'); ``` ## Durability @@ -74,5 +74,5 @@ const vowZone = zone.subZone('VowTools'); const { watch, makeVowKit } = prepareVowTools(vowZone); // Now the functions have been bound to the durable baggage. -// Vows and settlers you create can be saved in durable stores. +// Vows and resolvers you create can be saved in durable stores. ``` diff --git a/packages/vow/src/E.js b/packages/vow/src/E.js index 5b6f4049bab..66a218dc317 100644 --- a/packages/vow/src/E.js +++ b/packages/vow/src/E.js @@ -427,14 +427,6 @@ export default makeE; * )} LocalRecord */ -/** - * @template [R = unknown] - * @typedef {{ - * promise: Promise; - * settler: import('@endo/eventual-send').Settler; - * }} EPromiseKit - */ - /** * Type for an object that must only be invoked with E. It supports a given * interface but declares all the functions as asyncable. diff --git a/packages/vow/src/tools.js b/packages/vow/src/tools.js index 03ecfe94f04..8d0701d2d06 100644 --- a/packages/vow/src/tools.js +++ b/packages/vow/src/tools.js @@ -7,12 +7,19 @@ import { prepareWatch } from './watch.js'; * @param {import('@agoric/base-zone').Zone} zone * @param {object} [powers] * @param {(reason: any) => boolean} [powers.rejectionMeansRetry] - * @param {(p: PromiseLike, watcher: import('./watch.js').PromiseWatcher, ...args: unknown[]) => void} [powers.watchPromise] + * @param {(p: PromiseLike, watcher: import('./watch-promise.js').PromiseWatcher, ...args: unknown[]) => void} [powers.watchPromise] */ -export const prepareVowTools = (zone, powers) => { - const { rejectionMeansRetry = () => false, watchPromise } = powers || {}; - const { makeVowKit, makeVowPromiseKit } = prepareVowKits(zone); - const when = makeWhen(makeVowPromiseKit, rejectionMeansRetry); +export const prepareVowTools = (zone, powers = {}) => { + const { rejectionMeansRetry = () => false, watchPromise } = powers; + const { makeVowKit, providePromiseForVowResolver } = prepareVowKits( + zone, + watchPromise, + ); + const when = makeWhen( + makeVowKit, + providePromiseForVowResolver, + rejectionMeansRetry, + ); const watch = prepareWatch( zone, makeVowKit, diff --git a/packages/vow/src/types.js b/packages/vow/src/types.js index 2646a28afcc..dc44ab4e2a0 100644 --- a/packages/vow/src/types.js +++ b/packages/vow/src/types.js @@ -50,7 +50,7 @@ export {}; * @template [T=any] * @typedef {{ * vow: Vow, - * settler: Settler, + * resolver: VowResolver, * }} VowKit */ @@ -58,14 +58,14 @@ export {}; * @template [T=any] * @typedef {{ * vow: Vow, - * settler: Settler, + * resolver: VowResolver, * promise: Promise * }} VowPromiseKit */ /** * @template [T=any] - * @typedef {{ resolve(value?: T | PromiseVow): void, reject(reason?: any): void }} Settler + * @typedef {{ resolve(value?: T | PromiseVow): void, reject(reason?: any): void }} VowResolver */ /** diff --git a/packages/vow/src/vow.js b/packages/vow/src/vow.js index f157bcc9dd0..07c7ae92197 100644 --- a/packages/vow/src/vow.js +++ b/packages/vow/src/vow.js @@ -2,45 +2,51 @@ import { makePromiseKit } from '@endo/promise-kit'; import { M } from '@endo/patterns'; import { makeTagged } from '@endo/pass-style'; +import { PromiseWatcherI, watchPromiseShim } from './watch-promise.js'; + +const sink = () => {}; +harden(sink); + +/** + * @typedef {Partial> & + * Pick, 'promise'>} VowEphemera + */ /** * @param {import('@agoric/base-zone').Zone} zone + * @param {typeof watchPromiseShim} [watchPromise] */ -export const prepareVowKits = zone => { - /** WeakMap */ - const vowV0ToEphemeral = new WeakMap(); +export const prepareVowKits = (zone, watchPromise = watchPromiseShim) => { + /** @type {WeakMap} */ + const resolverToEphemera = new WeakMap(); /** * Get the current incarnation's promise kit associated with a vowV0. * - * @param {import('./types.js').VowPayload['vowV0']} vowV0 - * @returns {import('@endo/promise-kit').PromiseKit} + * @param {import('./types.js').VowResolver} resolver */ - const findCurrentKit = vowV0 => { - let pk = vowV0ToEphemeral.get(vowV0); + const provideCurrentKit = resolver => { + let pk = resolverToEphemera.get(resolver); if (pk) { return pk; } pk = makePromiseKit(); - pk.promise.catch(() => {}); // silence unhandled rejection - vowV0ToEphemeral.set(vowV0, pk); + pk.promise.catch(sink); // silence unhandled rejection + resolverToEphemera.set(resolver, pk); return pk; }; /** - * @param {'resolve' | 'reject'} kind - * @param {import('./types.js').VowPayload['vowV0']} vowV0 - * @param {unknown} value + * @param {import('./types.js').VowResolver} resolver */ - const settle = (kind, vowV0, value) => { - const kit = findCurrentKit(vowV0); - const cb = kit[kind]; - if (!cb) { - return; + const getPromiseKitForResolution = resolver => { + const kit = provideCurrentKit(resolver); + if (kit.resolve) { + // Resolution is a one-time event, so forget the resolve/reject functions. + resolverToEphemera.set(resolver, harden({ promise: kit.promise })); } - vowV0ToEphemeral.set(vowV0, harden({ promise: kit.promise })); - cb(value); + return kit; }; const makeVowInternalsKit = zone.exoClassKit( @@ -49,35 +55,68 @@ export const prepareVowKits = zone => { vowV0: M.interface('VowV0', { shorten: M.call().returns(M.promise()), }), - settler: M.interface('Settler', { + resolver: M.interface('VowResolver', { resolve: M.call().optional(M.any()).returns(), reject: M.call().optional(M.any()).returns(), }), + watchNextStep: PromiseWatcherI, }, - () => ({}), + () => ({ + value: undefined, + // The stepStatus is null if the promise step hasn't settled yet. + stepStatus: /** @type {null | 'fulfilled' | 'rejected'} */ (null), + }), { vowV0: { /** * @returns {Promise} */ - shorten() { - return findCurrentKit(this.facets.vowV0).promise; + async shorten() { + const { stepStatus, value } = this.state; + switch (stepStatus) { + case 'fulfilled': + return value; + case 'rejected': + throw value; + case null: + return provideCurrentKit(this.facets.resolver).promise; + default: + throw new TypeError(`unexpected stepStatus ${stepStatus}`); + } }, }, - settler: { + resolver: { /** * @param {any} [value] */ resolve(value) { - const { vowV0 } = this.facets; - settle('resolve', vowV0, value); + const { resolver } = this.facets; + const { promise, resolve } = getPromiseKitForResolution(resolver); + if (resolve) { + resolve(value); + watchPromise(promise, this.facets.watchNextStep); + } }, /** * @param {any} [reason] */ reject(reason) { - const { vowV0 } = this.facets; - settle('reject', vowV0, reason); + const { resolver, watchNextStep } = this.facets; + const { reject } = getPromiseKitForResolution(resolver); + if (reject) { + reject(reason); + watchNextStep.onRejected(reason); + } + }, + }, + watchNextStep: { + onFulfilled(value) { + this.state.stepStatus = 'fulfilled'; + this.state.value = value; + }, + onRejected(reason) { + this.state.stepStatus = 'rejected'; + this.state.value = reason; }, }, }, @@ -88,24 +127,17 @@ export const prepareVowKits = zone => { * @returns {import('./types.js').VowKit} */ const makeVowKit = () => { - const { settler, vowV0 } = makeVowInternalsKit(); + const { resolver, vowV0 } = makeVowInternalsKit(); const vow = makeTagged('Vow', harden({ vowV0 })); - return harden({ settler, vow }); + return harden({ resolver, vow }); }; /** - * @template T - * @returns {import('./types.js').VowPromiseKit} + * @param {import('./types').VowResolver} resolver */ - const makeVowPromiseKit = () => { - const { settler, vowV0 } = makeVowInternalsKit(); - const vow = makeTagged('Vow', harden({ vowV0 })); - - /** @type {{ promise: Promise }} */ - const { promise } = findCurrentKit(vowV0); - return harden({ settler, vow, promise }); - }; - return { makeVowKit, makeVowPromiseKit }; + const providePromiseForVowResolver = resolver => + provideCurrentKit(resolver).promise; + return { makeVowKit, providePromiseForVowResolver }; }; harden(prepareVowKits); diff --git a/packages/vow/src/watch-promise.js b/packages/vow/src/watch-promise.js new file mode 100644 index 00000000000..0d850090269 --- /dev/null +++ b/packages/vow/src/watch-promise.js @@ -0,0 +1,61 @@ +// @ts-check +import { M } from '@endo/patterns'; + +import { basicE } from './vow-utils.js'; + +const { Fail } = assert; + +const { apply } = Reflect; + +export const PromiseWatcherI = M.interface('PromiseWatcher', { + onFulfilled: M.call(M.any()).rest(M.any()).returns(), + onRejected: M.call(M.any()).rest(M.any()).returns(), +}); + +/** + * @typedef {object} PromiseWatcher + * @property {(fulfilment: unknown, ...args: unknown[]) => void} [onFulfilled] + * @property {(reason: unknown, ...args: unknown[]) => void} [onRejected] + */ + +/** + * Adapt a promise watcher method to E.when. + * @param {Record unknown>} that + * @param {PropertyKey} prop + * @param {unknown[]} postArgs + */ +const callMeMaybe = (that, prop, postArgs) => { + const fn = that[prop]; + if (!fn) { + return undefined; + } + assert.typeof(fn, 'function'); + /** + * @param {unknown} arg value or reason + */ + const wrapped = arg => { + // Don't return a value, to prevent E.when from subscribing to a resulting + // promise. + apply(fn, that, [arg, ...postArgs]); + }; + return wrapped; +}; + +/** + * Shim the promise watcher behaviour when VatData.watchPromise is not available. + * + * @param {Promise} p + * @param {PromiseWatcher} watcher + * @param {...unknown[]} watcherArgs + * @returns {void} + */ +export const watchPromiseShim = (p, watcher, ...watcherArgs) => { + Promise.resolve(p) === p || Fail`watchPromise only watches promises`; + const onFulfilled = callMeMaybe(watcher, 'onFulfilled', watcherArgs); + const onRejected = callMeMaybe(watcher, 'onRejected', watcherArgs); + onFulfilled || + onRejected || + Fail`promise watcher must implement at least one handler method`; + void basicE.when(p, onFulfilled, onRejected); +}; +harden(watchPromiseShim); diff --git a/packages/vow/src/watch.js b/packages/vow/src/watch.js index 58ed1046229..63f00317193 100644 --- a/packages/vow/src/watch.js +++ b/packages/vow/src/watch.js @@ -2,63 +2,10 @@ import { M } from '@endo/patterns'; import { getVowPayload, unwrapPromise, basicE } from './vow-utils.js'; - -const { Fail } = assert; +import { PromiseWatcherI, watchPromiseShim } from './watch-promise.js'; const { apply } = Reflect; -export const PromiseWatcherI = M.interface('PromiseWatcher', { - onFulfilled: M.call(M.any()).rest(M.any()).returns(), - onRejected: M.call(M.any()).rest(M.any()).returns(), -}); - -/** - * @typedef {object} PromiseWatcher - * @property {(...args: unknown[]) => void} [onFulfilled] - * @property {(...args: unknown[]) => void} [onRejected] - */ - -/** - * Adapt a promise watcher method to E.when. - * @param {Record unknown>} that - * @param {PropertyKey} prop - * @param {unknown[]} postArgs - */ -const callMeMaybe = (that, prop, postArgs) => { - const fn = that[prop]; - if (!fn) { - return undefined; - } - assert.typeof(fn, 'function'); - /** - * @param {unknown} arg value or reason - */ - const wrapped = arg => { - // Don't return a value, to prevent E.when from subscribing to a resulting - // promise. - apply(fn, that, [arg, ...postArgs]); - }; - return wrapped; -}; - -/** - * Shim the promise watcher behaviour when VatData.watchPromise is not available. - * - * @param {Promise} p - * @param {PromiseWatcher} watcher - * @param {...unknown[]} watcherArgs - * @returns {void} - */ -const watchPromiseShim = (p, watcher, ...watcherArgs) => { - Promise.resolve(p) === p || Fail`watchPromise only watches promises`; - const onFulfilled = callMeMaybe(watcher, 'onFulfilled', watcherArgs); - const onRejected = callMeMaybe(watcher, 'onRejected', watcherArgs); - onFulfilled || - onRejected || - Fail`promise watcher must implement at least one handler method`; - void basicE.when(p, onFulfilled, onRejected); -}; - /** * @param {typeof watchPromiseShim} watchPromise */ @@ -66,7 +13,7 @@ const makeWatchVow = watchPromise => /** * @param {any} specimen - * @param {PromiseWatcher} promiseWatcher + * @param {import('./watch-promise.js').PromiseWatcher} promiseWatcher */ (specimen, promiseWatcher) => { let promise; @@ -80,12 +27,12 @@ const makeWatchVow = }; /** - * @param {import('./types.js').Settler} settler + * @param {import('./types.js').VowResolver} resolver * @param {import('./types.js').Watcher} watcher * @param {keyof Required} wcb * @param {unknown} value */ -const settle = (settler, watcher, wcb, value) => { +const settle = (resolver, watcher, wcb, value) => { try { let chainedValue = value; const w = watcher[wcb]; @@ -94,10 +41,10 @@ const settle = (settler, watcher, wcb, value) => { } else if (wcb === 'onRejected') { throw value; } - settler && settler.resolve(chainedValue); + resolver && resolver.resolve(chainedValue); } catch (e) { - if (settler) { - settler.reject(e); + if (resolver) { + resolver.reject(e); } else { throw e; } @@ -122,13 +69,13 @@ const prepareWatcherKit = (zone, rejectionMeansRetry, watchVow) => * @template [T=any] * @template [TResult1=T] * @template [TResult2=never] - * @param {import('./types.js').Settler} settler + * @param {import('./types.js').VowResolver} resolver * @param {import('./types.js').Watcher} [watcher] */ - (settler, watcher) => { + (resolver, watcher) => { const state = { vow: undefined, - settler, + resolver, watcher, }; return /** @type {Partial} */ (state); @@ -141,9 +88,9 @@ const prepareWatcherKit = (zone, rejectionMeansRetry, watchVow) => }, }, promiseWatcher: { - /** @type {Required['onFulfilled']} */ + /** @type {Required['onFulfilled']} */ onFulfilled(value) { - const { watcher, settler } = this.state; + const { watcher, resolver } = this.state; if (getVowPayload(value)) { // We've been shortened, so reflect our state accordingly, and go again. this.facets.vowSetter.setVow(value); @@ -151,32 +98,32 @@ const prepareWatcherKit = (zone, rejectionMeansRetry, watchVow) => return undefined; } this.state.watcher = undefined; - this.state.settler = undefined; - if (!settler) { + this.state.resolver = undefined; + if (!resolver) { return undefined; } else if (watcher) { - settle(settler, watcher, 'onFulfilled', value); + settle(resolver, watcher, 'onFulfilled', value); } else { - settler.resolve(value); + resolver.resolve(value); } }, - /** @type {Required['onRejected']} */ + /** @type {Required['onRejected']} */ onRejected(reason) { - const { watcher, settler } = this.state; + const { watcher, resolver } = this.state; if (rejectionMeansRetry(reason)) { watchVow(this.state.vow, this.facets.promiseWatcher); return; } - this.state.settler = undefined; + this.state.resolver = undefined; this.state.watcher = undefined; if (!watcher) { - settler && settler.reject(reason); - } else if (!settler) { + resolver && resolver.reject(reason); + } else if (!resolver) { throw reason; // for host's unhandled rejection handler to catch } else if (watcher.onRejected) { - settle(settler, watcher, 'onRejected', reason); + settle(resolver, watcher, 'onRejected', reason); } else { - settler.reject(reason); + resolver.reject(reason); } }, }, @@ -207,9 +154,9 @@ export const prepareWatch = ( */ const watch = (specimenP, watcher) => { /** @type {import('./types.js').VowKit} */ - const { settler, vow } = makeVowKit(); + const { resolver, vow } = makeVowKit(); - const { promiseWatcher, vowSetter } = makeWatcherKit(settler, watcher); + const { promiseWatcher, vowSetter } = makeWatcherKit(resolver, watcher); // Ensure we have a presence that won't be disconnected later. unwrapPromise(specimenP, (specimen, payload) => { diff --git a/packages/vow/src/when.js b/packages/vow/src/when.js index 932fdecbc3a..6dc9e289cf9 100644 --- a/packages/vow/src/when.js +++ b/packages/vow/src/when.js @@ -2,11 +2,13 @@ import { unwrapPromise, getVowPayload, basicE } from './vow-utils.js'; /** - * @param {() => import('./types.js').VowPromiseKit} makeVowPromiseKit + * @param {() => import('./types.js').VowKit} makeVowKit + * @param {(resolver: import('./types').VowResolver) => Promise} providePromiseForVowResolver * @param {(reason: any) => boolean} [rejectionMeansRetry] */ export const makeWhen = ( - makeVowPromiseKit, + makeVowKit, + providePromiseForVowResolver, rejectionMeansRetry = () => false, ) => { /** @@ -14,8 +16,9 @@ export const makeWhen = ( * @param {import('./types.js').ERef>} specimenP */ const when = specimenP => { - /** @type {import('./types.js').VowPromiseKit} */ - const { settler, promise } = makeVowPromiseKit(); + /** @type {import('./types.js').VowKit} */ + const { resolver } = makeVowKit(); + const promise = providePromiseForVowResolver(resolver); // Ensure we have a presence that won't be disconnected later. unwrapPromise(specimenP, async (specimen, payload) => { // Shorten the vow chain without a watcher. @@ -39,8 +42,8 @@ export const makeWhen = ( } payload = nextPayload; } - settler.resolve(result); - }).catch(e => settler.reject(e)); + resolver.resolve(result); + }).catch(e => resolver.reject(e)); return promise; }; diff --git a/packages/vow/test/test-watch.js b/packages/vow/test/test-watch.js index 64ff0d6f17f..1348adf3965 100644 --- a/packages/vow/test/test-watch.js +++ b/packages/vow/test/test-watch.js @@ -34,21 +34,21 @@ const runTests = async t => { const connErrorP = Promise.reject(Error('disconnected')); t.is(await when(watch(connErrorP, makeAckWatcher(packet))), 'rejected'); - const { vow, settler } = makeVowKit(); + const { vow, resolver } = makeVowKit(); const connVowP = Promise.resolve(vow); - settler.resolve('ack'); + resolver.resolve('ack'); t.is(await when(watch(connVowP, makeAckWatcher(packet))), 'fulfilled'); t.is(await when(watch(vow, makeAckWatcher(packet))), 'fulfilled'); - const { vow: vow2, settler: settler2 } = makeVowKit(); + const { vow: vow2, resolver: resolver2 } = makeVowKit(); const connVow2P = Promise.resolve(vow2); - settler2.resolve(vow); + resolver2.resolve(vow); t.is(await when(watch(connVow2P, makeAckWatcher(packet))), 'fulfilled'); - const { vow: vow3, settler: settler3 } = makeVowKit(); + const { vow: vow3, resolver: resolver3 } = makeVowKit(); const connVow3P = Promise.resolve(vow3); - settler3.reject(Error('disco2')); - settler3.resolve(vow2); + resolver3.reject(Error('disco2')); + resolver3.resolve(vow2); t.is(await when(watch(connVow3P, makeAckWatcher(packet))), 'rejected'); };