Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(vow): report unhandled vow rejections on collection or upgrade #10686

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,7 @@ Generated by [AVA](https://avajs.dev).
AdminRetryableFlow_singleton: 'Alleged: AdminRetryableFlow',
PromiseWatcher_kindHandle: 'Alleged: kind',
VowInternalsKit_kindHandle: 'Alleged: kind',
VowRejectionTrackerKit_kindHandle: 'Alleged: kind',
WatchUtils_kindHandle: 'Alleged: kind',
retryableFlowForOutcomeVow: {},
},
Expand Down
Binary file not shown.
3 changes: 2 additions & 1 deletion packages/orchestration/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"codegen": "scripts/fetch-chain-info.ts",
"prepack": "tsc --build tsconfig.build.json",
"postpack": "git clean -f '*.d.ts*' '*.tsbuildinfo'",
"test": "ava",
"test": "AGORIC_AVA_ALLOW_UNHANDLED_REJECTIONS=1 ava",
"test:rejection": "ava",
"test:c8": "c8 --all $C8_OPTIONS ava",
"test:xs": "exit 0",
"lint": "run-s --continue-on-error lint:*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1208,6 +1208,7 @@ Generated by [AVA](https://avajs.dev).
AdminRetryableFlow_singleton: 'Alleged: AdminRetryableFlow',
PromiseWatcher_kindHandle: 'Alleged: kind',
VowInternalsKit_kindHandle: 'Alleged: kind',
VowRejectionTrackerKit_kindHandle: 'Alleged: kind',
WatchUtils_kindHandle: 'Alleged: kind',
retryableFlowForOutcomeVow: {},
},
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ Generated by [AVA](https://avajs.dev).
AdminRetryableFlow_singleton: 'Alleged: AdminRetryableFlow',
PromiseWatcher_kindHandle: 'Alleged: kind',
VowInternalsKit_kindHandle: 'Alleged: kind',
VowRejectionTrackerKit_kindHandle: 'Alleged: kind',
WatchUtils_kindHandle: 'Alleged: kind',
retryableFlowForOutcomeVow: {},
},
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ Generated by [AVA](https://avajs.dev).
AdminRetryableFlow_singleton: 'Alleged: AdminRetryableFlow',
PromiseWatcher_kindHandle: 'Alleged: kind',
VowInternalsKit_kindHandle: 'Alleged: kind',
VowRejectionTrackerKit_kindHandle: 'Alleged: kind',
WatchUtils_kindHandle: 'Alleged: kind',
retryableFlowForOutcomeVow: {},
},
Expand Down
Binary file not shown.
2 changes: 1 addition & 1 deletion packages/vow/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"build": "exit 0",
"prepack": "tsc --build tsconfig.build.json",
"postpack": "git clean -f '*.d.ts*' '*.tsbuildinfo'",
"test": "ava",
"test": "AGORIC_AVA_ALLOW_UNHANDLED_REJECTIONS=1 ava",
"test:xs": "exit 0",
"lint-fix": "yarn lint:eslint --fix",
"lint": "run-s --continue-on-error lint:*",
Expand Down
97 changes: 97 additions & 0 deletions packages/vow/src/rejection-tracker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// @ts-check
import { Fail, X } from '@endo/errors';
import { makePromiseKit } from '@endo/promise-kit';
import { M } from '@endo/patterns';

import { sink } from './vow-utils.js';

/**
* @import {PromiseKit} from '@endo/promise-kit';
* @import {Zone} from '@agoric/base-zone';
* @import {VowV0} from './types.js';
*/

/**
* @param {Promise<void>} [rejected]
* @param {PromiseKit<void>} [upgradedPK]
*/
const makeUnhandledRejection = (rejected, upgradedPK) => {
return () => {
// Handle the rejected promise to silence it.
rejected?.catch(sink);
// Resolve the upgraded promise to prevent it from being rejected by a
// future upgrade.
upgradedPK?.resolve();
};
};

const VowCapShape = M.remotable('VowCap');
const VowRejectionTrackerKitI = {
public: M.interface('VowRejectionTracker', {
handle: M.call(VowCapShape).returns(),
reject: M.call(VowCapShape, M.raw(), M.promise()).returns(),
}),
upgradeRejectionWatcher: M.interface('UpgradeRejectionWatcher', {
onRejected: M.call(M.raw(), M.raw()).returns(),
}),
};

/** @param {Zone} zone */
export const prepareVowRejectionTracker = zone => {
/** @type {WeakMap<VowV0, () => void>} */
const vowToCancelUnhandledRejection = new WeakMap();

const makeVowRejectionTrackerKit = zone.exoClassKit(
'VowRejectionTrackerKit',
VowRejectionTrackerKitI,
() => ({}),
{
public: {
handle(vowV0) {
const cancel = vowToCancelUnhandledRejection.get(vowV0);
if (!cancel) {
throw Fail`vow ${vowV0} already handled`;
}
cancel();
},
/**
*
* @param {VowV0} vowV0
* @param {any} reason
* @param {Promise<void>} [rejected]
*/
reject(vowV0, reason, rejected = Promise.reject(reason)) {
const { upgradeRejectionWatcher } = this.facets;
!vowToCancelUnhandledRejection.has(vowV0) ||
Fail`vow ${vowV0} already rejected`;

// Register a never-resolved native promise with liveslots, so it
// can reject on upgrade.
const upgradedPK = makePromiseKit();
zone.watchPromise(
upgradedPK.promise,
upgradeRejectionWatcher,
reason,
);

// Save the cancel function.
const cancel = makeUnhandledRejection(rejected, upgradedPK);
vowToCancelUnhandledRejection.set(vowV0, cancel);
},
},
upgradeRejectionWatcher: {
onRejected(upgradeReason, baseReason) {
if (baseReason instanceof Error) {
assert.note(baseReason, X`upgraded: ${upgradeReason}`);
}
throw baseReason;
},
},
},
);

const makeVowRejectionTracker = () => makeVowRejectionTrackerKit().public;
return makeVowRejectionTracker;
};

/** @typedef {ReturnType<ReturnType<typeof prepareVowRejectionTracker>>} VowRejectionTracker */
4 changes: 3 additions & 1 deletion packages/vow/src/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { makeAsVow } from './vow-utils.js';
import { prepareVowKit } from './vow.js';
import { prepareWatchUtils } from './watch-utils.js';
import { prepareWatch } from './watch.js';
import { prepareVowRejectionTracker } from './rejection-tracker.js';
import { prepareRetryableTools } from './retryable.js';
import { makeWhen } from './when.js';

