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
Result — typeof 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
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/postgresdriver (filed as #75 from a different symptom). The driver'squery()returnsPromise<QueryResult>, resolved with an object literal. The awaiter seestypeof r === 'object'but every field isundefined. That also explained why our "Perry beats Bun 50×" tight-loop result wastotal=1ms avg=1.0µs— the loop was running synchronously, not actually awaiting anything.Repro (standalone, no Postgres)
Observed
Perry 0.5.92:
Bun (expected behavior):
Observations
Result—typeofisobject, but none of the declared fields are set.main().then(...)— the timer is queued correctly, it just runs too late.Promise<string>and resolving with a string works correctly.Promise<number>and resolving with a number works correctly.Promise<SomeInterface>(interface types) andPromise<{ a: number; b: string }>(inline object types).Suspected cause
Looks like Perry is specializing
Promise<ObjectLiteral>generics by allocating a default-initializedObjectLiteralat theawaitsite and resolving to it synchronously, instead of waiting for the actualresolve(...)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
onConnectpromise resolved to aConnectionobject whose internal fields were all undefined, making subsequentquery()calls no-op.Impact
@perry/postgres(and any TS driver that returns rich result objects) produces garbage. Users of the driver can't actually execute queries on Perry.Environment