Skip to content

Commit

Permalink
feat(async-flow): add more context to vow rejection
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelfig committed Nov 13, 2024
1 parent fc58f8d commit 49b4c9f
Show file tree
Hide file tree
Showing 4 changed files with 226 additions and 31 deletions.
20 changes: 17 additions & 3 deletions packages/async-flow/src/async-flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export const prepareAsyncFlowTools = (outerZone, outerOptions = {}) => {
* @param {Zone} zone
* @param {string} tag
* @param {GuestAsyncFunc} guestAsyncFunc
* @param {{ startEager?: boolean }} [options]
* @param {{ startEager?: boolean, definitionStack?: string | Error}} [options]
*/
const prepareAsyncFlowKit = (zone, tag, guestAsyncFunc, options = {}) => {
typeof guestAsyncFunc === 'function' ||
Expand All @@ -118,6 +118,7 @@ export const prepareAsyncFlowTools = (outerZone, outerOptions = {}) => {
bijection, // membrane's guest-host mapping
outcomeKit: makeVowKit(), // outcome of activation as host vow
isDone: false, // persistently done
hostVowToCall: zone.detached().weakMapStore('hostVowToCall'),
};
},
{
Expand Down Expand Up @@ -165,7 +166,13 @@ export const prepareAsyncFlowTools = (outerZone, outerOptions = {}) => {
*/
restart(eager = startEager) {
const { state, facets } = this;
const { activationArgs, log, bijection, outcomeKit } = state;
const {
activationArgs,
log,
bijection,
outcomeKit,
hostVowToCall,
} = state;
const { flow, admin, wakeWatcher } = facets;

const startFlowState = flow.getFlowState();
Expand Down Expand Up @@ -198,6 +205,9 @@ export const prepareAsyncFlowTools = (outerZone, outerOptions = {}) => {
vowTools,
watchWake,
panic,
tag,
definitionStack: options.definitionStack,
hostVowToCall,
});
initMembrane(flow, membrane);
const guestArgs = membrane.hostToGuest(activationArgs);
Expand Down Expand Up @@ -486,7 +496,11 @@ export const prepareAsyncFlowTools = (outerZone, outerOptions = {}) => {
* @returns {HostOf<F>}
*/
const asyncFlow = (zone, tag, guestFunc, options = undefined) => {
const makeAsyncFlowKit = prepareAsyncFlowKit(zone, tag, guestFunc, options);
const definitionStack = Error('this stack');
const makeAsyncFlowKit = prepareAsyncFlowKit(zone, tag, guestFunc, {
...options,
definitionStack,
});
const hostFuncName = `${tag}_hostFlow`;

const wrapperFunc = /** @type {HostOf<F>} */ (
Expand Down
112 changes: 84 additions & 28 deletions packages/async-flow/src/replay-membrane.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
/* eslint-disable no-use-before-define */
import { isVow } from '@agoric/vow/src/vow-utils.js';
import { isVow, toPassableCap } from '@agoric/vow/src/vow-utils.js';
import { heapVowE } from '@agoric/vow/vat.js';
import { throwLabeled } from '@endo/common/throw-labeled.js';
import { Fail, X, b, makeError, q } from '@endo/errors';
import { Fail, X, annotateError, b, makeError, q } from '@endo/errors';
import { E } from '@endo/eventual-send';
import { getMethodNames } from '@endo/eventual-send/utils.js';
import { Far, Remotable, getInterfaceOf } from '@endo/pass-style';
import { makeConvertKit } from './convert.js';
import { makeEquate } from './equate.js';
import {
inlineTemplateArgs,
hostCallToTemplateArgs,
idTemplateTag,
} from './template.js';

/**
* @import {PromiseKit} from '@endo/promise-kit';
* @import {RemotableBrand} from '@endo/eventual-send';
* @import {Callable, Passable, PassableCap} from '@endo/pass-style';
* @import {Vow, VowTools, VowKit} from '@agoric/vow';
* @import {WeakMapStore} from '@agoric/store';
* @import {LogStore} from '../src/log-store.js';
* @import {Bijection} from '../src/bijection.js';
* @import {Host, HostVow, LogEntry, Outcome} from '../src/types.js';
* @import {Host, HostCall, HostVow, LogEntry, Outcome} from '../src/types.js';
*/

const { fromEntries, defineProperties, assign } = Object;
Expand All @@ -28,6 +34,9 @@ const { fromEntries, defineProperties, assign } = Object;
* @param {VowTools} arg.vowTools
* @param {(vowish: Promise | Vow) => void} arg.watchWake
* @param {(problem: Error) => never} arg.panic
* @param {string | Error} [arg.definitionStack]
* @param {string} arg.tag
* @param {WeakMapStore<PassableCap, HostCall>} [arg.hostVowToCall]
*/
export const makeReplayMembrane = arg => {
const noDunderArg = /** @type {typeof arg} */ (
Expand All @@ -43,6 +52,9 @@ export const makeReplayMembrane = arg => {
* @param {VowTools} arg.vowTools
* @param {(vowish: Promise | Vow) => void} arg.watchWake
* @param {(problem: Error) => never} arg.panic
* @param {string | Error} [arg.definitionStack]
* @param {string} arg.tag
* @param {WeakMapStore<PassableCap, HostCall>} [arg.hostVowToCall]
* @param {boolean} [arg.__eventualSendForTesting] CAVEAT: Only for async-flow tests
*/
export const makeReplayMembraneForTesting = ({
Expand All @@ -51,6 +63,9 @@ export const makeReplayMembraneForTesting = ({
vowTools,
watchWake,
panic,
definitionStack,
tag,
hostVowToCall,
__eventualSendForTesting,
}) => {
const { when, makeVowKit } = vowTools;
Expand All @@ -70,6 +85,10 @@ export const makeReplayMembraneForTesting = ({
Fail`generation expected non-negative; got ${generation}`;
};

const flowDescription = definitionStack
? idTemplateTag`${tag} defined at ${definitionStack}`
: tag;

// ////////////// Host or Interpreter to Guest ///////////////////////////////

/**
Expand Down Expand Up @@ -106,6 +125,25 @@ export const makeReplayMembraneForTesting = ({
Fail`doReject should only be called on a registered unresolved promise`;
}
const guestReason = hostToGuest(hostReason);
if (guestReason instanceof Error) {
annotateError(
guestReason,
X(...inlineTemplateArgs`from flow ${flowDescription}`),
);
if (hostVowToCall) {
const hostVowCap = toPassableCap(hostVow);
if (hostVowToCall.has(hostVowCap)) {
const hostCall = hostVowToCall.get(hostVowCap);
const hostDescription = hostCallToTemplateArgs(hostCall);
annotateError(
guestReason,
X(
...inlineTemplateArgs`host rejection from call to ${hostDescription}`,
),
);
}
}
}
status.reject(guestReason);
guestPromiseMap.set(guestPromise, 'settled');
};
Expand Down Expand Up @@ -157,9 +195,15 @@ export const makeReplayMembraneForTesting = ({
const performCall = (hostTarget, optVerb, hostArgs, callIndex) => {
let hostResult;
try {
hostResult = optVerb
? hostTarget[optVerb](...hostArgs)
: hostTarget(...hostArgs);
hostResult =
optVerb === undefined
? hostTarget(...hostArgs)
: hostTarget[optVerb](...hostArgs);
if (hostVowToCall && isVow(hostResult)) {
const hostCall = harden({ target: hostTarget, method: optVerb });
const hostVowCap = toPassableCap(hostResult);
hostVowToCall.init(hostVowCap, hostCall);
}
// Try converting here just to route the error correctly
hostToGuest(hostResult, `converting ${optVerb || 'host'} result`);
} catch (hostProblem) {
Expand Down Expand Up @@ -285,6 +329,15 @@ export const makeReplayMembraneForTesting = ({
throw Panic`internal: eventual send synchronously failed ${hostProblem}`;
}
try {
if (hostVowToCall) {
const hostCall = harden({
target: hostTarget,
method: optVerb,
eventual: true,
});
const hostVowCap = toPassableCap(vow);
hostVowToCall.init(hostVowCap, hostCall);
}
/** @type {LogEntry} */
const entry = harden(['doReturn', callIndex, vow]);
log.pushEntry(entry);
Expand Down Expand Up @@ -568,32 +621,35 @@ export const makeReplayMembraneForTesting = ({
hVow,
async hostFulfillment => {
await log.promiseReplayDone(); // should never reject
if (!stopped && guestPromiseMap.get(promiseKey) !== 'settled') {
/** @type {LogEntry} */
const entry = harden(['doFulfill', hVow, hostFulfillment]);
log.pushEntry(entry);
try {
interpretOne(topDispatch, entry);
} catch {
// interpretOne does its own try/catch/panic, so failure would
// already be registered. Here, just return to avoid the
// Unhandled rejection.
}
if (stopped || guestPromiseMap.get(promiseKey) === 'settled') {
return;
}
/** @type {LogEntry} */
const entry = harden(['doFulfill', hVow, hostFulfillment]);
log.pushEntry(entry);
try {
interpretOne(topDispatch, entry);
} catch {
// interpretOne does its own try/catch/panic, so failure would
// already be registered. Here, just return to avoid the
// Unhandled rejection.
}
},
async hostReason => {
await log.promiseReplayDone(); // should never reject
if (!stopped && guestPromiseMap.get(promiseKey) !== 'settled') {
/** @type {LogEntry} */
const entry = harden(['doReject', hVow, hostReason]);
log.pushEntry(entry);
try {
interpretOne(topDispatch, entry);
} catch {
// interpretOne does its own try/catch/panic, so failure would
// already be registered. Here, just return to avoid the
// Unhandled rejection.
}
if (stopped || guestPromiseMap.get(promiseKey) === 'settled') {
return;
}

/** @type {LogEntry} */
const entry = harden(['doReject', hVow, hostReason]);
log.pushEntry(entry);
try {
interpretOne(topDispatch, entry);
} catch {
// interpretOne does its own try/catch/panic, so failure would
// already be registered. Here, just return to avoid the
// Unhandled rejection.
}
},
);
Expand Down
119 changes: 119 additions & 0 deletions packages/async-flow/src/template.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* @import {HostCall} from './types.js';
*/

const { assign } = Object;

/** Doesn't need to be exhaustive, just a little prettier than JSON-quoting. */
const BEST_GUESS_ID_REGEX = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;

/**
* Return an object that mimics a template strings array.
*
* @param {string[]} strings
* @returns {TemplateStringsArray}
*/
export const makeTemplateStringsArray = strings =>
harden(assign([...strings], { raw: strings }));
harden(makeTemplateStringsArray);

/**
* When used as a template tag, this function returns its arguments verbatim.
*
* @template {any[]} A
* @param {A} allArgs
* @returns {A}
*/
export const idTemplateTag = (...allArgs) => allArgs;
harden(idTemplateTag);

/**
* Convert a replay membrane HostCall structure to template arguments.
*
* @param {HostCall} hostCall
* @returns {[TemplateStringsArray, ...any[]]}
*/
export const hostCallToTemplateArgs = ({ target, method, eventual }) => {
/** @type {string[]} */
const tmpl = [];

/** @type {any[]} */
const args = [];

const tpush = str => {
tmpl.push(str);
};
const tappend = str => {
tmpl[tmpl.length - 1] += str;
};

tpush(eventual ? 'E' : '');
tappend('(');
args.push(target);
tpush(')');
if (typeof method === 'string') {
if (BEST_GUESS_ID_REGEX.test(method)) {
tappend(`.${method}`);
} else {
tappend(`[${JSON.stringify(method)}]`);
}
} else if (method !== undefined) {
tappend(`[${String(method)}]`);
}
tappend('(...)');

return /** @type {const} */ ([makeTemplateStringsArray(tmpl), ...args]);
};
harden(hostCallToTemplateArgs);

/**
* Template tag to flatten any nested template arguments by joining them to the
* returned template strings and rest arguments.
*
* @param {TemplateStringsArray} tmpl
* @param {any[]} args
* @returns {[TemplateStringsArray, ...any[]]}
*/
export const inlineTemplateArgs = (tmpl, ...args) => {
/** @type {string[]} */
const itmpl = [tmpl[0]];
/** @type {any[]} */
const iargs = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
const nextStr = tmpl[i + 1];

// Could be a template and argument list.
const argLength = Array.isArray(arg) ? arg.length : 0;
/** @type {string[] & { raw: string[] } | undefined} */
const t = argLength && arg[0] ? arg[0] : undefined;
if (
!Array.isArray(t) ||
!Array.isArray(t.raw) ||
t.length !== argLength ||
t.raw.length !== argLength ||
!t.every(v => typeof v === 'string') ||
!t.raw.every(v => typeof v === 'string')
) {
// Not a template string array shape, so just push it.
iargs.push(arg);
nextStr === undefined || itmpl.push(nextStr);
continue;
}

// Join the current outer template string with the first inner one.
itmpl[itmpl.length - 1] += t[0];

// Push the rest of the inner template strings and arguments.
itmpl.push(...t.slice(1));
iargs.push(...arg.slice(1));

if (nextStr !== undefined) {
// Join the last inner template string with the next outer one.
itmpl[itmpl.length - 1] += nextStr;
}
}

return /** @type {const} */ ([makeTemplateStringsArray(itmpl), ...iargs]);
};
harden(inlineTemplateArgs);
6 changes: 6 additions & 0 deletions packages/async-flow/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export type FlowState =
export type Guest<T extends unknown = any> = T;
export type Host<T extends Passable = Passable> = T;

export type HostCall = {
target: any;
method: PropertyKey | undefined;
eventual?: boolean;
};

/**
* A HostVow must be durably storable. It corresponds to an
* ephemeral guest promise.
Expand Down

0 comments on commit 49b4c9f

Please sign in to comment.