Ecma262: ProxyCreate’s sensitivity to revocation of arguments

Created on 29 Nov 2019  Â·  13Comments  Â·  Source: tc39/ecma262

I’ve used the following implementation of IsConstructor (i.e., the internal op by this name, realized in ES code) for a while. This operation is needed in Web IDL algorithms and polyfills:

function isConstructor(value) {
  try {
    new new Proxy(value, { construct: () => ({}) });
    return true;
  } catch {
    return false;
  }
}

I learned today that there’s a set of values for which this returns false negatives: revoked proxies which have [[Construct]]. This is due to step 2 of ProxyCreate:

  1. If _target_ is a Proxy exotic object and _target_.[[ProxyHandler]] is null, throw a TypeError exception.

In other words, it will throw in those cases for the “wrong reason” and return false. (The internal IsConstructor op would return true.)

I don’t understand the purpose of this step (or step 4). Why should Proxy construction be concerned with the implementations of its arguments? Why should it care if they are proxies at all? Proxies already have to be recursively sound, even if revoked, so this looks like an anomalous inversion of responsibility.

In particular, note that the step _doesn’t_ prevent proxies from having revoked proxies as their targets. It only creates a seemingly-arbitrary ordering requirement about when that revocation occurs; it could happen immediately afterwards.

Odds are there’s a reason for this behavior — usually if something seems off-kilter it just has some non-obvious history to account for. I’m hoping somebody can shed some light on what that purpose is so that it doesn’t feel like such a bummer to discover yet another thing we can’t implement in ES.

Or (fingers crossed) ... maybe these steps really _shouldn’t_ exist?


BTW, while this behavior invalidates the isConstructor implementation shown above, it also _enables_ an infallible check for something I would have expected to be deliberately unknowable (strictly speaking) from ES code:

function isRevokedProxy(value) {
  try {
    if (Object(value) === value) {
      new Proxy(value, {});
    }
    return false;
  } catch {
    return true;
  }
}

Two minor implementation curiosities I found when investigating this (actually the first one is major, but it’s edge, so maybe it won’t matter in a month?):

  1. In Edge, revoking a proxy appears to actually cause the Proxy exotic object to have its [[Call]] and [[Construct]] internal methods _removed._ It begins reporting "object" for typeof! If that’s really what’s going on, then there are likely places where “Assert: IsCallable(x) is true” and “Assert: IsConstructor(x) is true” appear in algorithms where the assertion could be false in Edge. However it got annoying to test because the Edge console dies repeatedly if you do anything with Proxies.
  2. Possibly this is console/devtools specific and means nothing, but in Safari, revoked proxies are shown as having their handler slot nulled but their target slot intact. It made me wonder if, in Safari, revoked proxies might be preventing collection of their original targets in general.

Most helpful comment

FWIW, I found a workaround (for the specific issue of achieving IsConstructor in ES). The Reflect.construct operation provides a hook to access the real internal IsConstructor because it does nothing except perform this test prior to attempting coercing the arraylike argument. By making that argument always an invalid arraylike, one can use a unique privately held value as an abrupt completion that reveals the constructor-status of the first argument without actually constructing it:

const { construct } = Reflect;

const BAD_ARRAY_LIKE = {
  get length() {
    throw IS_CONSTRUCTOR;
  }
};

const IS_CONSTRUCTOR = Symbol();

function isConstructor(value) {
  try {
    construct(value, BAD_ARRAY_LIKE);
  } catch (err) {
    return err === IS_CONSTRUCTOR;
  }
}

const constructorProxyRecord = Proxy.revocable(class {}, {});
const nonConstructorProxyRecord = Proxy.revocable(() => {}, {});

console.assert(isConstructor(Object));
console.assert(isConstructor(Symbol));
console.assert(isConstructor(function() {}));
console.assert(isConstructor(function() {}.bind()));
console.assert(isConstructor(class {}));
console.assert(isConstructor(class {}.bind()));
console.assert(isConstructor(new Proxy(class {}, {})));
console.assert(isConstructor(constructorProxyRecord.proxy));
constructorProxyRecord.revoke();
console.assert(isConstructor(constructorProxyRecord.proxy));

console.assert(!isConstructor(Math.abs));
console.assert(!isConstructor(async function() {}));
console.assert(!isConstructor({ method() {} }.method));
console.assert(!isConstructor(1n));
console.assert(!isConstructor(true));
console.assert(!isConstructor(null));
console.assert(!isConstructor(1));
console.assert(!isConstructor({}));
console.assert(!isConstructor(''));
console.assert(!isConstructor(Symbol()));
console.assert(!isConstructor());
console.assert(!isConstructor(new Proxy({}, {})));
console.assert(!isConstructor(nonConstructorProxyRecord.proxy));
nonConstructorProxyRecord.revoke();
console.assert(!isConstructor(nonConstructorProxyRecord.proxy));

The fact that a workaround exists provides me w/ immense psychic relief haha. But the issue is still pretty weird and I’m glad to see I’m not alone in believing the current behavior is a mistake.

All 13 comments

cc @erights @tvcutsem @caridy

I don't remember all the details around steps 2 and 4, nor if it was ever discussed in details. Since the target and the handle could be a proxies, revokable ones, those two checks do make sense at first glance.

I feel that letting the proxy to be created, and let the recursiveness of proxies to take care of them, will simply defer the error to a later point in time when we know for sure that it will never work. But I do agree with you that this has implications that might outweigh the early error argument.

Since IsCallable(target) and IsConstructor(target) are immune to the revocation, we might be able to get away with the removal of both checks.

The membrane case is actually quite interesting. A good example here will be, a proxy created and revoked in a realm that is connected to another realm via a membrane, if you use the proxy identity in a map or some other similar mechanism that is immune to the revocation, you will still be able to share that proxy via the membrane, because the wrapping of the revoked proxy is done via a shadow target, although, we still need to figure the type and probably the name of the target function (if applicable) to have a high-fidelity membrane. Food for thoughts here!

Yes, this is what I mean when I say these checks seem arbitrary. There’s nowhere else where the revocation status of a PEO is significant information _in itself_ — all other cases occur when an object internal method is invoked (and is accounted for by the definitions of those methods for PEOs) or when a proxy-piercing operation would need to inspect state associated with the target (e.g. IsArray, GetFunctionRealm). In all other identity-and-type-only scenarios, revocation status is not significant (and not revealed), even if it could also be understood as constituting early error opportunities (though IMO, it is correct that they should not be). The effect is that it appears as though these steps exist simply because proxy revocation was on the mind when they were being written.

Relevant spec text is at https://tc39.es/ecma262/#sec-proxycreate step 2. I just looked. I fully agree with @bathos that this clause is a mistake. We should have caught it. It is a mistake because it makes the revocable-ness state of a proxy observable. When we first introduced revocable proxies, we intended observational equivalence to a handler that only throws. We were not trying to introduce behavior that could not otherwise be expressed. We were trying to enable gc.

IIRC @DavidBruant was the one who noticed that the old proxy design could not gc under such behavioral revocation and thus we needed a builtin way to say the same thing.

That step 2 breaks that illusion and makes detectable a state change that should have been encapsulated in the proxy.

@tvcutsem , @DavidBruant , is this how you remember it? Do you agree that step 2 should be removed?

FWIW, I found a workaround (for the specific issue of achieving IsConstructor in ES). The Reflect.construct operation provides a hook to access the real internal IsConstructor because it does nothing except perform this test prior to attempting coercing the arraylike argument. By making that argument always an invalid arraylike, one can use a unique privately held value as an abrupt completion that reveals the constructor-status of the first argument without actually constructing it:

const { construct } = Reflect;

const BAD_ARRAY_LIKE = {
  get length() {
    throw IS_CONSTRUCTOR;
  }
};

const IS_CONSTRUCTOR = Symbol();

function isConstructor(value) {
  try {
    construct(value, BAD_ARRAY_LIKE);
  } catch (err) {
    return err === IS_CONSTRUCTOR;
  }
}

