diff --git a/packages/async-flow/src/async-flow.js b/packages/async-flow/src/async-flow.js index 25625dcf0826..af233e0360a9 100644 --- a/packages/async-flow/src/async-flow.js +++ b/packages/async-flow/src/async-flow.js @@ -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' || @@ -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'), }; }, { @@ -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(); @@ -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); @@ -486,7 +496,11 @@ export const prepareAsyncFlowTools = (outerZone, outerOptions = {}) => { * @returns {HostOf} */ 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} */ ( diff --git a/packages/async-flow/src/replay-membrane.js b/packages/async-flow/src/replay-membrane.js index 57aa83aef9b0..88cc7850867c 100644 --- a/packages/async-flow/src/replay-membrane.js +++ b/packages/async-flow/src/replay-membrane.js @@ -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; @@ -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} [arg.hostVowToCall] */ export const makeReplayMembrane = arg => { const noDunderArg = /** @type {typeof arg} */ ( @@ -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} [arg.hostVowToCall] * @param {boolean} [arg.__eventualSendForTesting] CAVEAT: Only for async-flow tests */ export const makeReplayMembraneForTesting = ({ @@ -51,6 +63,9 @@ export const makeReplayMembraneForTesting = ({ vowTools, watchWake, panic, + definitionStack, + tag, + hostVowToCall, __eventualSendForTesting, }) => { const { when, makeVowKit } = vowTools; @@ -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 /////////////////////////////// /** @@ -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'); }; @@ -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) { @@ -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); @@ -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. } }, ); diff --git a/packages/async-flow/src/template.js b/packages/async-flow/src/template.js new file mode 100644 index 000000000000..6878a796cc60 --- /dev/null +++ b/packages/async-flow/src/template.js @@ -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); diff --git a/packages/async-flow/src/types.ts b/packages/async-flow/src/types.ts index c9b005333926..30cce92faa87 100644 --- a/packages/async-flow/src/types.ts +++ b/packages/async-flow/src/types.ts @@ -22,6 +22,12 @@ export type FlowState = export type Guest = T; export type Host = T; +export type HostCall = { + target: any; + method: PropertyKey | undefined; + eventual?: boolean; +}; + /** * A HostVow must be durably storable. It corresponds to an * ephemeral guest promise.