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(marshal): Allow promise stand-ins #1313

Draft
wants to merge 14 commits into
base: master
Choose a base branch
from

Conversation

gibson042
Copy link
Contributor

@gibson042 gibson042 commented Oct 11, 2022

Stacked on #1311

Fixes #1312

This is the most restrictive solution, in which a pseudo-promise is not
allowed to have any properties or methods (although that may be relaxed
in the future to support a then method and/or settlement/resolution
data).

TODO:

@erights erights changed the base branch from master to gibson-2022-10-marshal-assertions October 11, 2022 20:19
@erights
Copy link
Contributor

erights commented Oct 11, 2022

@gibson042 since this is stacked on #1311 , I edited the github PR to compare to #1311 's fork for easier reviewability. If this is not appropriate, feel free to change back.

(#1311 has since been merged, so this note is no longer relevant)

return;
}

PromiseHelper.canBeValid(candidate, assertChecker);
Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like we're duplicating the isPromise check if the candidate is not a native promise, which means really we're creating 2 throw away Promise objects.

Copy link
Contributor

Choose a reason for hiding this comment

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

But only one extra one, since we gotta do at least one. Unless we wanna invert the order of tests, which is likely to be net more expensive.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's actually even more than that, because passStyleOf itself uses isPromise on every [frozen] object, and then every one that fails the check and does not have an explicit PASS_STYLE is subject to iteration over each helper's canBeValid in priority order, with promise's using isPromise and then each object accepted by that canBeValid is passed to assertValid (which also uses isPromise). I'll reorder to check PASS_STYLE first, but that will still leave two calls for ersatz promises, and if we really care then I think we'll need a WeakMap.

Copy link
Contributor

Choose a reason for hiding this comment

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

passStyleOf memoizes

Comment on lines 41 to 46
const descKeys = ownKeys(candidate);
(descKeys.length === 1 && descKeys[0] === PASS_STYLE) ||
assert.fail(X`Must not have any own properties: ${candidate}`);
Copy link
Contributor

Choose a reason for hiding this comment

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

I thought that for @FUDCo's use case, we needed at least a toString() method? When discussing this with @erights we didn't see a need to disallow any more methods than for remotables.

Copy link
Contributor

Choose a reason for hiding this comment

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

Being able to specify the toString() method is literally the only thing that matters to me here. Everything else is just machinery to enable that to work.

Copy link
Contributor

@erights erights Oct 11, 2022

Choose a reason for hiding this comment

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

... toString() method ...

Cannot do that in any case, and this PR wisely does not attempt to. What do you really need? What are you doing now, so that your current state works? My sense is that you only need a pair of functions which can be local to a context, where

getSomething(makePromiseWithSomething(something)) === something

for some appropriate somethings. We can do that trivially with real promises and a weakmap, yes?

Copy link
Contributor

Choose a reason for hiding this comment

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

When discussing this with @erights we didn't see a need to disallow any more methods than for remotables.

True. And what I said then contradicts what I said above. There are two simple extremes, and we should do one or the other.

  • Allow mostly-arbitrary unvalidatable methods, like we do for remotables. This is why remotables can have an arbitrary toString method.
  • Data-only, which is where this PR currently is, and why I'm trying to avoid any unvalidatable behavior.

The problem is that we are still operating under the "any module might be multiply instantiated" restriction, and so any objective predicate, like passStyleOf, cannot brand things to remember that they are safe by construction. It can only tell that it is safe by inspection. passStyleOf does use a memo table to avoid redundant work, which is much like a brand table. Except that if absent, it always falls back to inspection. The behavior of non-primordial non-global methods cannot be inspected to be safe.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This comes down to a tension between practicality and the principle that unmarshaling should recover full state. We can relax this constraint if the use case is in fact sufficiently strong, but we can't go in the other direction.

Copy link
Contributor

@erights erights left a comment

Choose a reason for hiding this comment

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

Not approving for two reasons:

  • The requested changes, in particular using checkTagRecord for better validation.
  • In light of subsequent discussions, I am queasy about introducing something passStyleOf recognizes as a promise type, but which cannot yet work with E( nor E.when(.

Despite the second bullet, please do respond to requested changes, so we'll be closer to moving ahead if/when the second bullet is resolved. There are two needs this PR would be a step towards:

  • @FUDCo 's immediate need for his encode/decode manipulation in the post-smallcaps SwingSet tests,
  • @mhofman 's plans for virtual persistent promises.

@FUDCo already has something working. I'd like to see that before judging that use to be urgent. Assuming it is not urgent, I'd like to hold this off until we find something that works with E and E.when, probably as part of doing virtual and durable promises as the same time.

@@ -20,7 +21,7 @@ import { assertSafePromise } from './helpers/safe-promise.js';
/** @typedef {import('./types.js').PassStyleOf} PassStyleOf */
/** @typedef {import('./types.js').PrimitiveStyle} PrimitiveStyle */

/** @typedef {Exclude<PassStyle, PrimitiveStyle | "promise">} HelperPassStyle */
Copy link
Contributor

Choose a reason for hiding this comment

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

Whoa! TIL Exclude

Copy link
Contributor

Choose a reason for hiding this comment

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

I see this is from #1043 which I should have paid more attention to at the time. It surprises me, but it does look good.

Copy link
Contributor

Choose a reason for hiding this comment

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

No change suggested.

@@ -195,6 +197,7 @@ export const passStyleOf = makePassStyleOf([
CopyRecordHelper,
TaggedHelper,
RemotableHelper,
PromiseHelper,
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't you also delete

          if (isPromise(inner)) {
            assertSafePromise(inner);
            return 'promise';
          }

from lines 150-153 above?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Only if we also move the prohibition of a then method. It seemed cleaner to keep it at the expense of some redundancy.

serializeBodyFormat: 'capdata',
},
);
const assertRoundTrip = (val, body, slots, description) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

More better testing once we have arb-passable.js?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Absolutely!

Copy link
Contributor

Choose a reason for hiding this comment

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

;)

No change suggested for this PR.

@gibson042 gibson042 force-pushed the gibson-2022-10-marshal-assertions branch from 08b7e45 to c206d14 Compare October 11, 2022 23:59
@gibson042 gibson042 force-pushed the gibson-1312-passstyleof-promise branch from 6dff770 to b016e59 Compare October 12, 2022 00:34
Comment on lines 54 to 57
// "promise" was a late addition, so we tolerate its absence.
if (HelperTable.promise === undefined) {
delete HelperTable.promise;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

But it is an addition provided by this same PR. In what scenario could this condition ever happen?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If any code outside of this package calls makePassStyleOf, which is exported. However, there are no examples of that anywhere in endo or agoric-sdk AFAICT... should I remove this leeway?

Copy link
Contributor

Choose a reason for hiding this comment

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

Where is makePassStyleOf exported? I should not be.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh, my mistake! I guess I was looking elsewhere. Fixed.

return (
(candidate[PASS_STYLE] === 'promise' ||
((passStyleDesc && passStyleDesc.value === 'promise') ||
Copy link
Contributor

Choose a reason for hiding this comment

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

Aha. Would be vulnerable to the obscure Object.prototype.value = junk arrack on descriptors we just talked about in the endo meeting. (Were you there for that?) However, this is in the marshal package, not the ses package, which should only execute after lockdown, and so is not vulnerable. But amusing nonetheless!

No change suggested.

const proto = getPrototypeOf(candidate);
proto === objectPrototype ||
proto === null ||
checkTagRecord(candidate, 'promise', assertChecker);
Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, good choice moving checkTagRecord from canBeValid to assertValid.

No change suggested.

Comment on lines 51 to 168
// XXX Should this (and TaggedHelper.assertValid) support a null prototype?
getPrototypeOf(candidate) === objectPrototype ||
assert.fail(X`Unexpected prototype for: ${candidate}`);
Copy link
Contributor

Choose a reason for hiding this comment

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

Good question. Yes, I think both should. But here's a counter-argument that says we shouldn't support it at all, i.e., that we should remove such support even from copyRecords:

Any such non-standard null prototype is not pass invariant. If such a copyRecord is passed through any marshal barrier, the copyRecord that gets unserialized on the other side will always inherit directly from `Object.prototype'.

So, I am still on the fence. Opinions?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I am in favor of removing support for all null-prototype passables unless we have a clear scenario benefiting from them, in which case every check for Object.prototype should be expanded accordingly.

Copy link
Contributor

Choose a reason for hiding this comment

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

I agree. So no change suggested here. Anytime you'd like to propagate this reform (assuming it doesn't break anything but tests), I'll happily review. Thanks!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

Necroing this thread but an argument could be made that a copyRecord should be unmarshalled with a null prototype instead.

Copy link
Contributor

@erights erights Feb 14, 2023

Choose a reason for hiding this comment

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

Given that we agree on preserving pass-invariance, there are only two solutions:

  1. All CopyRecords inherit from Object.prototype (status quo)
  2. All CopyRecords inherit from null (what we’d all prefer if we could have it everywhere at reasonable cost)

Yes, we could change passStyleOf and unserialize to be consistent with (2). What we cannot reasonably change is requiring everyone to write

harden({
  __proto__: null,
  foo: 3
})

rather than the status quo

harden({foo: 3})

Yes, there are approaches to this notational constraint, like using something instead of harden that either

  • modifies the argument record in place (like Far does), or
  • produces a new object derived from the argument object

Altogether, for the way we use CopyRecords everywhere, I don’t think either of these coping strategies are at reasonable notational and explanatory cost. Such a cure would be worse than the disease.

@@ -20,7 +21,7 @@ import { assertSafePromise } from './helpers/safe-promise.js';
/** @typedef {import('./types.js').PassStyleOf} PassStyleOf */
/** @typedef {import('./types.js').PrimitiveStyle} PrimitiveStyle */

/** @typedef {Exclude<PassStyle, PrimitiveStyle | "promise">} HelperPassStyle */
Copy link
Contributor

Choose a reason for hiding this comment

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

No change suggested.

serializeBodyFormat: 'capdata',
},
);
const assertRoundTrip = (val, body, slots, description) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

;)

No change suggested for this PR.

@@ -55,6 +77,89 @@ test('some passStyleOf rejections', t => {
message: /\[Promise\]" - Must not have any own properties: \["then"\]/,
});

// Pseudo-promise
for (const proto of [null, {}]) {
const fakeprbadProto = makePseudoPromiseVariant(proto);
Copy link
Contributor

Choose a reason for hiding this comment

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

This one is genuinely fake. No change suggested.

Copy link
Contributor

@erights erights left a comment

Choose a reason for hiding this comment

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

Were we to go ahead with this, its current state would LGTM

@@ -45,6 +45,12 @@ test('passStyleOf basic success cases', t => {
t.is(passStyleOf(harden({ then: 'non-function then ok' })), 'copyRecord');
t.is(passStyleOf(makeTagged('unknown', undefined)), 'tagged');
t.is(passStyleOf(harden(Error('ok'))), 'error');

const hairlessError = Error('hairless');
Copy link
Contributor

Choose a reason for hiding this comment

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

Cool name!

No change suggested.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@gibson042 gibson042 force-pushed the gibson-2022-10-marshal-assertions branch from c206d14 to 7f0aa17 Compare October 13, 2022 15:11
Base automatically changed from gibson-2022-10-marshal-assertions to master October 13, 2022 15:18
@gibson042 gibson042 force-pushed the gibson-1312-passstyleof-promise branch from 3bcc377 to 389d4c7 Compare October 13, 2022 15:28
@gibson042 gibson042 force-pushed the gibson-1312-passstyleof-promise branch from 4c41e58 to 397a2bf Compare October 13, 2022 17:35
@gibson042 gibson042 requested review from erights and mhofman October 13, 2022 17:46
Comment on lines +170 to +176
const tagDesc = getOwnPropertyDescriptor(candidate, toStringTag);
(tagDesc && tagDesc.value === 'Pseudo-promise') ||
assert.fail(
X`Pseudo-promise must be an object with ${q(toStringTag)} ${q(
'Pseudo-promise',
)}: ${candidate}`,
);
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems overly restrictive. I'm wondering if we may ever want to differentiate between different kinds of pseudo promises in which case toStringTag would be appropriate.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I figure we can relax this constraint if that need arises.

);
const keys = ownKeys(candidate);
keys.every(k => k === PASS_STYLE || k === toStringTag) ||
assert.fail(X`Unexpected properties on pseudo-promise ${keys}`);
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
assert.fail(X`Unexpected properties on pseudo-promise ${keys}`);
assert.fail(X`Unexpected properties on pseudo-promise ${keys.filter(k => k !== PASS_STYLE && k !== toStringTag)}`);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done with the ...rest pattern used elsewhere.

@erights erights added the pseudo-promise Virtual, Durable, or Passable promise-likes label Nov 18, 2022
@erights
Copy link
Contributor

erights commented Nov 18, 2022

@mhofman I just created the label pseudo-promise and labeled this PR with that label, to keep track of PRs and issues relevant for thinking about possible pseudo-promise semantics.

@gibson042 gibson042 marked this pull request as draft March 29, 2023 16:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
pseudo-promise Virtual, Durable, or Passable promise-likes
Projects
None yet
Development

Successfully merging this pull request may close these issues.

marshal: Allow promise stand-ins
4 participants