diff --git a/packages/eventual-send/src/E.js b/packages/eventual-send/src/E.js index d9465e02e2..29ff9e7ad1 100644 --- a/packages/eventual-send/src/E.js +++ b/packages/eventual-send/src/E.js @@ -1,8 +1,11 @@ import { trackTurns } from './track-turns.js'; +import { makeMessageBreakpointTester } from './message-breakpoints.js'; const { details: X, quote: q, Fail } = assert; const { assign, create } = Object; +const onSend = makeMessageBreakpointTester('ENDO_SEND_BREAKPOINTS'); + /** @type {ProxyHandler} */ const baseFreezableProxyHandler = { set(_target, _prop, _value) { @@ -31,38 +34,55 @@ const baseFreezableProxyHandler = { /** * A Proxy handler for E(x). * - * @param {any} x Any value passed to E(x) + * @param {any} recipient Any value passed to E(x) * @param {import('./types').HandledPromiseConstructor} HandledPromise * @returns {ProxyHandler} the Proxy handler */ -const makeEProxyHandler = (x, HandledPromise) => +const makeEProxyHandler = (recipient, HandledPromise) => harden({ ...baseFreezableProxyHandler, - get: (_target, p, receiver) => { + get: (_target, propertyKey, receiver) => { return harden( { // This function purposely checks the `this` value (see above) // In order to be `this` sensitive it is defined using concise method // syntax rather than as an arrow function. To ensure the function // is not constructable, it also avoids the `function` syntax. - [p](...args) { + [propertyKey](...args) { if (this !== receiver) { // Reject the async function call return HandledPromise.reject( assert.error( - X`Unexpected receiver for "${p}" method of E(${q(x)})`, + X`Unexpected receiver for "${propertyKey}" method of E(${q( + recipient, + )})`, ), ); } - return HandledPromise.applyMethod(x, p, args); + if (onSend && onSend.shouldBreakpoint(recipient, propertyKey)) { + // eslint-disable-next-line no-debugger + debugger; // LOOK UP THE STACK + // Stopped at a breakpoint on eventual-send of a method-call + // message, + // so that you can walk back on the stack to see how we came to + // make this eventual-send + } + return HandledPromise.applyMethod(recipient, propertyKey, args); }, // @ts-expect-error https://github.com/microsoft/TypeScript/issues/50319 - }[p], + }[propertyKey], ); }, apply: (_target, _thisArg, argArray = []) => { - return HandledPromise.applyFunction(x, argArray); + if (onSend && onSend.shouldBreakpoint(recipient, undefined)) { + // eslint-disable-next-line no-debugger + debugger; // LOOK UP THE STACK + // Stopped at a breakpoint on eventual-send of a function-call message, + // so that you can walk back on the stack to see how we came to + // make this eventual-send + } + return HandledPromise.applyFunction(recipient, argArray); }, has: (_target, _p) => { // We just pretend everything exists. @@ -74,35 +94,50 @@ const makeEProxyHandler = (x, HandledPromise) => * A Proxy handler for E.sendOnly(x) * It is a variant on the E(x) Proxy handler. * - * @param {any} x Any value passed to E.sendOnly(x) + * @param {any} recipient Any value passed to E.sendOnly(x) * @param {import('./types').HandledPromiseConstructor} HandledPromise * @returns {ProxyHandler} the Proxy handler */ -const makeESendOnlyProxyHandler = (x, HandledPromise) => +const makeESendOnlyProxyHandler = (recipient, HandledPromise) => harden({ ...baseFreezableProxyHandler, - get: (_target, p, receiver) => { + get: (_target, propertyKey, receiver) => { return harden( { // This function purposely checks the `this` value (see above) // In order to be `this` sensitive it is defined using concise method // syntax rather than as an arrow function. To ensure the function // is not constructable, it also avoids the `function` syntax. - [p](...args) { + [propertyKey](...args) { // Throw since the function returns nothing this === receiver || - Fail`Unexpected receiver for "${q(p)}" method of E.sendOnly(${q( - x, - )})`; - HandledPromise.applyMethodSendOnly(x, p, args); + Fail`Unexpected receiver for "${q( + propertyKey, + )}" method of E.sendOnly(${q(recipient)})`; + if (onSend && onSend.shouldBreakpoint(recipient, propertyKey)) { + // eslint-disable-next-line no-debugger + debugger; // LOOK UP THE STACK + // Stopped at a breakpoint on eventual-send of a method-call + // message, + // so that you can walk back on the stack to see how we came to + // make this eventual-send + } + HandledPromise.applyMethodSendOnly(recipient, propertyKey, args); return undefined; }, // @ts-expect-error https://github.com/microsoft/TypeScript/issues/50319 - }[p], + }[propertyKey], ); }, apply: (_target, _thisArg, argsArray = []) => { - HandledPromise.applyFunctionSendOnly(x, argsArray); + if (onSend && onSend.shouldBreakpoint(recipient, undefined)) { + // eslint-disable-next-line no-debugger + debugger; // LOOK UP THE STACK + // Stopped at a breakpoint on eventual-send of a function-call message, + // so that you can walk back on the stack to see how we came to + // make this eventual-send + } + HandledPromise.applyFunctionSendOnly(recipient, argsArray); return undefined; }, has: (_target, _p) => { diff --git a/packages/eventual-send/src/local.js b/packages/eventual-send/src/local.js index f7610978ad..cfecf0b63f 100644 --- a/packages/eventual-send/src/local.js +++ b/packages/eventual-send/src/local.js @@ -1,3 +1,5 @@ +import { makeMessageBreakpointTester } from './message-breakpoints.js'; + const { details: X, quote: q, Fail } = assert; const { getOwnPropertyDescriptors, getPrototypeOf, freeze } = Object; @@ -5,6 +7,8 @@ const { apply, ownKeys } = Reflect; const ntypeof = specimen => (specimen === null ? 'null' : typeof specimen); +const onDelivery = makeMessageBreakpointTester('ENDO_DELIVERY_BREAKPOINTS'); + /** * TODO Consolidate with `isObject` that's currently in `@endo/marshal` * @@ -64,39 +68,63 @@ export const getMethodNames = val => { // ses creates `harden`, and so cannot rely on `harden` at top level. freeze(getMethodNames); -export const localApplyFunction = (t, args) => { - typeof t === 'function' || +export const localApplyFunction = (recipient, args) => { + typeof recipient === 'function' || assert.fail( - X`Cannot invoke target as a function; typeof target is ${q(ntypeof(t))}`, + X`Cannot invoke target as a function; typeof target is ${q( + ntypeof(recipient), + )}`, TypeError, ); - return apply(t, undefined, args); + if (onDelivery && onDelivery.shouldBreakpoint(recipient, undefined)) { + // eslint-disable-next-line no-debugger + debugger; // STEP INTO APPLY + // Stopped at a breakpoint on this delivery of an eventual function call + // so that you can step *into* the following `apply` in order to see the + // function call as it happens. Or step *over* to see what happens + // after the function call returns. + } + const result = apply(recipient, undefined, args); + return result; }; -export const localApplyMethod = (t, method, args) => { - if (method === undefined || method === null) { +export const localApplyMethod = (recipient, methodName, args) => { + if (methodName === undefined || methodName === null) { // Base case; bottom out to apply functions. - return localApplyFunction(t, args); + return localApplyFunction(recipient, args); } - if (t === undefined || t === null) { + if (recipient === undefined || recipient === null) { assert.fail( - X`Cannot deliver ${q(method)} to target; typeof target is ${q( - ntypeof(t), + X`Cannot deliver ${q(methodName)} to target; typeof target is ${q( + ntypeof(recipient), )}`, TypeError, ); } - const fn = t[method]; + const fn = recipient[methodName]; if (fn === undefined) { assert.fail( - X`target has no method ${q(method)}, has ${q(getMethodNames(t))}`, + X`target has no method ${q(methodName)}, has ${q( + getMethodNames(recipient), + )}`, TypeError, ); } const ftype = ntypeof(fn); typeof fn === 'function' || - Fail`invoked method ${q(method)} is not a function; it is a ${q(ftype)}`; - return apply(fn, t, args); + Fail`invoked method ${q(methodName)} is not a function; it is a ${q( + ftype, + )}`; + if (onDelivery && onDelivery.shouldBreakpoint(recipient, methodName)) { + // eslint-disable-next-line no-debugger + debugger; // STEP INTO APPLY + // Stopped at a breakpoint on this delivery of an eventual method call + // so that you can step *into* the following `apply` in order to see the + // method call as it happens. Or step *over* to see what happens + // after the method call returns. + } + const result = apply(fn, recipient, args); + return result; }; export const localGet = (t, key) => t[key]; diff --git a/packages/eventual-send/src/message-breakpoints.js b/packages/eventual-send/src/message-breakpoints.js new file mode 100644 index 0000000000..0278591f10 --- /dev/null +++ b/packages/eventual-send/src/message-breakpoints.js @@ -0,0 +1,179 @@ +import { getEnvironmentOption } from '@endo/env-options'; + +const { quote: q, Fail } = assert; + +const { hasOwn, freeze, entries } = Object; + +/** + * @typedef {string | '*'} MatchStringTag + * A star `'*'` matches any recipient. Otherwise, the string is + * matched against the value of a recipient's `@@toStringTag` + * after stripping out any leading `'Alleged: '` or `'DebugName: '` + * prefix. For objects defined with `Far` this is the first argument, + * known as the `farName`. For exos, this is the tag. + */ +/** + * @typedef {string | '*'} MatchMethodName + * A star `'*'` matches any method name. Otherwise, the string is + * matched against the method name. Currently, this is only an exact match. + * However, beware that we may introduce a string syntax for + * symbol method names. + */ +/** + * @typedef {number | '*'} MatchCountdown + * A star `'*'` will always breakpoint. Otherwise, the string + * must be a non-negative integer. Once that is zero, always breakpoint. + * Otherwise decrement by one each time it matches until it reaches zero. + * In other words, the countdown represents the number of + * breakpoint occurrences to skip before actually breakpointing. + */ + +/** + * This is the external JSON representation, in which + * - the outer property name is the class-like tag or '*', + * - the inner property name is the method name or '*', + * - the value is a non-negative integer countdown or '*'. + * + * @typedef {Record>} MessageBreakpoints + */ + +/** + * This is the internal JSON representation, in which + * - the outer property name is the method name or '*', + * - the inner property name is the class-like tag or '*', + * - the value is a non-negative integer countdown or '*'. + * + * @typedef {Record>} BreakpointTable + */ + +/** + * @typedef {object} MessageBreakpointTester + * @property {() => MessageBreakpoints} getBreakpoints + * @property {(newBreakpoints?: MessageBreakpoints) => void} setBreakpoints + * @property {( + * recipient: object, + * methodName: string | symbol | undefined + * ) => boolean} shouldBreakpoint + */ + +/** + * @param {any} val + * @returns {val is Record} + */ +const isJSONRecord = val => + typeof val === 'object' && val !== null && !Array.isArray(val); + +/** + * Return `tag` after stripping off any `'Alleged: '` or `'DebugName: '` + * prefix if present. + * ```js + * simplifyTag('Alleged: moola issuer') === 'moola issuer' + * ``` + * If there are multiple such prefixes, only the outer one is removed. + * + * @param {string} tag + * @returns {string} + */ +const simplifyTag = tag => { + for (const prefix of ['Alleged: ', 'DebugName: ']) { + if (tag.startsWith(prefix)) { + return tag.slice(prefix.length); + } + } + return tag; +}; + +/** + * @param {string} optionName + * @returns {MessageBreakpointTester | undefined} + */ +export const makeMessageBreakpointTester = optionName => { + let breakpoints = JSON.parse(getEnvironmentOption(optionName, 'null')); + + if (breakpoints === null) { + return undefined; + } + + /** @type {BreakpointTable} */ + let breakpointsTable; + + const getBreakpoints = () => breakpoints; + freeze(getBreakpoints); + + const setBreakpoints = (newBreakpoints = breakpoints) => { + isJSONRecord(newBreakpoints) || + Fail`Expected ${q(optionName)} option to be a JSON breakpoints record`; + + /** @type {BreakpointTable} */ + // @ts-expect-error confused by __proto__ + const newBreakpointsTable = { __proto__: null }; + + for (const [tag, methodBPs] of entries(newBreakpoints)) { + tag === simplifyTag(tag) || + Fail`Just use simple tag ${q(simplifyTag(tag))} rather than ${q(tag)}`; + isJSONRecord(methodBPs) || + Fail`Expected ${q(optionName)} option's ${q( + tag, + )} to be a JSON methods breakpoints record`; + for (const [methodName, count] of entries(methodBPs)) { + count === '*' || + (typeof count === 'number' && + Number.isSafeInteger(count) && + count >= 0) || + Fail`Expected ${q(optionName)} option's ${q(tag)}.${q( + methodName, + )} to be "*" or a non-negative integer`; + + const classBPs = hasOwn(newBreakpointsTable, methodName) + ? newBreakpointsTable[methodName] + : (newBreakpointsTable[methodName] = { + // @ts-expect-error confused by __proto__ + __proto__: null, + }); + classBPs[tag] = count; + } + } + breakpoints = newBreakpoints; + breakpointsTable = newBreakpointsTable; + }; + freeze(setBreakpoints); + + const shouldBreakpoint = (recipient, methodName) => { + if (methodName === undefined || methodName === null) { + // TODO enable function breakpointing + return false; + } + const classBPs = breakpointsTable[methodName] || breakpointsTable['*']; + if (classBPs === undefined) { + return false; + } + let tag = simplifyTag(recipient[Symbol.toStringTag]); + let count = classBPs[tag]; + if (count === undefined) { + tag = '*'; + count = classBPs[tag]; + if (count === undefined) { + return false; + } + } + if (count === '*') { + return true; + } + if (count === 0) { + return true; + } + assert(typeof count === 'number' && count >= 1); + classBPs[tag] = count - 1; + return false; + }; + freeze(shouldBreakpoint); + + const breakpointTester = freeze({ + getBreakpoints, + setBreakpoints, + shouldBreakpoint, + }); + breakpointTester.setBreakpoints(); + return breakpointTester; +}; +freeze(makeMessageBreakpointTester); diff --git a/packages/eventual-send/utils.js b/packages/eventual-send/utils.js index 4fee534df0..3ab91beff8 100644 --- a/packages/eventual-send/utils.js +++ b/packages/eventual-send/utils.js @@ -1 +1,2 @@ export { getMethodNames } from './src/local.js'; +export { makeMessageBreakpointTester } from './src/message-breakpoints.js'; diff --git a/packages/pass-style/test/prepare-breakpoints.js b/packages/pass-style/test/prepare-breakpoints.js new file mode 100644 index 0000000000..4472fae09b --- /dev/null +++ b/packages/pass-style/test/prepare-breakpoints.js @@ -0,0 +1,21 @@ +/* global process */ + +process.env.ENDO_DELIVERY_BREAKPOINTS = `{ + "Bob": { + "foo": "*" + }, + "*": { + "bar": 0 + } +}`; + +process.env.ENDO_SEND_BREAKPOINTS = `{ + "Bob": { + "foo": "*", + "zap": 3 + }, + "*": { + "bar": 0, + "zip": 3 + } +}`; diff --git a/packages/pass-style/test/test-message-breakpoints-demo.js b/packages/pass-style/test/test-message-breakpoints-demo.js new file mode 100644 index 0000000000..3fc8905331 --- /dev/null +++ b/packages/pass-style/test/test-message-breakpoints-demo.js @@ -0,0 +1,28 @@ +import './prepare-breakpoints.js'; +import { test } from './prepare-test-env-ava.js'; + +// eslint-disable-next-line import/order +import { E } from '@endo/eventual-send'; +import { Far } from '../src/make-far.js'; + +// Example from test-deep-send.js in @endo/eventual-send + +const carol = Far('Carol', { + bar: () => console.log('Wut?'), +}); + +const bob = Far('Bob', { + foo: carolP => E(carolP).bar(), +}); + +const alice = Far('Alice', { + test: () => E(bob).foo(carol), +}); + +// This is not useful as an automated test. Its purpose is to run it under a +// debugger and see where it breakpoints. To play with it, adjust the +// settings in prepare-breakpoints.js and try again. +test('test breakpoints on delivery', async t => { + await alice.test(); + t.pass('introduced'); +}); diff --git a/packages/pass-style/test/test-message-breakpoints.js b/packages/pass-style/test/test-message-breakpoints.js new file mode 100644 index 0000000000..00cc794e3c --- /dev/null +++ b/packages/pass-style/test/test-message-breakpoints.js @@ -0,0 +1,104 @@ +import './prepare-breakpoints.js'; +// eslint-disable-next-line import/order +import { test } from './prepare-test-env-ava.js'; + +import { makeMessageBreakpointTester } from '@endo/eventual-send/utils.js'; +import { E } from '@endo/eventual-send'; + +import { Far } from '../src/make-far.js'; + +const { values } = Object; + +// Example from test-deep-send.js in @endo/eventual-send + +const carol = Far('Carol', { + bar: () => console.log('Wut?'), +}); + +const bob = Far('Bob', { + foo: carolP => E(carolP).bar(), +}); + +const alice = Far('Alice', { + test: () => E(bob).foo(carol), +}); + +const onSend = makeMessageBreakpointTester('ENDO_SEND_BREAKPOINTS'); + +test('message breakpoint tester', t => { + t.is(onSend.shouldBreakpoint(alice, 'test'), false); + t.is(onSend.shouldBreakpoint(bob, 'test'), false); + t.is(onSend.shouldBreakpoint(bob, 'foo'), true); + t.is(onSend.shouldBreakpoint(alice, 'bar'), true); + + t.is(onSend.shouldBreakpoint(bob, 'zap'), false); + t.is(onSend.shouldBreakpoint(alice, 'zap'), false); + t.is(onSend.shouldBreakpoint(carol, 'zap'), false); + t.is(onSend.shouldBreakpoint(alice, 'zap'), false); + t.is(onSend.shouldBreakpoint(bob, 'zap'), false); + t.is(onSend.shouldBreakpoint(bob, 'zap'), false); + t.is(onSend.shouldBreakpoint(bob, 'zap'), true); + t.is(onSend.shouldBreakpoint(bob, 'zap'), true); + + t.is(onSend.shouldBreakpoint(bob, 'zip'), false); + t.is(onSend.shouldBreakpoint(alice, 'zip'), false); + t.is(onSend.shouldBreakpoint(carol, 'zip'), false); + t.is(onSend.shouldBreakpoint(alice, 'zip'), true); + t.is(onSend.shouldBreakpoint(bob, 'zip'), true); + + onSend.setBreakpoints(); // Should refresh the counts + + t.is(onSend.shouldBreakpoint(bob, 'zip'), false); + t.is(onSend.shouldBreakpoint(alice, 'zip'), false); + t.is(onSend.shouldBreakpoint(carol, 'zip'), false); + t.is(onSend.shouldBreakpoint(alice, 'zip'), true); + t.is(onSend.shouldBreakpoint(bob, 'zip'), true); + + // Approx what you would do interactively + const bps = onSend.getBreakpoints(); + t.is( + values(bps).some(meths => '*' in meths), + false, + ); + bps.Bob['*'] = 3; + bps['*']['*'] = 1; + onSend.setBreakpoints(); + const bps2 = onSend.getBreakpoints(); + t.is( + values(bps2).every(meths => '*' in meths), + true, + ); + + t.is(onSend.shouldBreakpoint(alice, 'test'), false); + t.is(onSend.shouldBreakpoint(bob, 'test'), false); + t.is(onSend.shouldBreakpoint(alice, 'test'), true); + t.is(onSend.shouldBreakpoint(bob, 'test'), false); + t.is(onSend.shouldBreakpoint(alice, 'test'), true); + t.is(onSend.shouldBreakpoint(bob, 'test'), false); + t.is(onSend.shouldBreakpoint(alice, 'test'), true); + t.is(onSend.shouldBreakpoint(bob, 'test'), true); + + onSend.setBreakpoints(); + + t.is(onSend.shouldBreakpoint(alice, 'test'), false); + t.is(onSend.shouldBreakpoint(bob, 'test'), false); + t.is(onSend.shouldBreakpoint(alice, 'test'), true); +}); + +test('message breakpoint validation', t => { + t.throws(() => onSend.setBreakpoints([]), { + message: + 'Expected "ENDO_SEND_BREAKPOINTS" option to be a JSON breakpoints record', + }); + t.throws(() => onSend.setBreakpoints({ 'Alleged: Bob': {} }), { + message: 'Just use simple tag "Bob" rather than "Alleged: Bob"', + }); + t.throws(() => onSend.setBreakpoints({ Bob: 3 }), { + message: + 'Expected "ENDO_SEND_BREAKPOINTS" option\'s "Bob" to be a JSON methods breakpoints record', + }); + t.throws(() => onSend.setBreakpoints({ Bob: { foo: 3n } }), { + message: + 'Expected "ENDO_SEND_BREAKPOINTS" option\'s "Bob"."foo" to be "*" or a non-negative integer', + }); +});