Skip to content

const [a, b] = await Promise.all([fn1(), fn2()]) — destructured values are dropped (undefined) when fn returns property access through async; sequential awaits work #1013

Description

@proggeramlug

Symptom

This pattern, which is everywhere in node code:

const [accessToken, userPlan] = await Promise.all([
  getAccessToken(userId),
  getUserPlan(userId),
]);
if (!accessToken) return reply.status(401).send({ error: "No access token. Please re-authenticate." });

…silently produces accessToken === undefined (and the 401 path fires) under Perry, even when:

  • getAccessToken is logged to be returning a 256-char string (console.log inside the function fires with length=256)
  • getUserPlan returns an object whose .plan access also seems empty at the callsite
  • The underlying token is unexpired (refresh path isn't taken)

Rewriting to sequential awaits with no destructuring fixes it immediately:

const accessToken = await getAccessToken(userId);  // now a real 256-char string
const userPlan = await getUserPlan(userId);

After that one change, the endpoint returns clicks: 232, impressions: 17542, daily: [...] correctly — same source, same data, same Perry version, just no Promise.all + tuple destructure.

Repro context

  • Perry HEAD: 13dc587a (fix(runtime): js_async_step_chain awaits V8 Promise handles instead of passing them through as primitives (#1010))
  • gscmaster-api Fastify route handler. Both fns are cross-module imports.
  • getAccessToken(userId) body (simplified):
    export async function getAccessToken(userId: string): Promise<string | null> {
      const account = await queryOne<DbGoogleAccount>(
        "SELECT * FROM google_accounts WHERE user_id = ? AND is_primary = TRUE", [userId]
      );
      if (!account) return null;
      // [diagnostic logs confirmed account.access_token is 256 chars, needsRefresh=false]
      const tokenValue: string = account.access_token;
      return tokenValue;
    }
  • Caller path: Promise.all([getAccessToken(userId), getUserPlan(userId)]) → both values come out undefined.
  • After change to sequential await getAccessToken(userId): both values come out correct.

What I tried

  1. Confirmed via console.log inside getAccessToken that it returns a 256-char string.
  2. Stored the property access in a local var first (const tokenValue = account.access_token; return tokenValue;) — didn't fix it alone while still using Promise.all.
  3. Replaced Promise.all with sequential awaits — fixed.

Likely shape

Probably an interaction between the Promise.all helper's array-rest gather and the async-step driver's per-call frame, where the resolved values are copied into the result array but the array slots reference frames that have already been popped — so the destructuring reads undefined NaN-box slots.

Workaround

In gscmaster-api I've audited and converted these patterns to sequential awaits:

  • routes/dashboard.ts ([accessToken, userPlan])
  • routes/sites.ts ([currentStats, prevStats])
  • routes/notifications.ts ([preferences, planInfo], [updated, planInfo])
  • lib/notifications/content-generator.ts ([currentStats, previousStats], [currentQueries, previousQueries])

Followups #1010, #1009, #1008 in the changelog look like they're in the right area. This may already be fixed in main after 13dc587a — happy to re-test if there's a newer commit you want me to try, but reporting as-of HEAD.

Environment

  • Perry: 13dc587a (built today at v0.5.1004)
  • Target: Ubuntu 24.04 x86_64 / glibc
  • Project: gscmaster-api (Fastify + mysql2 + fetch)

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