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

feat(async-flow): add more context to vow rejection #10359

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions packages/async-flow/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"test/**/*.test.*"
],
"require": [
"./test/console-recorder-shim.js",
"@endo/init/debug.js"
],
"timeout": "20m",
Expand Down
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'),
Copy link
Member

@mhofman mhofman Nov 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Besides changing to an ephemeral store, I think we shouldn't burden ourselves with weak collections and their gc costs since we have delimited lifetimes for these host call entries, and don't need to retain them past settlement. Since the replay membrane is keeping the result vow/promises live anyway, there is no reason to assume those might be dropped earlier either.

};
},
{
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
121 changes: 93 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,26 @@ export const makeReplayMembraneForTesting = ({
Fail`generation expected non-negative; got ${generation}`;
};

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

/**
* @param {Vow} vow
* @param {HostCall} hostCall
*/
const initHostCall = (vow, hostCall) => {
harden(hostCall);
if (!hostVowToCall) {
return;
}
const key = toPassableCap(vow);
if (hostVowToCall.has(key)) {
return;
}
hostVowToCall.init(key, hostCall);
};

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

/**
Expand Down Expand Up @@ -106,6 +141,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)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In what case would the map not have the cap?

const hostCall = hostVowToCall.get(hostVowCap);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason to leave the host call in the map after rejection? I think doFulfill should remove from the map as well.

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 +211,12 @@ 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);
isVow(hostResult) &&
initHostCall(hostResult, { target: hostTarget, method: optVerb });
Comment on lines +218 to +219
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should be able to do this on the guest side with the guest promise, right? Or possibly the callIndex / callStack ?

// Try converting here just to route the error correctly
hostToGuest(hostResult, `converting ${optVerb || 'host'} result`);
} catch (hostProblem) {
Expand Down Expand Up @@ -285,6 +342,11 @@ export const makeReplayMembraneForTesting = ({
throw Panic`internal: eventual send synchronously failed ${hostProblem}`;
}
try {
initHostCall(vow, {
target: hostTarget,
method: optVerb,
eventual: true,
});
Comment on lines +345 to +349
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah with eventual send it's gonna be more difficult to record on the guest side since we don't see the guest anymore.

/** @type {LogEntry} */
const entry = harden(['doReturn', callIndex, vow]);
log.pushEntry(entry);
Expand Down Expand Up @@ -568,32 +630,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
130 changes: 130 additions & 0 deletions packages/async-flow/src/template.js
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is all very neat, but I think I'd grok it better with some unit tests showing what each helper does.

Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* @import {HostCall} from './types.js';
*/

const { assign, defineProperty } = 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
* @param {string[]} [raw] optional raw strings, defaults to `strings`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

* @returns {TemplateStringsArray}
*/
export const makeTemplateStringsArray = (strings, raw = strings) => {
// Get the shape of the result object to match TemplateStringsArray.
const result = assign([...strings], { raw });

// Make the raw property non-enumerable.
defineProperty(result, 'raw', {
value: raw,
enumerable: false,
});
Comment on lines +19 to +25
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const result = assign([...strings], { raw });
// Make the raw property non-enumerable.
defineProperty(result, 'raw', {
value: raw,
enumerable: false,
});
const result = defineProperty([...strings], 'raw', {
value: raw,
enumerable: false,
});


return harden(result);
};
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) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am wondering if the ergonomics wouldn't be simpler with a wrapper for the original template tag function:

inlineArgs(X)`foo bar ${inlinedArg}`;

/** @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 {TemplateStringsArray | 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);
Loading
Loading