Skip to content

Commit

Permalink
refactor(exo): shorten exo-call fastpath (#2247)
Browse files Browse the repository at this point in the history
closes: #XXXX
refs: #XXXX

## Description

Should be a pure refactor without any[1] observable differences. Shorten
the exo-call fastpath for the normal thisful case
- primarily to make stepping into an exo method more pleasant with fewer
clicks and visual context switches
- probably also has a performance advantage for the normal thisful case.

By "thisful", I mean the exo objects defined with the normal exo-making,
exo-class-making, and exo-class-kit-making APIs, whether directly or via
zones. The non-thisful case is only the deprecated virtual and durable
kinds, which are not yet gone and so must still be supported. But have
only the non-thisful case pay for the arg shifting, rather than making
all calls pay for that.

The previous debug experience involved two layers of wrapping function
to get from the external call to a method of an exo into the exo's own
behavior method. This PR reduces that to one level of wrapper. The
effectively step into from the call into the actual behavior method
before and after this PR
- "step into" (new page) "step over", "step into" (new page) "step over"
"step over" "step into" (new page)
- "step into" (new page) "step over" "step over" "step into" (new page)

There is also one less exo infrastructure frame on the call stack. In
the debugger, the remaining stack frame is labeled


![image](https://github.com/endojs/endo/assets/273868/18920693-a9dc-4c07-af29-1bbe8a77b8a7)

vs


![image](https://github.com/endojs/endo/assets/273868/0cb7bbd3-51b3-4187-874c-68dff170f1fe)

Also less noise in verbose error stacks.

### Security Considerations

none. The safety checks should be exactly the same.
### Scaling Considerations

Probably has a performance advantage for the normal thisful case. But we
won't know until we measure. We're putting this into review before
measuring though, since the main motivation is the better debugging
experience.

### Documentation Considerations

none
### Testing Considerations

[1] The refactoring did result in an error message changing, requiring a
corresponding change in a golden error message test.

The tests in endo only test the normal thisful case. We depend on
integration testing with agoric-sdk to test the non-thisful case.

### Compatibility Considerations

none, given that the integration test with agoric-sdk confirms we did
not break the non-thisful case.

### Upgrade Considerations

none.

- ~[ ] Includes `*BREAKING*:` in the commit message with migration
instructions for any breaking change.~
- ~[ ] Updates `NEWS.md` for user-facing changes.~
  • Loading branch information
erights committed Apr 29, 2024
1 parent 31f055b commit f48d376
Show file tree
Hide file tree
Showing 2 changed files with 111 additions and 85 deletions.
169 changes: 97 additions & 72 deletions packages/exo/src/exo-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { GET_INTERFACE_GUARD } from './get-interface.js';

/**
* @import {InterfaceGuard, Method, MethodGuard, MethodGuardPayload} from '@endo/patterns'
* @import {ContextProvider, FacetName, KitContextProvider, MatchConfig, Methods} from './types.js'
* @import {ClassContext, ContextProvider, FacetName, KitContext, KitContextProvider, MatchConfig, Methods} from './types.js'
*/

const { apply, ownKeys } = Reflect;
Expand Down Expand Up @@ -146,21 +146,28 @@ const buildMatchConfig = methodGuardPayload => {
};

/**
* @param {Method} method
* @param {(representative: any) => ClassContext | KitContext} getContext
* @param {CallableFunction} behaviorMethod
* @param {MethodGuardPayload} methodGuardPayload
* @param {string} label
* @returns {Method}
*/
const defendSyncMethod = (method, methodGuardPayload, label) => {
const defendSyncMethod = (
getContext,
behaviorMethod,
methodGuardPayload,
label,
) => {
const { returnGuard } = methodGuardPayload;
const isRawReturn = isRawGuard(returnGuard);
const matchConfig = buildMatchConfig(methodGuardPayload);
const { syncMethod } = {
// Note purposeful use of `this` and concise method syntax
syncMethod(...syncArgs) {
const context = getContext(this);
// Only harden args and return value if not dealing with a raw value guard.
const realArgs = defendSyncArgs(syncArgs, matchConfig, label);
const result = apply(method, this, realArgs);
const result = apply(behaviorMethod, context, realArgs);
if (!isRawReturn) {
mustMatch(harden(result), returnGuard, `${label}: result`);
}
Expand Down Expand Up @@ -202,11 +209,17 @@ const desync = methodGuardPayload => {
};

/**
* @param {(...args: unknown[]) => any} method
* @param {(representative: any) => ClassContext | KitContext} getContext
* @param {CallableFunction} behaviorMethod
* @param {MethodGuardPayload} methodGuardPayload
* @param {string} label
*/
const defendAsyncMethod = (method, methodGuardPayload, label) => {
const defendAsyncMethod = (
getContext,
behaviorMethod,
methodGuardPayload,
label,
) => {
const { returnGuard } = methodGuardPayload;
const isRawReturn = isRawGuard(returnGuard);

Expand All @@ -231,8 +244,11 @@ const defendAsyncMethod = (method, methodGuardPayload, label) => {
for (let j = 0; j < awaitedArgs.length; j += 1) {
syncArgs[awaitIndexes[j]] = awaitedArgs[j];
}
// Get the context after all waiting in case we ever do revocation
// by removing the context entry. Avoid TOCTTOU!
const context = getContext(this);
const realArgs = defendSyncArgs(syncArgs, matchConfig, label);
return apply(method, this, realArgs);
return apply(behaviorMethod, context, realArgs);
},
);
if (isRawReturn) {
Expand All @@ -249,93 +265,73 @@ const defendAsyncMethod = (method, methodGuardPayload, label) => {

/**
*
* @param {Method} method
* @param {(representative: any) => ClassContext | KitContext} getContext
* @param {CallableFunction} behaviorMethod
* @param {MethodGuard} methodGuard
* @param {string} label
*/
const defendMethod = (method, methodGuard, label) => {
const defendMethod = (getContext, behaviorMethod, methodGuard, label) => {
const methodGuardPayload = getMethodGuardPayload(methodGuard);
const { callKind } = methodGuardPayload;
if (callKind === 'sync') {
return defendSyncMethod(method, methodGuardPayload, label);
return defendSyncMethod(
getContext,
behaviorMethod,
methodGuardPayload,
label,
);
} else {
assert(callKind === 'async');
return defendAsyncMethod(method, methodGuardPayload, label);
return defendAsyncMethod(
getContext,
behaviorMethod,
methodGuardPayload,
label,
);
}
};

/**
* @param {string} methodTag
* @param {ContextProvider} contextProvider
* @param {CallableFunction} behaviorMethod
* @param {boolean} [thisfulMethods]
* @param {MethodGuard} [methodGuard]
* @param {import('@endo/patterns').DefaultGuardType} [defaultGuards]
* @param {MethodGuard} methodGuard
*/
const bindMethod = (
methodTag,
contextProvider,
behaviorMethod,
thisfulMethods = false,
methodGuard = undefined,
defaultGuards = undefined,
methodGuard,
) => {
assert.typeof(behaviorMethod, 'function');

const getContext = self => {
const context = contextProvider(self);
context ||
Fail`${q(methodTag)} may only be applied to a valid instance: ${self}`;
/**
* @param {any} representative
* @returns {ClassContext | KitContext}
*/
const getContext = representative => {
representative ||
// separate line to ease breakpointing
Fail`Method ${methodTag} called without 'this' object`;
const context = contextProvider(representative);
if (context === undefined) {
throw Fail`${q(
methodTag,
)} may only be applied to a valid instance: ${representative}`;
}
return context;
};

// Violating all Jessie rules to create representatives that inherit
// methods from a shared prototype. The bound method therefore needs
// to mention `this`. We define it using concise method syntax
// so that it will be `this` sensitive but not constructable.
//
// We normally consider `this` unsafe because of the hazard of a
// method of one abstraction being applied to an instance of
// another abstraction. To prevent that attack, the bound method
// checks that its `this` is in the map in which its representatives
// are registered.
let { method } = thisfulMethods
? {
method(...args) {
this ||
Fail`thisful method ${methodTag} called without 'this' object`;
const context = getContext(this);
return apply(behaviorMethod, context, args);
},
}
: {
method(...args) {
const context = getContext(this);
return apply(behaviorMethod, null, [context, ...args]);
},
};
if (!methodGuard && thisfulMethods) {
switch (defaultGuards) {
case undefined:
case 'passable':
methodGuard = PassableMethodGuard;
break;
case 'raw':
methodGuard = RawMethodGuard;
break;
default:
throw Fail`Unrecognized defaultGuards ${q(defaultGuards)}`;
}
}
if (methodGuard) {
method = defendMethod(method, methodGuard, methodTag);
}
const method = defendMethod(
getContext,
behaviorMethod,
methodGuard,
methodTag,
);

defineProperties(method, {
name: { value: methodTag },
length: {
value: thisfulMethods ? behaviorMethod.length : behaviorMethod.length - 1,
},
length: { value: behaviorMethod.length },
});
return method;
};
Expand Down Expand Up @@ -402,14 +398,44 @@ export const defendPrototype = (
}

for (const prop of methodNames) {
const originalMethod = behaviorMethods[prop];
const { shiftedMethod } = {
shiftedMethod(...args) {
return originalMethod(this, ...args);
},
};
const behaviorMethod = thisfulMethods ? originalMethod : shiftedMethod;
// TODO some tool does not yet understand the `?.[` syntax
// See https://github.com/endojs/endo/pull/2247#discussion_r1583724424
let methodGuard = methodGuards && methodGuards[prop];
if (!methodGuard) {
switch (defaultGuards) {
case undefined: {
if (thisfulMethods) {
methodGuard = PassableMethodGuard;
} else {
methodGuard = RawMethodGuard;
}
break;
}
case 'passable': {
methodGuard = PassableMethodGuard;
break;
}
case 'raw': {
methodGuard = RawMethodGuard;
break;
}
default: {
throw Fail`Unrecognized defaultGuards ${q(defaultGuards)}`;
}
}
}
prototype[prop] = bindMethod(
`In ${q(prop)} method of (${tag})`,
contextProvider,
behaviorMethods[prop],
thisfulMethods,
// TODO some tool does not yet understand the `?.[` syntax
methodGuards && methodGuards[prop],
defaultGuards,
behaviorMethod,
methodGuard,
);
}

Expand All @@ -424,8 +450,7 @@ export const defendPrototype = (
`In ${q(GET_INTERFACE_GUARD)} method of (${tag})`,
contextProvider,
getInterfaceGuardMethod,
thisfulMethods,
undefined,
PassableMethodGuard,
);
}

Expand Down
27 changes: 14 additions & 13 deletions packages/exo/test/test-heap-classes.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @endo/no-optional-chaining */
// @ts-check
import test from '@endo/ses-ava/prepare-endo.js';

Expand Down Expand Up @@ -77,7 +78,7 @@ test('test defineExoClass', t => {
message:
'In "incr" method of (UpCounter): arg 0?: string "foo" - Must be a number',
});
t.deepEqual(upCounter[GET_INTERFACE_GUARD](), UpCounterI);
t.deepEqual(upCounter[GET_INTERFACE_GUARD]?.(), UpCounterI);
t.deepEqual(getInterfaceMethodKeys(UpCounterI), ['incr']);

const symbolic = Symbol.for('symbolic');
Expand All @@ -91,7 +92,7 @@ test('test defineExoClass', t => {
[symbolic]() {},
});
const foo = makeFoo();
t.deepEqual(foo[GET_INTERFACE_GUARD](), FooI);
t.deepEqual(foo[GET_INTERFACE_GUARD]?.(), FooI);
// @ts-expect-error intentional for test
t.throws(() => foo[symbolic]('invalid arg'), {
message:
Expand Down Expand Up @@ -143,8 +144,8 @@ test('test defineExoClassKit', t => {
t.throws(() => upCounter.decr(3), {
message: 'upCounter.decr is not a function',
});
t.deepEqual(upCounter[GET_INTERFACE_GUARD](), UpCounterI);
t.deepEqual(downCounter[GET_INTERFACE_GUARD](), DownCounterI);
t.deepEqual(upCounter[GET_INTERFACE_GUARD]?.(), UpCounterI);
t.deepEqual(downCounter[GET_INTERFACE_GUARD]?.(), DownCounterI);
});

test('test makeExo', t => {
Expand All @@ -165,7 +166,7 @@ test('test makeExo', t => {
message:
'In "incr" method of (upCounter): arg 0?: string "foo" - Must be a number',
});
t.deepEqual(upCounter[GET_INTERFACE_GUARD](), UpCounterI);
t.deepEqual(upCounter[GET_INTERFACE_GUARD]?.(), UpCounterI);
});

// For code sharing with defineKind which does not support an interface
Expand All @@ -186,7 +187,7 @@ test('missing interface', t => {
message:
'In "makeSayHello" method of (greeterMaker): result: "[Symbol(passStyle)]" property expected: "[Function <anon>]"',
});
t.is(greeterMaker[GET_INTERFACE_GUARD](), undefined);
t.is(greeterMaker[GET_INTERFACE_GUARD]?.(), undefined);
});

const SloppyGreeterI = M.interface('greeter', {}, { sloppy: true });
Expand All @@ -199,7 +200,7 @@ test('sloppy option', t => {
},
});
t.is(greeter.sayHello(), 'hello');
t.deepEqual(greeter[GET_INTERFACE_GUARD](), SloppyGreeterI);
t.deepEqual(greeter[GET_INTERFACE_GUARD]?.(), SloppyGreeterI);

t.throws(
() =>
Expand Down Expand Up @@ -262,7 +263,7 @@ test('raw guards', t => {
return 'hello';
},
});
t.deepEqual(greeter[GET_INTERFACE_GUARD](), RawGreeterI);
t.deepEqual(greeter[GET_INTERFACE_GUARD]?.(), RawGreeterI);
testGreeter(t, greeter, 'raw defaultGuards');

const Greeter2I = M.interface('greeter2', {
Expand Down Expand Up @@ -302,7 +303,7 @@ test('raw guards', t => {
return {};
},
});
t.deepEqual(greeter2[GET_INTERFACE_GUARD](), Greeter2I);
t.deepEqual(greeter2[GET_INTERFACE_GUARD]?.(), Greeter2I);
testGreeter(t, greeter, 'explicit raw');

t.is(Object.isFrozen(greeter2.rawIn({})), true);
Expand Down Expand Up @@ -340,15 +341,15 @@ test('naked function call', t => {
const { sayHello, [GET_INTERFACE_GUARD]: gigm } = greeter;
t.throws(() => sayHello(), {
message:
'thisful method "In \\"sayHello\\" method of (greeter)" called without \'this\' object',
'Method "In \\"sayHello\\" method of (greeter)" called without \'this\' object',
});
t.is(sayHello.bind(greeter)(), 'hello');

t.throws(() => gigm(), {
t.throws(() => gigm?.(), {
message:
'thisful method "In \\"__getInterfaceGuard__\\" method of (greeter)" called without \'this\' object',
'Method "In \\"__getInterfaceGuard__\\" method of (greeter)" called without \'this\' object',
});
t.deepEqual(gigm.bind(greeter)(), GreeterI);
t.deepEqual(gigm?.bind(greeter)(), GreeterI);
});

// needn't run. we just don't have a better place to write these.
Expand Down

0 comments on commit f48d376

Please sign in to comment.