Expand All @@ -24,7 +25,8 @@ import { makeWhen } from './when.js';
export const prepareBasicVowTools = (zone, powers = {}) => {
const { isRetryableReason = /** @type {IsRetryableReason} */ (() => false) } =
powers;
const makeVowKit = prepareVowKit(zone);
const makeVowRejectionTracker = prepareVowRejectionTracker(zone);
const makeVowKit = prepareVowKit(zone, makeVowRejectionTracker());
const when = makeWhen(isRetryableReason);
const watch = prepareWatch(zone, makeVowKit, isRetryableReason);
const makeWatchUtils = prepareWatchUtils(zone, {
Expand Down
3 changes: 3 additions & 0 deletions packages/vow/src/vow-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import { M, matches } from '@endo/patterns';
* @import {MakeVowKit} from './vow.js';
*/

export const sink = () => {};
harden(sink);

export { basicE };

export const VowShape = M.tagged(
Expand Down
44 changes: 23 additions & 21 deletions packages/vow/src/vow.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,20 @@ const { details: X } = assert;
/**
* @import {PromiseKit} from '@endo/promise-kit';
* @import {Zone} from '@agoric/base-zone';
* @import {MapStore} from '@agoric/store';
* @import {VowResolver, VowKit} from './types.js';
* @import {VowRejectionTracker} from './rejection-tracker.js';
*/

const sink = () => {};
harden(sink);

/**
* @typedef {Partial<PromiseKit<any>> &
* Pick<PromiseKit<any>, 'promise'>} VowEphemera
*/

/**
* @param {Zone} zone
* @param {VowRejectionTracker} [vowRejectionTracker]
*/
export const prepareVowKit = zone => {
export const prepareVowKit = (zone, vowRejectionTracker) => {
/** @type {WeakMap<VowResolver, VowEphemera>} */
const resolverToEphemera = new WeakMap();

Expand All @@ -43,7 +41,6 @@ export const prepareVowKit = zone => {
}

pk = makePromiseKit();
pk.promise.catch(sink); // silence unhandled rejection
resolverToEphemera.set(resolver, pk);
return pk;
};
Expand Down Expand Up @@ -79,11 +76,7 @@ export const prepareVowKit = zone => {
null
),
isStoredValue: /** @type {boolean} */ (false),
/**
* Map for future properties that aren't in the schema.
* UNTIL https://github.com/Agoric/agoric-sdk/issues/7407
* @type {MapStore<any, any> | undefined}
*/
vowIsHandled: /** @type {boolean} */ (false),
extra: undefined,
}),
{
Expand All @@ -92,11 +85,14 @@ export const prepareVowKit = zone => {
* @returns {Promise<any>}
*/
async shorten() {
const { stepStatus, isStoredValue, value } = this.state;
const { resolver } = this.facets;
const { stepStatus, isStoredValue, value, vowIsHandled } = this.state;
const { resolver, vowV0 } = this.facets;

switch (stepStatus) {
case 'fulfilled': {
if (vowIsHandled === false) {
this.state.vowIsHandled = true;
}
if (isStoredValue) {
// Always return a stored fulfilled value.
return value;
Expand All @@ -109,12 +105,15 @@ export const prepareVowKit = zone => {
throw value;
}
case 'rejected': {
if (!isStoredValue && resolverToNonStoredValue.has(resolver)) {
// Non-stored reason is available.
throw resolverToNonStoredValue.get(resolver);
if (vowIsHandled === false) {
this.state.vowIsHandled = true;
vowRejectionTracker?.handle(vowV0);
}
// Always throw a stored rejection reason.
throw value;
const reason = resolverToNonStoredValue.has(resolver)
? resolverToNonStoredValue.get(resolver)
: value;

throw reason;
}
case null:
case 'pending':
Expand Down Expand Up @@ -178,22 +177,25 @@ export const prepareVowKit = zone => {
}
},
onRejected(reason) {
const { resolver } = this.facets;
const { reject } = getPromiseKitForResolution(resolver);
const { resolver, vowV0 } = this.facets;
const { reject, promise } = getPromiseKitForResolution(resolver);
harden(reason);
if (reject) {
reject(reason);
}
if (this.state.vowIsHandled === false) {
vowRejectionTracker?.reject(vowV0, reason, promise);
}
this.state.stepStatus = 'rejected';
this.state.isStoredValue = zone.isStorable(reason);
if (this.state.isStoredValue) {
this.state.value = reason;
} else {
resolverToNonStoredValue.set(resolver, reason);
this.state.value = assert.error(
X`Vow rejection reason was not stored: ${reason}`,
);
}
resolverToNonStoredValue.set(resolver, reason);
},
},
},
Expand Down
36 changes: 23 additions & 13 deletions packages/vow/src/watch.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const { apply } = Reflect;

/**
* @import { PromiseWatcher, Zone } from '@agoric/base-zone';
* @import { ERef, EVow, IsRetryableReason, Vow, VowKit, VowResolver, Watcher } from './types.js';
* @import { EVow, IsRetryableReason, VowKit, VowResolver, Watcher } from './types.js';
*/

/**
Expand Down Expand Up @@ -40,7 +40,8 @@ const makeWatchNextStep =
* @param {unknown} value
* @param {unknown[]} [watcherArgs]
*/
const settle = (resolver, watcher, wcb, value, watcherArgs = []) => {
const settle = async (resolver, watcher, wcb, value, watcherArgs = []) => {
await null;
try {
let chainedValue = value;
const w = watcher && watcher[wcb];
Expand All @@ -49,14 +50,21 @@ const settle = (resolver, watcher, wcb, value, watcherArgs = []) => {
} else if (wcb === 'onRejected') {
throw value;
}
resolver && resolver.resolve(chainedValue);

if (resolver) {
resolver.resolve(chainedValue);
return;
}

await chainedValue;
} catch (e) {
if (resolver) {
resolver.reject(e);
} else {
// for host's unhandled rejection handler to catch
throw e;
return;
}

// Create a native unhandled rejection.
throw e;
}
};

Expand Down Expand Up @@ -123,26 +131,27 @@ const preparePromiseWatcher = (zone, isRetryableReason, watchNextStep) => {
this.state.priorRetryValue = undefined;
this.state.watcher = undefined;
this.state.resolver = undefined;
settle(resolver, watcher, 'onFulfilled', value, watcherArgs);
void settle(resolver, watcher, 'onFulfilled', value, watcherArgs);
},
/** @type {Required<PromiseWatcher>['onRejected']} */
onRejected(reason) {
const { vow, watcher, watcherArgs, resolver, priorRetryValue } =
this.state;
if (vow) {
const retryValue = isRetryableReason(reason, priorRetryValue);
if (retryValue) {
const retryValue = isRetryableReason(reason, priorRetryValue);
if (retryValue) {
this.state.priorRetryValue = retryValue;
if (vow) {
// Retry the same specimen.
this.state.priorRetryValue = retryValue;
watchNextStep(vow, this.self);
return;
}
}
// Final rejection.
watcherSeenPayloads.delete(this.self);
this.state.priorRetryValue = undefined;
this.state.resolver = undefined;
this.state.watcher = undefined;
settle(resolver, watcher, 'onRejected', reason, watcherArgs);
void settle(resolver, watcher, 'onRejected', reason, watcherArgs);
},
},
);
Expand Down Expand Up @@ -185,7 +194,8 @@ export const prepareWatch = (
const promiseWatcher = makePromiseWatcher(resolver, watcher, watcherArgs);

// Coerce the specimen to a promise, and start the watcher cycle.
zone.watchPromise(basicE.resolve(specimenP), promiseWatcher);
const promise = basicE.resolve(specimenP);
zone.watchPromise(promise, promiseWatcher);

return vow;
};
Expand Down
4 changes: 3 additions & 1 deletion packages/vow/test/retryable.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js';
import { prepareVowKit } from '../src/vow.js';
import { isVow } from '../src/vow-utils.js';
import { prepareRetryableTools } from '../src/retryable.js';
import { prepareVowRejectionTracker } from '../src/rejection-tracker.js';
import { makeWhen } from '../src/when.js';

/**
Expand All @@ -21,7 +22,8 @@ import { makeWhen } from '../src/when.js';
*/
const makeTestTools = ({ isRetryableReason = () => false } = {}) => {
const zone = makeHeapZone();
const makeVowKit = prepareVowKit(zone);
const makeVowRejectionTracker = prepareVowRejectionTracker(zone);
const makeVowKit = prepareVowKit(zone, makeVowRejectionTracker());
const when = makeWhen(isRetryableReason);

const { retryable, adminRetryableFlow } = prepareRetryableTools(zone, {
Expand Down
Loading
Loading