Skip to content

fix(core): make registerPlugin proxy non-thenable to prevent silent await hangs - #8582

Open
bun-unsafe wants to merge 1 commit into
ionic-team:mainfrom
bun-unsafe:fix/plugin-proxy-not-thenable
Open

fix(core): make registerPlugin proxy non-thenable to prevent silent await hangs#8582
bun-unsafe wants to merge 1 commit into
ionic-team:mainfrom
bun-unsafe:fix/plugin-proxy-not-thenable

Conversation

@bun-unsafe

Copy link
Copy Markdown

Problem

The plugin Proxy in registerPlugin() (core/src/runtime.ts) returns a callable method wrapper for every property name that isn't in its allowlist. The allowlist already special-cases $$typeof (react/react#20030) so React doesn't misclassify a plugin as a Lazy/Promise — but it doesn't cover the language-level thenable probe.

When any value is adopted into a Promise — most commonly a plugin proxy returned from an async function — the engine runs PromiseResolveThenableJob, which accesses .then and, if callable, invokes proxy.then(resolve, reject).

For the plugin proxy:

  1. proxy.then hits the get trap's default branch and returns a method wrapper.
  2. The engine classifies the proxy as a thenable and calls proxy.then(resolve, reject).
  3. That dispatches a then() method call across the bridge. Plugins don't implement then, so the call never settles — the outer await hangs forever, and the try/catch at the call site can never reach the failure.
// hangs forever with no error at the call site:
async function getPlugin() {
  return Capacitor.Plugins.PushNotifications;
}
const plugin = await getPlugin(); // never resolves

This is the same class of issue the $$typeof special case already fixed, but triggered by the language's own thenable adoption machinery instead of React's userland check.

Fix

Return undefined for then, catch, and finally in the get trap, mirroring the existing $$typeof handling: a Capacitor plugin is not a Promise, so these names must not produce callables.

 case '$$typeof':
   return undefined;
+// https://tc39.es/ecma262/#sec-promiseresolvethenablejob
+// ...
+case 'then':
+case 'catch':
+case 'finally':
+  return undefined;

Why this is safe:

  • then is probed by the language on every value adopted into a Promise; returning undefined makes the proxy a plain non-thenable, so await resolves to the proxy itself — same semantics as a plain object.
  • .then(...) on real promises returned by plugin methods is unaffected — that's a Promise method, not a proxy get.
  • No official or community plugin exposes methods named then/catch/finally; such names would already be hostile to Promise-using callers.

Testing

Added a regression test to core/src/tests/runtime.spec.ts (verified it fails on main without the patch):

it('registerPlugin proxy is not thenable', async () => {
  const plugin = cap.registerPlugin('Awesome');

  expect(plugin.then).toBeUndefined();
  expect(plugin.catch).toBeUndefined();
  expect(plugin.finally).toBeUndefined();

  // a plugin proxy returned from an async function must not hang the await
  await expect((async () => plugin)()).resolves.toBe(plugin);
  await expect(Promise.resolve(plugin)).resolves.toBe(plugin);

  // regular methods are unaffected
  expect(typeof plugin.someMethod).toBe('function');
  expect(plugin.$$typeof).toBeUndefined();
  expect(plugin.toJSON()).toEqual({});
});

Full suite passes: cd core && npm run build && npm test — 52/52 (was 51, +1).

Fixes #8469
Closes #8472

…wait hangs

The plugin Proxy get trap returns a callable method wrapper for every
property name outside its allowlist. That allowlist already special-cases
$$typeof so React does not misclassify a plugin as a Lazy/Promise, but
it does not cover the language-level thenable probe: when a value is
adopted into a Promise (e.g. a plugin proxy returned from an async
function), the runtime calls proxy.then(resolve, reject) per
PromiseResolveThenableJob.

The get trap answered that with a method wrapper, so the engine treated
the proxy as a thenable and dispatched a then() call across the bridge.
On plugins without a native then method the call never settles, so the
outer await hangs forever with no error at the call site.

Return undefined for then/catch/finally, mirroring the $$typeof fix:
a Capacitor plugin is not a Promise, so these names must not produce
callables. Regular plugin methods are unaffected.

Fixes ionic-team#8469
Closes ionic-team#8472
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant