Skip to content

await thenable returns the thenable itself instead of calling .then(resolve, reject) #586

Closed

Description

@proggeramlug

Repro

class Thenable {
  [Symbol.toStringTag] = "Thenable";
  then(onFulfilled: any, _onRejected: any) {
    console.log('[then] called');
    return Promise.resolve(42).then(onFulfilled);
  }
}

const t = new Thenable();
const result = await t;
console.log('result =', result);

Bun output (correct, ECMAScript spec):

[then] called
result = 42

Perry output (broken):

result = {}

Perry's await only handles real Promises — it doesn't recognize objects with a .then(resolve, reject) method (the thenable protocol). When you await a thenable, perry returns the thenable object itself unchanged.

Spec

ECMAScript await semantics: if the awaited value is an object with a callable then property, it should be assimilated — perry should call value.then(resolve, reject) and resume the surrounding async function with whatever resolve is called with. Both real Promise instances and user-defined thenables (Q, Bluebird, Drizzle's QueryPromise, etc.) follow this contract.

Why it matters for #488

drizzle-orm's QueryPromise (drizzle-orm/query-promise.js) is the load-bearing pattern:

class QueryPromise {
  static [entityKind] = "QueryPromise";
  [Symbol.toStringTag] = "QueryPromise";
  then(onFulfilled, onRejected) {
    return this.execute().then(onFulfilled, onRejected);  // executes lazily on first await
  }
}

db.select().from(users) returns a PgSelectBase extending QueryPromise. The intent is await db.select().from(users) calls .then(resolve), which triggers .execute() (the actual SQL round-trip), and resolves with rows. Without thenable assimilation, await returns the builder object unchanged — query never runs.

This is the second perry-side blocker for #488 (after #585 cross-module instanceof).

Where I'd start digging

crates/perry-runtime/src/promise.rs (await/resolve path) and the async-to-generator transform in crates/perry-transform/src/generator.rs. The relevant point is wherever the await is evaluated — when the awaited value is not a Promise, perry should:

  1. Probe for a .then method via js_object_get_field_by_name(value, "then")
  2. If callable, allocate a fresh Promise, call value.then(resolve, reject) where resolve/reject route to the new promise
  3. Suspend the async function on that new Promise

The fast path for real Promises stays unchanged.

Workaround

User-side await Promise.resolve(thenable.execute()) works (bypasses the await-via-thenable assimilation by calling .execute() directly and awaiting the result). For drizzle this would mean rewriting every query call site to call .execute() explicitly — invasive enough that fixing the runtime is the right move.

Refs

Blocks #488. Surfaced from #420 sub-issue acceptance run on v0.5.684.

Activity

  1. added a commit that references this issue on May 8, 2026
  2. proggeramlug commented on May 8, 2026

    @proggeramlug
    ContributorAuthor

    Fixed in 1fd8598 (v0.5.692). Thenable assimilation in js_promise_resolved (since the async-to-generator transform routes await x through Promise.resolve(x).then(...)); plus belt-and-suspenders in codegen Expr::Await for un-transformed paths.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions