ionic-team / ionic-team/capacitor
bug: plugin proxy `get` trap routes `then` access to native, causing silent `await` hangs via Promise thenable adoption
- 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
Assessment
This issue has not been assessed yet.