diff --git a/packages/vow/README.md b/packages/vow/README.md index da4b8d34de3..08d32ef3d13 100644 --- a/packages/vow/README.md +++ b/packages/vow/README.md @@ -27,7 +27,7 @@ Here they are: { ``` You can use `heapVowE` exported from `@agoric/vow`, which converts a chain of -promises and vows to a promise for its final fulfilment, by unwrapping any +promises and vows to a promise for its final fulfillment, by unwrapping any intermediate vows: ```js @@ -77,6 +77,67 @@ const { watch, makeVowKit } = prepareVowTools(vowZone); // Vows and resolvers you create can be saved in durable stores. ``` +## VowTools + +VowTools are a set of utility functions for working with Vows in Agoric smart contracts and vats. These tools help manage asynchronous operations in a way that's resilient to vat upgrades, ensuring your smart contract can handle long-running processes reliably. + +### Usage + +VowTools are typically prepared in the start function of a smart contract or vat and passed in as a power to exos. + + +```javascript +import { prepareVowTools } from '@agoric/vow/vat.js'; +import { makeDurableZone } from '@agoric/zone/durable.js'; + +export const start = async (zcf, privateArgs, baggage) => { + const zone = makeDurableZone(baggage); + const vowTools = prepareVowTools(zone.subZone('vows')); + + // Use vowTools here... +} +``` + +### Available Tools + +#### `when(vowOrPromise)` +Returns a Promise for the fulfillment of the very end of the `vowOrPromise` chain. It can retry disconnections due to upgrades of other vats, but cannot survive the upgrade of the calling vat. + +#### `watch(promiseOrVow, [watcher], [context])` +Watch a Vow and optionally provide a `watcher` with `onFulfilled`/`onRejected` handlers and a `context` value for the handlers. When handlers are not provided the fulfillment or rejection will simply pass through. + +It also registers pending Promises, so if the current vat is upgraded, the watcher is rejected because the Promise was lost when the heap was reset. + +#### `all(arrayOfPassables, [watcher], [context])` +Vow-tolerant implementation of Promise.all that takes an iterable of vows and other Passables and returns a single Vow. It resolves with an array of values when all of the input's promises or vows are fulfilled and rejects with the first rejection reason when any of the input's promises or vows are rejected. + +#### `allSettled(arrayOfPassables, [watcher], [context])` +Vow-tolerant implementation of Promise.allSettled that takes an iterable of vows and other Passables and returns a single Vow. It resolves when all of the input's promises or vows are settled with an array of settled outcome objects. + +#### `asVow(fn)` +Takes a function that might return synchronously, throw an Error, or return a Promise or Vow and returns a Vow. + +#### `asPromise(vow)` +Converts a Vow back into a Promise. + +### Example + +```javascript +const { when, watch, all, allSettled } = vowTools; + +// Using watch to create a Vow +const myVow = watch(someAsyncOperation()); + +// Using when to resolve a Vow +const result = await when(myVow); + +// Using all +const results = await when(all([vow, vowForVow, promise])); + +// Using allSettled +const outcomes = await when(allSettled([vow, vowForVow, promise])); +``` + ## Internals The current "version 0" vow internals expose a `shorten()` method, returning a diff --git a/packages/vow/src/tools.js b/packages/vow/src/tools.js index ff35539726c..275d274c615 100644 --- a/packages/vow/src/tools.js +++ b/packages/vow/src/tools.js @@ -7,6 +7,7 @@ import { makeWhen } from './when.js'; /** * @import {Zone} from '@agoric/base-zone'; + * @import {Passable} from '@endo/pass-style'; * @import {IsRetryableReason, AsPromiseFunction, EVow, Vow, ERef} from './types.js'; */ @@ -52,11 +53,31 @@ export const prepareBasicVowTools = (zone, powers = {}) => { }; /** - * Vow-tolerant implementation of Promise.all. + * Vow-tolerant implementation of Promise.all that takes an iterable of vows + * and other {@link Passable}s and returns a single {@link Vow}. It resolves + * with an array of values when all of the input's promises or vows are + * fulfilled and rejects when any of the input's promises or vows are + * rejected with the first rejection reason. * - * @param {EVow[]} maybeVows + * @param {unknown[]} maybeVows */ - const allVows = maybeVows => watchUtils.all(maybeVows); + const all = maybeVows => watchUtils.all(maybeVows); + + /** + * @param {unknown[]} maybeVows + * @deprecated use `vowTools.all` + */ + const allVows = all; + + /** + * Vow-tolerant implementation of Promise.allSettled that takes an iterable + * of vows and other {@link Passable}s and returns a single {@link Vow}. It + * resolves when all of the input's promises or vows are settled with an + * array of settled outcome objects. + * + * @param {unknown[]} maybeVows + */ + const allSettled = maybeVows => watchUtils.allSettled(maybeVows); /** @type {AsPromiseFunction} */ const asPromise = (specimenP, ...watcherArgs) => @@ -66,7 +87,9 @@ export const prepareBasicVowTools = (zone, powers = {}) => { when, watch, makeVowKit, + all, allVows, + allSettled, asVow, asPromise, retriable, diff --git a/packages/vow/src/types.js b/packages/vow/src/types.js index 15b894b244e..1370be6bd1c 100644 --- a/packages/vow/src/types.js +++ b/packages/vow/src/types.js @@ -66,6 +66,7 @@ export {}; */ /** + * Vows are objects that represent promises that can be stored durably. * @template [T=any] * @typedef {CopyTagged<'Vow', VowPayload>} Vow */ diff --git a/packages/vow/src/watch-utils.js b/packages/vow/src/watch-utils.js index 0bb740530bc..24b809f5a67 100644 --- a/packages/vow/src/watch-utils.js +++ b/packages/vow/src/watch-utils.js @@ -10,7 +10,7 @@ const { Fail, bare, details: X } = assert; * @import {Zone} from '@agoric/base-zone'; * @import {Watch} from './watch.js'; * @import {When} from './when.js'; - * @import {VowKit, AsPromiseFunction, IsRetryableReason, EVow} from './types.js'; + * @import {VowKit, AsPromiseFunction, IsRetryableReason, Vow} from './types.js'; */ const VowShape = M.tagged( @@ -54,11 +54,16 @@ export const prepareWatchUtils = ( { utils: M.interface('Utils', { all: M.call(M.arrayOf(M.any())).returns(VowShape), + allSettled: M.call(M.arrayOf(M.any())).returns(VowShape), asPromise: M.call(M.raw()).rest(M.raw()).returns(M.promise()), }), watcher: M.interface('Watcher', { - onFulfilled: M.call(M.any()).rest(M.any()).returns(M.any()), - onRejected: M.call(M.any()).rest(M.any()).returns(M.any()), + onFulfilled: M.call(M.raw()).rest(M.raw()).returns(M.raw()), + onRejected: M.call(M.raw()).rest(M.raw()).returns(M.raw()), + }), + helper: M.interface('Helper', { + createVow: M.call(M.arrayOf(M.any()), M.boolean()).returns(VowShape), + processResult: M.call(M.raw()).rest(M.raw()).returns(M.undefined()), }), retryRejectionPromiseWatcher: PromiseWatcherI, }, @@ -68,6 +73,7 @@ export const prepareWatchUtils = ( * @property {number} remaining * @property {MapStore} resultsMap * @property {VowKit['resolver']} resolver + * @property {boolean} [isAllSettled] */ /** @type {MapStore} */ const idToVowState = detached.mapStore('idToVowState'); @@ -79,32 +85,83 @@ export const prepareWatchUtils = ( }, { utils: { + /** @param {unknown[]} specimens */ + all(specimens) { + return this.facets.helper.createVow(specimens, false); + }, + /** @param {unknown[]} specimens */ + allSettled(specimens) { + return /** @type {Vow<({status: 'fulfilled', value: any} | {status: 'rejected', reason: any})[]>} */ ( + this.facets.helper.createVow(specimens, true) + ); + }, + /** @type {AsPromiseFunction} */ + asPromise(specimenP, ...watcherArgs) { + // Watch the specimen in case it is an ephemeral promise. + const vow = watch(specimenP, ...watcherArgs); + const promise = when(vow); + // Watch the ephemeral result promise to ensure that if its settlement is + // lost due to upgrade of this incarnation, we will at least cause an + // unhandled rejection in the new incarnation. + zone.watchPromise(promise, this.facets.retryRejectionPromiseWatcher); + + return promise; + }, + }, + watcher: { /** - * @param {EVow[]} vows + * @param {unknown} value + * @param {object} ctx + * @param {bigint} ctx.id + * @param {number} ctx.index + * @param {number} ctx.numResults + * @param {boolean} ctx.isAllSettled */ - all(vows) { + onFulfilled(value, ctx) { + this.facets.helper.processResult(value, ctx, 'fulfilled'); + }, + /** + * @param {unknown} reason + * @param {object} ctx + * @param {bigint} ctx.id + * @param {number} ctx.index + * @param {number} ctx.numResults + * @param {boolean} ctx.isAllSettled + */ + onRejected(reason, ctx) { + this.facets.helper.processResult(reason, ctx, 'rejected'); + }, + }, + helper: { + /** + * @param {unknown[]} specimens + * @param {boolean} isAllSettled + */ + createVow(specimens, isAllSettled) { const { nextId: id, idToVowState } = this.state; /** @type {VowKit} */ const kit = makeVowKit(); - // Preserve the order of the vow results. - for (let index = 0; index < vows.length; index += 1) { - watch(vows[index], this.facets.watcher, { + // Preserve the order of the results. + for (let index = 0; index < specimens.length; index += 1) { + watch(specimens[index], this.facets.watcher, { id, index, - numResults: vows.length, + numResults: specimens.length, + isAllSettled, }); } - if (vows.length > 0) { + if (specimens.length > 0) { // Save the state until rejection or all fulfilled. this.state.nextId += 1n; idToVowState.init( id, harden({ resolver: kit.resolver, - remaining: vows.length, + remaining: specimens.length, resultsMap: detached.mapStore('resultsMap'), + isAllSettled, }), ); const idToNonStorableResults = provideLazyMap( @@ -119,27 +176,36 @@ export const prepareWatchUtils = ( } return kit.vow; }, - /** @type {AsPromiseFunction} */ - asPromise(specimenP, ...watcherArgs) { - // Watch the specimen in case it is an ephemeral promise. - const vow = watch(specimenP, ...watcherArgs); - const promise = when(vow); - // Watch the ephemeral result promise to ensure that if its settlement is - // lost due to upgrade of this incarnation, we will at least cause an - // unhandled rejection in the new incarnation. - zone.watchPromise(promise, this.facets.retryRejectionPromiseWatcher); - - return promise; - }, - }, - watcher: { - onFulfilled(value, { id, index, numResults }) { + /** + * @param {unknown} result + * @param {object} ctx + * @param {bigint} ctx.id + * @param {number} ctx.index + * @param {number} ctx.numResults + * @param {boolean} ctx.isAllSettled + * @param {'fulfilled' | 'rejected'} status + */ + processResult(result, { id, index, numResults, isAllSettled }, status) { const { idToVowState } = this.state; if (!idToVowState.has(id)) { // Resolution of the returned vow happened already. return; } const { remaining, resultsMap, resolver } = idToVowState.get(id); + if (!isAllSettled && status === 'rejected') { + // For 'all', we reject immediately on the first rejection + idToVowState.delete(id); + resolver.reject(result); + return; + } + + const possiblyWrappedResult = isAllSettled + ? harden({ + status, + [status === 'fulfilled' ? 'value' : 'reason']: result, + }) + : result; + const idToNonStorableResults = provideLazyMap( utilsToNonStorableResults, this.facets.utils, @@ -152,15 +218,16 @@ export const prepareWatchUtils = ( ); // Capture the fulfilled value. - if (zone.isStorable(value)) { - resultsMap.init(index, value); + if (zone.isStorable(possiblyWrappedResult)) { + resultsMap.init(index, possiblyWrappedResult); } else { - nonStorableResults.set(index, value); + nonStorableResults.set(index, possiblyWrappedResult); } const vowState = harden({ remaining: remaining - 1, resultsMap, resolver, + isAllSettled, }); if (vowState.remaining > 0) { idToVowState.set(id, vowState); @@ -177,9 +244,12 @@ export const prepareWatchUtils = ( results[i] = resultsMap.get(i); } else { numLost += 1; + results[i] = isAllSettled + ? { status: 'rejected', reason: 'Unstorable result was lost' } + : undefined; } } - if (numLost > 0) { + if (numLost > 0 && !isAllSettled) { resolver.reject( assert.error(X`${numLost} unstorable results were lost`), ); @@ -187,16 +257,6 @@ export const prepareWatchUtils = ( resolver.resolve(harden(results)); } }, - onRejected(value, { id, index: _index, numResults: _numResults }) { - const { idToVowState } = this.state; - if (!idToVowState.has(id)) { - // First rejection wins. - return; - } - const { resolver } = idToVowState.get(id); - idToVowState.delete(id); - resolver.reject(value); - }, }, retryRejectionPromiseWatcher: { onFulfilled(_result) {}, diff --git a/packages/vow/test/types.test-d.ts b/packages/vow/test/types.test-d.ts index 8a859348caf..6f2968a7631 100644 --- a/packages/vow/test/types.test-d.ts +++ b/packages/vow/test/types.test-d.ts @@ -16,3 +16,18 @@ expectType<(p1: number, p2: string) => Vow<{ someValue: 'bar' }>>( Promise.resolve({ someValue: 'bar' } as const), ), ); + +expectType< + Vow< + ( + | { status: 'fulfilled'; value: any } + | { status: 'rejected'; reason: any } + )[] + > +>( + vt.allSettled([ + Promise.resolve(1), + Promise.reject(new Error('test')), + Promise.resolve('hello'), + ]), +); diff --git a/packages/vow/test/watch-utils.test.js b/packages/vow/test/watch-utils.test.js index 13e5be07d5d..4a622f37195 100644 --- a/packages/vow/test/watch-utils.test.js +++ b/packages/vow/test/watch-utils.test.js @@ -1,4 +1,5 @@ // @ts-check +/* global setTimeout */ import test from 'ava'; import { makeHeapZone } from '@agoric/base-zone/heap.js'; @@ -6,21 +7,23 @@ import { E, getInterfaceOf } from '@endo/far'; import { prepareBasicVowTools } from '../src/tools.js'; -test('allVows waits for a single vow to complete', async t => { +const setTimeoutAmbient = setTimeout; + +test('vowTools.all waits for a single vow to complete', async t => { const zone = makeHeapZone(); - const { watch, when, allVows } = prepareBasicVowTools(zone); + const { watch, when, all } = prepareBasicVowTools(zone); const testPromiseP = Promise.resolve('promise'); const vowA = watch(testPromiseP); - const result = await when(allVows([vowA])); + const result = await when(all([vowA])); t.is(result.length, 1); t.is(result[0], 'promise'); }); -test('allVows waits for an array of vows to complete', async t => { +test('vowTools.all waits for an array of vows to complete', async t => { const zone = makeHeapZone(); - const { watch, when, allVows } = prepareBasicVowTools(zone); + const { watch, when, all } = prepareBasicVowTools(zone); const testPromiseAP = Promise.resolve('promiseA'); const testPromiseBP = Promise.resolve('promiseB'); @@ -29,14 +32,14 @@ test('allVows waits for an array of vows to complete', async t => { const vowB = watch(testPromiseBP); const vowC = watch(testPromiseCP); - const result = await when(allVows([vowA, vowB, vowC])); + const result = await when(all([vowA, vowB, vowC])); t.is(result.length, 3); t.like(result, ['promiseA', 'promiseB', 'promiseC']); }); -test('allVows returns vows in order', async t => { +test('vowTools.all returns vows in order', async t => { const zone = makeHeapZone(); - const { watch, when, allVows, makeVowKit } = prepareBasicVowTools(zone); + const { watch, when, all, makeVowKit } = prepareBasicVowTools(zone); const kit = makeVowKit(); const testPromiseAP = Promise.resolve('promiseA'); @@ -48,14 +51,14 @@ test('allVows returns vows in order', async t => { // test promie A and B should already be resolved. kit.resolver.resolve('promiseC'); - const result = await when(allVows([vowA, vowC, vowB])); + const result = await when(all([vowA, vowC, vowB])); t.is(result.length, 3); t.like(result, ['promiseA', 'promiseC', 'promiseB']); }); -test('allVows rejects upon first rejection', async t => { +test('vowTools.all rejects upon first rejection', async t => { const zone = makeHeapZone(); - const { watch, when, allVows } = prepareBasicVowTools(zone); + const { watch, when, all } = prepareBasicVowTools(zone); const testPromiseAP = Promise.resolve('promiseA'); const testPromiseBP = Promise.reject(Error('rejectedA')); @@ -70,60 +73,75 @@ test('allVows rejects upon first rejection', async t => { }, }); - await when(watch(allVows([vowA, vowB, vowC]), watcher)); + await when(watch(all([vowA, vowB, vowC]), watcher)); }); -test('allVows can accept vows awaiting other vows', async t => { +test('vowTools.all can accept vows awaiting other vows', async t => { const zone = makeHeapZone(); - const { watch, when, allVows } = prepareBasicVowTools(zone); + const { watch, when, all } = prepareBasicVowTools(zone); const testPromiseAP = Promise.resolve('promiseA'); const testPromiseBP = Promise.resolve('promiseB'); const vowA = watch(testPromiseAP); const vowB = watch(testPromiseBP); - const resultA = allVows([vowA, vowB]); + const resultA = all([vowA, vowB]); const testPromiseCP = Promise.resolve('promiseC'); const vowC = when(watch(testPromiseCP)); - const resultB = await when(allVows([resultA, vowC])); + const resultB = await when(all([resultA, vowC])); t.is(resultB.length, 2); t.like(resultB, [['promiseA', 'promiseB'], 'promiseC']); }); -test('allVows - works with just promises', async t => { +test('vowTools.all - works with just promises', async t => { const zone = makeHeapZone(); - const { when, allVows } = prepareBasicVowTools(zone); + const { when, all } = prepareBasicVowTools(zone); const result = await when( - allVows([Promise.resolve('promiseA'), Promise.resolve('promiseB')]), + all([Promise.resolve('promiseA'), Promise.resolve('promiseB')]), ); t.is(result.length, 2); t.like(result, ['promiseA', 'promiseB']); }); -test('allVows - watch promises mixed with vows', async t => { +test('vowTools.all - watch promises mixed with vows', async t => { const zone = makeHeapZone(); - const { watch, when, allVows } = prepareBasicVowTools(zone); + const { watch, when, all } = prepareBasicVowTools(zone); const testPromiseP = Promise.resolve('vow'); const vowA = watch(testPromiseP); - const result = await when(allVows([vowA, Promise.resolve('promise')])); + const result = await when(all([vowA, Promise.resolve('promise')])); t.is(result.length, 2); t.like(result, ['vow', 'promise']); }); -test('allVows can accept passable data (PureData)', async t => { +test('vowTools.all can accept passable data (PureData)', async t => { const zone = makeHeapZone(); - const { watch, when, allVows } = prepareBasicVowTools(zone); - - const testPromiseP = Promise.resolve('vow'); - const vowA = watch(testPromiseP); + const { when, all } = prepareBasicVowTools(zone); - const result = await when(allVows([vowA, 'string', 1n, { obj: true }])); + const result = await when( + all([Promise.resolve('promise'), 'string', 1n, { obj: true }]), + ); t.is(result.length, 4); - t.deepEqual(result, ['vow', 'string', 1n, { obj: true }]); + t.deepEqual(result, ['promise', 'string', 1n, { obj: true }]); +}); + +test('vowTools.all rejects on the first settled rejection', async t => { + const zone = makeHeapZone(); + const { when, all } = prepareBasicVowTools(zone); + + await t.throwsAsync( + when( + all([ + Promise.resolve('yes'), + Promise.reject(new Error('no')), + Promise.reject(new Error('no again')), + ]), + ), + { message: 'no' }, + ); }); const prepareAccount = zone => @@ -133,9 +151,9 @@ const prepareAccount = zone => }, }); -test('allVows supports Promise pipelining', async t => { +test('vowTools.all supports Promise pipelining', async t => { const zone = makeHeapZone(); - const { watch, when, allVows } = prepareBasicVowTools(zone); + const { watch, when, all } = prepareBasicVowTools(zone); // makeAccount returns a Promise const prepareLocalChain = makeAccount => { @@ -157,7 +175,7 @@ test('allVows supports Promise pipelining', async t => { const Localchain = prepareLocalChain(prepareAccount(zone)); const lcaP = E(Localchain).makeAccount(); - const results = await when(watch(allVows([lcaP, E(lcaP).getAddress()]))); + const results = await when(watch(all([lcaP, E(lcaP).getAddress()]))); t.is(results.length, 2); const [acct, address] = results; t.is(getInterfaceOf(acct), 'Alleged: Account'); @@ -168,9 +186,9 @@ test('allVows supports Promise pipelining', async t => { ); }); -test('allVows does NOT support Vow pipelining', async t => { +test('vowTools.all does NOT support Vow pipelining', async t => { const zone = makeHeapZone(); - const { watch, when, allVows } = prepareBasicVowTools(zone); + const { watch, when, all } = prepareBasicVowTools(zone); // makeAccount returns a Vow const prepareLocalChainVowish = makeAccount => { @@ -195,7 +213,7 @@ test('allVows does NOT support Vow pipelining', async t => { const lcaP = E(Localchain).makeAccount(); // @ts-expect-error Property 'getAddress' does not exist on type // 'EMethods & { payload: VowPayload; }>>'. - await t.throwsAsync(when(watch(allVows([lcaP, E(lcaP).getAddress()]))), { + await t.throwsAsync(when(watch(all([lcaP, E(lcaP).getAddress()]))), { message: 'target has no method "getAddress", has []', }); }); @@ -252,3 +270,109 @@ test('asPromise handles watcher arguments', async t => { t.is(result, 'watcher test'); t.true(watcherCalled); }); + +test('vowTools.all handles unstorable results', async t => { + const zone = makeHeapZone(); + const { watch, when, all } = prepareBasicVowTools(zone); + + const nonPassable = () => 'i am a function'; + + const specimenA = Promise.resolve('i am a promise'); + const specimenB = watch(nonPassable); + + const result = await when(all([specimenA, specimenB])); + t.is(result.length, 2); + t.is(result[0], 'i am a promise'); + t.is(result[1], nonPassable); + t.is(result[1](), 'i am a function'); +}); + +test('vowTools.allSettled handles mixed fulfilled and rejected vows', async t => { + const zone = makeHeapZone(); + const { watch, when, allSettled } = prepareBasicVowTools(zone); + + const vowA = watch(Promise.resolve('a')); + const vowB = watch(Promise.reject(new Error('b'))); + const vowC = watch(Promise.resolve('c')); + + const result = await when(allSettled([vowA, vowB, vowC])); + t.is(result.length, 3); + t.deepEqual(result[0], { status: 'fulfilled', value: 'a' }); + t.deepEqual(result[1], { + status: 'rejected', + reason: new Error('b'), + }); + t.deepEqual(result[2], { status: 'fulfilled', value: 'c' }); +}); + +test('vowTools.allSettled accepts any passables', async t => { + const zone = makeHeapZone(); + const { watch, when, allSettled } = prepareBasicVowTools(zone); + + const result = await when( + allSettled([ + watch(Promise.resolve('a')), + watch(Promise.reject(new Error('b'))), + Promise.resolve('c'), + 1n, + { foo: 'e' }, + new Error('f'), + 'g', + undefined, + ]), + ); + t.is(result.length, 8); + t.deepEqual(result[0], { status: 'fulfilled', value: 'a' }); + t.deepEqual(result[1], { + status: 'rejected', + reason: Error('b'), + }); + t.deepEqual(result[2], { status: 'fulfilled', value: 'c' }); + t.deepEqual(result[3], { status: 'fulfilled', value: 1n }); + t.deepEqual(result[4], { status: 'fulfilled', value: { foo: 'e' } }); + t.deepEqual(result[5], { status: 'fulfilled', value: Error('f') }); + t.deepEqual(result[6], { status: 'fulfilled', value: 'g' }); + t.deepEqual(result[7], { status: 'fulfilled', value: undefined }); +}); + +test('vowTools.allSettled returns vows in order', async t => { + const zone = makeHeapZone(); + const { watch, when, allSettled, makeVowKit } = prepareBasicVowTools(zone); + const kit = makeVowKit(); + + const vowA = watch(kit.vow); + const vowB = watch(Promise.resolve('b')); + const vowC = watch(Promise.reject(new Error('c'))); + const allSettledV = allSettled([vowA, vowB, vowC]); + setTimeoutAmbient(() => kit.resolver.resolve('a'), 250); + + const result = await when(allSettledV); + t.is(result.length, 3); + t.deepEqual(result[0], { status: 'fulfilled', value: 'a' }); + t.deepEqual(result[1], { status: 'fulfilled', value: 'b' }); + t.deepEqual(result[2], { + status: 'rejected', + reason: new Error('c'), + }); +}); + +test('vowTools.allSettled handles unstorable results', async t => { + const zone = makeHeapZone(); + const { watch, when, allSettled } = prepareBasicVowTools(zone); + + // it's not recommended to use non-passables with allVows or allSettled, + // but an attempt will be made to store the value + const nonPassable = () => 'im a function'; + t.is(zone.isStorable(nonPassable), false); + + const vowA = watch(Promise.resolve('a')); + const vowB = watch(nonPassable); + + const result = await when(allSettled([vowA, vowB])); + + t.is(result.length, 2); + t.deepEqual(result[0], { status: 'fulfilled', value: 'a' }); + t.deepEqual(result[1], { status: 'fulfilled', value: nonPassable }); + // @ts-expect-error narrowed in line above + t.is(result[1].value(), 'im a function'); +});