ionic-team / ionic-team/capacitor

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

Open
#8,469 1 comment 0 reactions 0 assignees View on GitHub
needs reproduction
Dominant language
TypeScript
Stars
16.7k
Forks
1.3k
Avg merge
3d 15h
Merged PRs (30d)
10

Description

## 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`](https://github.com/ionic-team/capacitor/blob/main/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`](https://github.com/ionic-team/capacitor/commit/4cbae41908670ab843bea5850da7a2cf1082afdb) so React's thenable detection ([`facebook/react#20030`](https://github.com/facebook/react/issues/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`](https://tc39.es/ecma262/#sec-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 ` (the exact format produced [here](https://github.com/ionic-team/capacitor/blob/main/core/src/runtime.ts) — 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:

```js
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:

```ts
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:

```ts
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: ("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`](https://github.com/ionic-team/capacitor/blob/main/core/src/runtime.ts) (around the block at lines 165–180):

```diff
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](https://tc39.es/ecma262/#sec-promiseresolvethenablejob), 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`](https://github.com/ionic-team/capacitor/commit/4cbae41908670ab843bea5850da7a2cf1082afdb)) 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

- ECMAScript spec: [`PromiseResolveThenableJob`](https://tc39.es/ecma262/#sec-promiseresolvethenablejob)
- React precedent: [`facebook/react#20030`](https://github.com/facebook/react/issues/20030), Capacitor fix [`4cbae41`](https://github.com/ionic-team/capacitor/commit/4cbae41908670ab843bea5850da7a2cf1082afdb)
- Independent write-up of the same bug class:
- Closest related Capacitor issue: [`#4356`](https://github.com/ionic-team/capacitor/issues/4356) (`hasOwnProperty` probe — same class of issue with a different language hook; closed `not_planned`, but `$$typeof` shows the maintainers do special-case some hooks where the cost is low and the user-facing surprise is high)

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.