Skip to content

await returns stub before setTimeout fires when resolved value is an object literal #77

@proggeramlug

Description

@proggeramlug

Summary

On Perry 0.5.92, await new Promise<T>((resolve) => setTimeout(() => resolve(objLiteral), ms)) returns a stub empty object before the timer fires. The timer fires later, after the awaiter has already observed the stub.

Primitive resolved values (string/number/boolean) work fine; this is specific to object literals.

This is the actual root cause of what we saw in the @perry/postgres driver (filed as #75 from a different symptom). The driver's query() returns Promise<QueryResult>, resolved with an object literal. The awaiter sees typeof r === 'object' but every field is undefined. That also explained why our "Perry beats Bun 50×" tight-loop result was total=1ms avg=1.0µs — the loop was running synchronously, not actually awaiting anything.

Repro (standalone, no Postgres)

interface Result {
    a: number;
    b: string;
}

async function produce(): Promise<Result> {
    return new Promise<Result>((resolve) => {
        setTimeout(() => {
            const r: Result = { a: 42, b: 'hello' };
            console.log('[producer] fired, r.a=' + r.a + ' r.b=' + r.b);
            resolve(r);
        }, 50);
    });
}

async function main(): Promise<void> {
    console.log('[main] awaiting');
    const r = await produce();
    console.log('[main] got typeof=' + typeof r + ' r.a=' + r.a + ' r.b=' + r.b);
}

main().then(() => console.log('[main] done'));

Observed

Perry 0.5.92:

[main] awaiting
[main] got typeof=object r.a=undefined r.b=undefined
[main] done
[producer] fired, r.a=42 r.b=hello     ← fires AFTER main saw the stub

Bun (expected behavior):

[main] awaiting
[producer] fired, r.a=42 r.b=hello
[main] got typeof=object r.a=42 r.b=hello
[main] done

Observations

  • Perry returns a fresh empty object of shape Resulttypeof is object, but none of the declared fields are set.
  • The producer's log fires after main().then(...) — the timer is queued correctly, it just runs too late.
  • Changing the return type to Promise<string> and resolving with a string works correctly.
  • Changing the return type to Promise<number> and resolving with a number works correctly.
  • Affects both Promise<SomeInterface> (interface types) and Promise<{ a: number; b: string }> (inline object types).

Suspected cause

Looks like Perry is specializing Promise<ObjectLiteral> generics by allocating a default-initialized ObjectLiteral at the await site and resolving to it synchronously, instead of waiting for the actual resolve(...) call to land. The correct async suspension / Future-style chaining is bypassed when the resolved type is an object literal.

This also explains the previous #75 symptom ("net.Socket data listener → resolver doesn't wake awaiter") — the awaiter did wake, but with a stub, and so the driver's onConnect promise resolved to a Connection object whose internal fields were all undefined, making subsequent query() calls no-op.

Impact

  • All async driver code in @perry/postgres (and any TS driver that returns rich result objects) produces garbage. Users of the driver can't actually execute queries on Perry.
  • Any user code that resolves Promises with object literals is affected.

Environment

  • Perry 0.5.92
  • macOS 26.4
  • Bun latest for comparison

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