const constructorProxyRecord = Proxy.revocable(class {}, {});
const nonConstructorProxyRecord = Proxy.revocable(() => {}, {});

console.assert(isConstructor(Object));
console.assert(isConstructor(Symbol));
console.assert(isConstructor(function() {}));
console.assert(isConstructor(function() {}.bind()));
console.assert(isConstructor(class {}));
console.assert(isConstructor(class {}.bind()));
console.assert(isConstructor(new Proxy(class {}, {})));
console.assert(isConstructor(constructorProxyRecord.proxy));
constructorProxyRecord.revoke();
console.assert(isConstructor(constructorProxyRecord.proxy));

console.assert(!isConstructor(Math.abs));
console.assert(!isConstructor(async function() {}));
console.assert(!isConstructor({ method() {} }.method));
console.assert(!isConstructor(1n));
console.assert(!isConstructor(true));
console.assert(!isConstructor(null));
console.assert(!isConstructor(1));
console.assert(!isConstructor({}));
console.assert(!isConstructor(''));
console.assert(!isConstructor(Symbol()));
console.assert(!isConstructor());
console.assert(!isConstructor(new Proxy({}, {})));
console.assert(!isConstructor(nonConstructorProxyRecord.proxy));
nonConstructorProxyRecord.revoke();
console.assert(!isConstructor(nonConstructorProxyRecord.proxy));

The fact that a workaround exists provides me w/ immense psychic relief haha. But the issue is still pretty weird and I’m glad to see I’m not alone in believing the current behavior is a mistake.

(for my curiosity) Are there any engines with Reflect.construct but not Proxy, or vice versa?

@ljharb Looking at Kangax for ES2015 with historical stuff on, I see that Duktape 2.0 and Duktape 2.1 both had implemented Reflect.construct and not Proxy. In all other cases it looks like they were introduced together.

I’m also curious if anybody knows whether Chakra development is going to continue post Chromium Edge. The issue I found (listed at the end of the original post) in Edge seemed potentially serious. A related Edge quirk is that typeof new Proxy(document.all, {}) reports as 'object' instead of 'function'.

@DavidBruant , is this how you remember it? Do you agree that step 2 should be removed?

it is how i remember it indeed
I do not see a reason for revocable-ness observability, this feels like an accident

@bathos we should open an PR to remove step 2, and gather feedback from now until the next meeting (early next year), to see if we can get this corrected. Can you take care of that?

@caridy Yep!

I would think this applies to step 4 also — it’s the same thing but for the handlers argument, and it also provides an avenue to reveal revocation status. Is there a reason that one ought to stay?

  1. If handler is a Proxy exotic object and handler.[[ProxyHandler]] is null, throw a TypeError exception.

Just to make sure that we are on the same page, it is still possible to observe a revoked proxy even when #1814 gets approved, e.g.:

var revocable = Proxy.revocable([], {});
var p = revocable.proxy;
revocable.revoke();
Array.isArray(p); // TypeError: illegal operation attempted on a revoked proxy

^ and to be clear, that's because IsArray() explicitly throws if the proxy is revoked, not as a side effect of the proxy being revoked.

It’s also possible in very narrow circumstances via GetFunctionRealm. In this case, I believe the object in question needs to have [[Construct]] and either

  • be an unrevoked PEO which, as a reaction to [[Get]]('prototype'), triggers its own revocation
  • be an unrevoked PEO whose target is a revoked PEO (or another PEO which...)
  • be a BFEO whose [[BoundTargetFunction]] is a revoked PEO (or another BFEO which...)

The GetFunctionRealm case, unlike ProxyCreate and IsArray, strictly doesn’t reveal that the object is a revoked proxy, but rather that it is either a revoked proxy or a BFEO whose [[BoundTargetFunction]] ultimately resolves to one.

Actually come to think of it, IsArray also has a twist. It’s not revealing ‘is a revoked proxy’ but rather ‘is a revoked proxy or is an unrevoked proxy whose target is... etc’.

Was this page helpful?
0 / 5 - 0 ratings