Skip to content

bug: plugin proxy get trap routes then access to native, causing silent await hangs via Promise thenable adoption #8469

@devin-ai-integration

Description

@devin-ai-integration

Capacitor version

@capacitor/core: 8.0.3

(The relevant code is the same on main as of writing; see linked source below.)

Platforms affected

  • iOS
  • Android
    (In principle, any platform that throws on unknown method dispatch.)

Summary

The plugin Proxy get trap in core/src/runtime.ts returns a callable method wrapper for every property name that isn't in a small allowlist ($$typeof, toJSON, addListener, removeListener). That allowlist intentionally special-cases $$typeof so React's thenable detection (facebook/react#20030) doesn't misclassify a plugin as a Lazy / Promise / Thenable.

But the same shape causes a more dangerous bug at the language level: the ECMAScript runtime itself probes .then on every value it adopts into a Promise. Any code that puts a plugin proxy on the resolution path of a Promise — most commonly returning the proxy from an async function — triggers the PromiseResolveThenableJob abstract operation, which calls proxy.then(resolve, reject).

For the plugin proxy:

  1. proxy.then returns a callable method wrapper (since then isn't allowlisted).
  2. The runtime classifies the proxy as a thenable.
  3. proxy.then(resolve, reject) dispatches a then() method call across the bridge to the native plugin.
  4. The native plugin (e.g. PushNotifications) has no then implementation, so it throws "PluginName.then()" is not implemented on <platform> (the exact format produced here — the not implemented branch).

Two effects:

(a) proxy.then(resolve, reject) rejects without calling resolve or reject, so the surrounding await hangs indefinitely. The user-side try/catch cannot reach this rejection.

(b) The rejection becomes an unhandled rejection, which Sentry / browser global handlers catch — but the actual call site has no idea anything went wrong, because the try/catch is dead.

This is the same root cause as the well-known $$typeof issue, but rather than React's userland thenable check it's the language's own thenable adoption machinery. So the special case for $$typeof does not cover it.

Reproduction

Minimal repro using a proxy that mimics the Capacitor plugin shape:

function makeNativeBridgePlugin(name) {
  return new Proxy({}, {
    get(_, prop) {
      switch (prop) {
        case '$$typeof':
        case 'toJSON':
        case 'addListener':
        case 'removeListener':
          return undefined;
        default:
          return (..._args) => {
            throw new Error(`"${name}.${String(prop)}()" is not implemented on ios`);
          };
      }
    },
  });
}

async function getPlugin() {
  return makeNativeBridgePlugin('PushNotifications');  // returns Proxy from async fn
}

(async () => {
  console.log('before');
  const plugin = await getPlugin();   // <- hangs forever; await never resolves
  console.log('after', plugin);
})();

Run with Node 20+. Output: before — and then nothing. The await never completes. An unhandled-rejection handler (or a process.on('unhandledRejection', ...)) does see Error: "PushNotifications.then()" is not implemented on ios, but the surrounding async function is dead.

This reproduces the exact runtime behavior that broke our iOS push notification registration in production (every iOS install ran await getPushPlugin() which silently never returned, and we never registered an APNs token).

Real-world incident

In our app (vellum-ai/vellum-assistant-platform) we had a helper:

async function getPushPlugin() {
  const mod = await import("@capacitor/push-notifications");
  return mod.PushNotifications;     // returns the Capacitor Proxy from an async fn
}
// ...
const PushNotifications = await getPushPlugin();   // hangs on iOS
await PushNotifications.requestPermissions();      // never reached

Result: APNs registration silently broken for every iOS user for ~3 days; we discovered it only because Sentry's global unhandled-rejection handler captured the inner exception.

We fixed it on our side by destructuring the import inline, so the proxy never crosses an async boundary:

const { PushNotifications } = await import("@capacitor/push-notifications");

…but that's a workaround for a footgun that any consumer can hit with no obvious warning sign.

The same bug class is documented for @capacitor-community/native-audio here: https://mloverflow.com/post/436853ed-2279-48d4-a037-8917cea14da2 ("Promise Assimilation Errors"), with the conclusion: "Critical: Never return the Capacitor plugin proxy from a Promise." It's surfaced repeatedly in third-party code over multiple Capacitor major versions, which suggests it warrants a defensive fix in core rather than guidance.

Suggested fix

Extend the switch in the plugin proxy get trap to also return undefined for then. Concretely, add a then case alongside the existing $$typeof case in core/src/runtime.ts (around the block at lines 165–180):

       get(_, prop) {
         switch (prop) {
           // https://github.com/facebook/react/issues/20030
           case '$$typeof':
             return undefined;
+          // ECMAScript Promise thenable adoption probes `.then` on every value
+          // it resolves. If we return a callable wrapper here, the runtime
+          // classifies the plugin as a thenable, calls `proxy.then(resolve, reject)`,
+          // and that dispatches a no-op `then()` method to the native side,
+          // which throws and never resolves or rejects — silently hanging the
+          // outer `await`. Returning `undefined` makes the proxy a non-thenable,
+          // which is the correct semantics: a Capacitor plugin is not a Promise.
+          // See https://tc39.es/ecma262/#sec-promiseresolvethenablejob
+          case 'then':
+            return undefined;
           case 'toJSON':
             return () => ({});
           case 'addListener':
             return pluginHeader ? addListenerNative : addListener;
           case 'removeListener':
             return removeListener;
           default:
             return createPluginMethodWrapper(prop);
         }
       },

This is symmetric with the existing $$typeof workaround — both are runtime-level thenable/special-form probes that should never be answered with a callable. Capacitor plugins are not promises, so then access should not produce one.

Why this is safe

  • then is reserved at the language level; it cannot be a legitimate Capacitor plugin method name (no Capacitor plugin in the wild publicly exposes a then(...) method, and doing so would already be hostile to Promise-using callers).
  • All four major Capacitor plugins (PushNotifications, Camera, Geolocation, Filesystem) and the community plugins surveyed do not define a then method.
  • Returning undefined for .then makes the proxy non-thenable per ECMA-262 §25.6.4.5.1, so Promise.resolve(plugin) resolves to the proxy itself, exactly as if plugin were a plain object.
  • Promise.then(...) calls on actual promises returned by plugin methods (e.g. PushNotifications.register() returns a real Promise) are unaffected — .then on a real Promise is a Promise method, not a proxy get.
  • Symmetric fix already shipped for $$typeof (commit 4cbae41) confirms the maintainers consider userland thenable-probe interception within scope.

Alternative considered

Documenting the rule "never return a Capacitor plugin from an async function" in the README. We added an equivalent rule to our own internal AGENTS.md, but documentation alone has not been sufficient — the same bug class has independently bitten multiple Capacitor consumers (links above). A defensive check in the proxy is durable; documentation is fragile.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs reproductionneeds reproducible example to illustrate the issue

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions