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
- Confirmed via
console.log inside getAccessToken that it returns a 256-char string.
- 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.
- 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)
Symptom
This pattern, which is everywhere in node code:
…silently produces
accessToken === undefined(and the 401 path fires) under Perry, even when:getAccessTokenis logged to be returning a 256-char string (console.loginside the function fires withlength=256)getUserPlanreturns an object whose.planaccess also seems empty at the callsiteRewriting to sequential awaits with no destructuring fixes it immediately:
After that one change, the endpoint returns
clicks: 232, impressions: 17542, daily: [...]correctly — same source, same data, same Perry version, just noPromise.all+ tuple destructure.Repro context
13dc587a(fix(runtime): js_async_step_chain awaits V8 Promise handles instead of passing them through as primitives (#1010))getAccessToken(userId)body (simplified):Promise.all([getAccessToken(userId), getUserPlan(userId)])→ both values come out undefined.await getAccessToken(userId): both values come out correct.What I tried
console.loginsidegetAccessTokenthat it returns a 256-char string.const tokenValue = account.access_token; return tokenValue;) — didn't fix it alone while still using Promise.all.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
13dc587a(built today at v0.5.1004)