diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index e52e82600..b4b544dde 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -18,6 +18,7 @@ import { type MoodAggregates, } from "@/lib/insights/mood-aggregates"; import type { MoodDailySeries } from "@/lib/analytics/mood-series"; +import { settleWithin } from "@/lib/async/settle-within"; import { resolveModuleMap } from "@/lib/modules/gate"; import { queryKeys } from "@/lib/query-keys"; @@ -32,12 +33,7 @@ import InsightsMoodPageClient from "./page-client"; const PREFETCH_BUDGET_MS = 1_500; function withinBudget(work: Promise): Promise { - return Promise.race([ - work, - new Promise((resolve) => - setTimeout(() => resolve(null), PREFETCH_BUDGET_MS), - ), - ]).catch(() => null); + return settleWithin(work, PREFETCH_BUDGET_MS, null).catch(() => null); } /** diff --git a/src/app/page.tsx b/src/app/page.tsx index 8f77e9367..493c3f27e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -13,6 +13,7 @@ import { type BatchWindow, } from "@/lib/dashboard/batch-chart-types"; import { resolveDashboardLayout } from "@/lib/dashboard-layout"; +import { settleWithin } from "@/lib/async/settle-within"; import { loadDailyDigest } from "@/lib/daily/load-digest"; import { readSeriesBatch } from "@/lib/measurements/series-batch-read"; import { queryKeys } from "@/lib/query-keys"; @@ -30,11 +31,6 @@ import DashboardPageClient from "./page-client"; */ const DIGEST_SOFT_BUDGET_MS = 400; -/** Resolve `undefined` after `ms` — the soft-budget loser in the digest race. */ -function softTimeout(ms: number): Promise { - return new Promise((resolve) => setTimeout(() => resolve(undefined), ms)); -} - /** * Thin RSC wrapper around the (client) dashboard page. * @@ -144,7 +140,7 @@ export default async function DashboardPage() { : Promise.resolve(undefined); const [digest, series] = await Promise.all([ - Promise.race([digestWork, softTimeout(DIGEST_SOFT_BUDGET_MS)]), + settleWithin(digestWork, DIGEST_SOFT_BUDGET_MS, undefined), seriesWork, ]); diff --git a/src/lib/async/__tests__/settle-within.test.ts b/src/lib/async/__tests__/settle-within.test.ts new file mode 100644 index 000000000..f706640b5 --- /dev/null +++ b/src/lib/async/__tests__/settle-within.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { settleWithin } from "../settle-within"; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("settleWithin", () => { + it("answers with the work when it lands inside the budget, and leaves no timer", async () => { + vi.useFakeTimers(); + await expect( + settleWithin(Promise.resolve("done"), 1_500, null), + ).resolves.toBe("done"); + // A bare race left the budget timer pending after the work answered. + expect(vi.getTimerCount()).toBe(0); + }); + + it("answers with the fallback once the budget runs out", async () => { + vi.useFakeTimers(); + const pending = settleWithin(new Promise(() => {}), 1_500, null); + await vi.advanceTimersByTimeAsync(1_500); + await expect(pending).resolves.toBeNull(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("passes a rejection inside the budget through, and clears the timer", async () => { + vi.useFakeTimers(); + await expect( + settleWithin(Promise.reject(new Error("boom")), 1_500, null), + ).rejects.toThrow("boom"); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/src/lib/async/settle-within.ts b/src/lib/async/settle-within.ts new file mode 100644 index 000000000..c43cfd6fd --- /dev/null +++ b/src/lib/async/settle-within.ts @@ -0,0 +1,24 @@ +/** + * Wait for `work` at most `ms`, then settle with `fallback` instead. + * + * The budget timer is cleared as soon as either side settles. A bare + * `Promise.race` against a `setTimeout` left the timer running after the + * work had long answered: one live timer per server render or per audited + * sign-in, each holding its closure until it fired. `work` is not + * cancelled; a rejection of `work` inside the budget rejects here too. + */ +export async function settleWithin( + work: Promise, + ms: number, + fallback: F, +): Promise { + let timer: ReturnType | undefined; + const budget = new Promise((resolve) => { + timer = setTimeout(() => resolve(fallback), ms); + }); + try { + return await Promise.race([work, budget]); + } finally { + clearTimeout(timer); + } +} diff --git a/src/lib/auth/audit.ts b/src/lib/auth/audit.ts index a5187881e..0a294a3bb 100644 --- a/src/lib/auth/audit.ts +++ b/src/lib/auth/audit.ts @@ -1,3 +1,4 @@ +import { settleWithin } from "@/lib/async/settle-within"; import { prisma } from "@/lib/db"; import { lookupIpGeo } from "@/lib/geo"; import { getEvent } from "@/lib/logging/context"; @@ -80,11 +81,7 @@ export async function auditLog( // provider can't stall the audit write. if (opts.ipAddress && action.startsWith("auth.")) { const ipAddress = opts.ipAddress; - const timeout = new Promise((resolve) => - setTimeout(() => resolve(null), 3000), - ); - - Promise.race([lookupIpGeo(ipAddress), timeout]) + settleWithin(lookupIpGeo(ipAddress), 3000, null) .then((result) => { if (!result) return; const { location, asn, carrier } = result;