From 5405055d5e17dbd4d9c018be3bf8149ba2a2e66a Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sun, 10 May 2026 18:04:48 -0700 Subject: [PATCH] feat(cache): detect 'use cache' module-scope deadlocks early in dev (#1126) Ports Next.js use-cache deadlock probe (vercel/next.js#93500, vercel/next.js#93538): - UseCacheTimeoutError / UseCacheDeadlockError with digest codes - Dev-only timeout around 'use cache' fills (default 30s) - Probe scheduler that fires after 10s idle time in dev - Worker-thread probe pool that re-executes the function with fresh module scope - Request store snapshot forwarding so private caches resolve correctly - HMR teardown so probes don't use stale module caches - Gated on NODE_ENV === 'development', tree-shakes out of production/Workers New files: - shims/use-cache-errors.ts - server/use-cache-probe-globals.ts - server/use-cache-probe-scheduler.ts - server/use-cache-probe-pool.ts - server/use-cache-probe-worker.ts Modified: - shims/cache-runtime.ts (dev timeout + probe integration) - index.ts (install probe in configureServer) Tests: - tests/use-cache-deadlock.test.ts (10 tests, all passing) --- knip.ts | 3 + packages/vinext/src/index.ts | 12 + .../src/server/use-cache-probe-globals.ts | 60 +++++ .../vinext/src/server/use-cache-probe-pool.ts | 158 ++++++++++++ .../src/server/use-cache-probe-scheduler.ts | 167 +++++++++++++ .../src/server/use-cache-probe-worker.ts | 145 +++++++++++ packages/vinext/src/shims/cache-runtime.ts | 120 +++++++++- packages/vinext/src/shims/use-cache-errors.ts | 60 +++++ tests/use-cache-deadlock.test.ts | 225 ++++++++++++++++++ 9 files changed, 941 insertions(+), 9 deletions(-) create mode 100644 packages/vinext/src/server/use-cache-probe-globals.ts create mode 100644 packages/vinext/src/server/use-cache-probe-pool.ts create mode 100644 packages/vinext/src/server/use-cache-probe-scheduler.ts create mode 100644 packages/vinext/src/server/use-cache-probe-worker.ts create mode 100644 packages/vinext/src/shims/use-cache-errors.ts create mode 100644 tests/use-cache-deadlock.test.ts diff --git a/knip.ts b/knip.ts index def3b15a3e..c173c09e4c 100644 --- a/knip.ts +++ b/knip.ts @@ -73,6 +73,9 @@ export default { // #726-CACHE-01/04 defines the disabled proof boundary before runtime // observation recording or cache reuse is wired in later slices. "src/server/cache-proof.ts", + // Worker entry loaded dynamically by use-cache-probe-pool.ts via + // getWorkerPath() + new Worker(). Not statically importable. + "src/server/use-cache-probe-worker.ts", ], project: ["src/**/*.{ts,tsx}"], }, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 726f47f5bd..8cd303706f 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -16,6 +16,7 @@ import { handleApiRoute } from "./server/api-handler.js"; import { installSocketErrorBackstop } from "./server/socket-error-backstop.js"; import { shouldInvalidateAppRouteFile } from "./server/dev-route-files.js"; import { createDirectRunner } from "./server/dev-module-runner.js"; +import { installUseCacheProbePool } from "./server/use-cache-probe-pool.js"; import { generateRscEntry } from "./entries/app-rsc-entry.js"; import { generateSsrEntry } from "./entries/app-ssr-entry.js"; import { generateBrowserEntry } from "./entries/app-browser-entry.js"; @@ -2072,6 +2073,17 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } }); + // ── "use cache" deadlock probe (dev-only) ─────────────────────────── + if (typeof process !== "undefined" && process.env.NODE_ENV === "development") { + installUseCacheProbePool({ + onInvalidate: (cb) => { + server.watcher.on("add", cb); + server.watcher.on("change", cb); + server.watcher.on("unlink", cb); + }, + }); + } + // ── Dev request origin check ───────────────────────────────────── // Registered directly (not in the returned function) so it runs // BEFORE Vite's built-in middleware. This ensures all requests diff --git a/packages/vinext/src/server/use-cache-probe-globals.ts b/packages/vinext/src/server/use-cache-probe-globals.ts new file mode 100644 index 0000000000..8f43284a82 --- /dev/null +++ b/packages/vinext/src/server/use-cache-probe-globals.ts @@ -0,0 +1,60 @@ +/** + * Cross-module handoff for the "use cache" hang-detection probe. + * + * A symbol on globalThis decouples the dev-server entry point (which installs + * the worker pool) from the read site inside cache-runtime.ts — avoiding a + * direct import of dev-only code from the cache module. In any process where the + * symbol is not set (production, edge, unit tests, the probe worker itself) + * getUseCacheProbe() returns undefined, which doubles as the recursion guard + * against a probe spawning another probe. + */ + +const SYMBOL: unique symbol = Symbol.for("vinext.dev.useCacheProbe"); + +/** + * Serializable view of the outer request store forwarded to the probe worker. + * The worker rebuilds a real request context from this so cache bodies that + * read cookies(), headers(), or draftMode() behave the same as in a real fill. + */ +export type UseCacheProbeRequestSnapshot = { + headers: [string, string][]; + cookieHeader: string | undefined; + urlPathname: string; + urlSearch: string; + rootParams: Record; + isDraftMode: boolean; + isHmrRefresh: boolean; +}; + +/** + * Probe hook installed by the dev server. Resolves to true if the cache + * function ran to completion in isolation — the strong signal that shared + * outer-scope state is deadlocking the main fill. Resolves to false for any + * other outcome (probe timeout, decode failure, missing module, etc.). + */ +type UseCacheProbe = (args: { + /** Module path of the file containing the cached function. */ + modulePath: string; + /** Export name / server reference id of the cached function. */ + id: string; + /** Cache variant (e.g. "" for default, "remote", "private"). */ + variant: string; + /** Serialized function arguments (string from stableStringify or RSC encodeReply). */ + encodedArguments: string; + /** Forwarded request store snapshot so private caches resolve correctly. */ + request: UseCacheProbeRequestSnapshot; + /** Internal timeout for the probe worker. */ + timeoutMs: number; +}) => Promise; + +type ProbeHolder = { + [SYMBOL]?: UseCacheProbe; +}; + +export function setUseCacheProbe(fn: UseCacheProbe | undefined): void { + (globalThis as unknown as ProbeHolder)[SYMBOL] = fn; +} + +export function getUseCacheProbe(): UseCacheProbe | undefined { + return (globalThis as unknown as ProbeHolder)[SYMBOL]; +} diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts new file mode 100644 index 0000000000..69fc92a92d --- /dev/null +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -0,0 +1,158 @@ +/** + * Dev-only "use cache" probe worker pool. + * + * Uses Node.js worker_threads to re-execute a cached function in isolation. + * The worker bypasses the ESM cache by appending ?v=probe- to the + * module URL, giving it a fresh module scope. + * + * The pool is torn down on HMR / file invalidation so the next probe starts + * with empty module caches. + */ + +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import fs from "node:fs"; +import { setUseCacheProbe, type UseCacheProbeRequestSnapshot } from "./use-cache-probe-globals.js"; + +let pool: WorkerPool | undefined; + +type UseCacheProbePoolOptions = { + /** Called whenever the dev server invalidates caches (HMR, route recompile). */ + onInvalidate?: (callback: () => void) => void; +}; + +type ProbeMessage = { + modulePath: string; + functionId: string; + variant: string; + encodedArguments: string; + request: UseCacheProbeRequestSnapshot; + timeoutMs: number; +}; + +type WorkerPool = { + runProbe(msg: ProbeMessage): Promise; + end(): Promise; +}; + +function getWorkerPath(): string { + const poolDir = fileURLToPath(new URL(".", import.meta.url)); + // When running from source the file is .ts; when built it's .js. + const tsPath = path.join(poolDir, "use-cache-probe-worker.ts"); + if (fs.existsSync(tsPath)) return tsPath; + return path.join(poolDir, "use-cache-probe-worker.js"); +} + +function createWorkerPool(): WorkerPool { + // Lazy-import worker_threads so this module can be loaded in contexts + // where Node.js built-ins are unavailable (e.g. browser builds, though + // this whole path is dev-only and never reached in production). + const { Worker } = require("node:worker_threads") as typeof import("node:worker_threads"); + + const workerPath = getWorkerPath(); + + // Inherit parent's execArgv so tsx loader (or any other loader) propagates + // to the worker. This lets the worker import .ts files when the parent runs + // under tsx (the common vinext dev workflow). + const execArgv = [...process.execArgv]; + + const worker = new Worker(workerPath, { + execArgv, + stderr: true, + stdout: true, + }); + + let nextId = 0; + const pending = new Map< + number, + { resolve: (v: boolean) => void; reject: (e: unknown) => void } + >(); + + worker.on( + "message", + (msg: { id: number; completed: boolean } | { id: number; error: string }) => { + const handler = pending.get(msg.id); + if (!handler) return; + pending.delete(msg.id); + if ("error" in msg) { + handler.reject(new Error(msg.error)); + } else { + handler.resolve(msg.completed); + } + }, + ); + + worker.on("error", (err: Error) => { + for (const [, handler] of pending) { + handler.reject(err); + } + pending.clear(); + }); + + worker.on("exit", (code: number) => { + if (code !== 0) { + for (const [, handler] of pending) { + handler.reject(new Error(`Probe worker exited with code ${code}`)); + } + pending.clear(); + } + }); + + return { + runProbe(msg: ProbeMessage): Promise { + const id = ++nextId; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + worker.postMessage({ id, ...msg }); + }); + }, + async end(): Promise { + await worker.terminate().catch(() => {}); + }, + }; +} + +function getPool(): WorkerPool { + if (!pool) { + pool = createWorkerPool(); + } + return pool; +} + +async function tearDownPool(): Promise { + const current = pool; + if (!current) return; + pool = undefined; + await current.end().catch(() => {}); +} + +export function installUseCacheProbePool(options?: UseCacheProbePoolOptions): void { + if (options?.onInvalidate) { + options.onInvalidate(() => { + void tearDownPool(); + }); + } + + setUseCacheProbe(async (args) => { + let activePool: WorkerPool; + try { + activePool = getPool(); + } catch { + return false; + } + + try { + return await activePool.runProbe({ + modulePath: args.modulePath, + functionId: args.id, + variant: args.variant, + encodedArguments: args.encodedArguments, + request: args.request, + timeoutMs: args.timeoutMs, + }); + } catch { + await tearDownPool(); + return false; + } + }); +} diff --git a/packages/vinext/src/server/use-cache-probe-scheduler.ts b/packages/vinext/src/server/use-cache-probe-scheduler.ts new file mode 100644 index 0000000000..53606534fc --- /dev/null +++ b/packages/vinext/src/server/use-cache-probe-scheduler.ts @@ -0,0 +1,167 @@ +/** + * "use cache" deadlock probe scheduler (dev-only) + * + * Ported from Next.js: packages/next/src/server/use-cache/use-cache-probe-scheduler.ts + * https://github.com/vercel/next.js/blob/canary/packages/next/src/server/use-cache/use-cache-probe-scheduler.ts + * + * Monitors a hanging "use cache" function execution in dev. If the Promise + * has not settled for ~10s, spawns a probe worker that re-executes the same + * function with a fresh module scope. If the probe completes while the main + * execution is still hung, the hang is attributable to module-scope state + * and we surface a UseCacheDeadlockError instead of waiting for the generic + * timeout. + * + * Vinext adaptation: Next.js monitors a ReadableStream (cache-fill stream). + * Vinext dev mode skips shared caching entirely, so there is no stream — we + * monitor the Promise directly via idle-time tracking. + */ + +import { getUseCacheProbe, type UseCacheProbeRequestSnapshot } from "./use-cache-probe-globals.js"; + +function getProbeThresholdMs(): number { + const env = + typeof process !== "undefined" ? Number(process.env.__VINEXT_PROBE_THRESHOLD_MS) : NaN; + if (Number.isFinite(env) && env > 0) return env; + return 10_000; +} + +function getProbeMinBudgetMs(): number { + const env = + typeof process !== "undefined" ? Number(process.env.__VINEXT_PROBE_MIN_BUDGET_MS) : NaN; + if (Number.isFinite(env) && env > 0) return env; + return 3_000; +} + +type SetupPromiseProbeOptions = { + /** The function identifier (module path + export name). */ + id: string; + /** Cache variant. */ + variant: string; + /** Serialized arguments for the probe worker. */ + encodedArguments: string; + /** Snapshot of the outer request store for private caches. */ + requestSnapshot: UseCacheProbeRequestSnapshot; + /** Absolute monotonic deadline at which the outer fill will be aborted. */ + fillDeadlineAt: number; + /** Aborts when the probe should stop watching (fill settled, timeout, etc.). */ + abortSignal: AbortSignal; + /** + * Called once if the probe ran the cache function to completion in isolation + * while the main fill was still pending. The caller aborts the main fill + * with UseCacheDeadlockError. + */ + onProbeCompleted: () => void; +}; + +/** + * Schedule an idle-deadline probe over a hanging "use cache" Promise (dev-only). + * + * Returns a cleanup function that clears any pending timers. The caller must + * call it when the main Promise settles (success or error). + */ +export function setupPromiseProbe(options: SetupPromiseProbeOptions): () => void { + const { + fillDeadlineAt, + abortSignal, + onProbeCompleted, + id, + variant, + encodedArguments, + requestSnapshot, + } = options; + + // Skip if there isn't enough time left for both the idle threshold and a + // minimum probe budget. + const probeThresholdMs = getProbeThresholdMs(); + const minBudgetMs = getProbeMinBudgetMs(); + if (fillDeadlineAt - performance.now() < probeThresholdMs + minBudgetMs) { + return () => {}; + } + + const probe = getUseCacheProbe(); + if (!probe) { + return () => {}; + } + + let lastProgressAt = performance.now(); + let idleTimer: ReturnType | undefined; + + const startProbe = () => { + if (abortSignal.aborted) return; + + const probeStartedAtProgress = lastProgressAt; + + // Reserve a 1s buffer so the probe's internal timeout fires before the + // outer render timeout. + const probeInternalTimeoutMs = Math.max(1_000, fillDeadlineAt - performance.now() - 1_000); + + probe({ + modulePath: id.split(":")[0] ?? id, + id, + variant, + encodedArguments, + request: requestSnapshot, + timeoutMs: probeInternalTimeoutMs, + }).then( + (completed) => { + // Mid-probe recovery: if progress was made while the probe ran, discard + // the result rather than reporting a stale deadlock. + if (lastProgressAt > probeStartedAtProgress) return; + if (completed && !abortSignal.aborted) { + onProbeCompleted(); + } + }, + () => { + // Probe failures are inconclusive; fall back to regular timeout. + }, + ); + }; + + const scheduleAfterIdle = () => { + if (idleTimer !== undefined || abortSignal.aborted) return; + const now = performance.now(); + const idleFor = now - lastProgressAt; + const wait = Math.max(0, probeThresholdMs - idleFor); + + if (fillDeadlineAt - now < wait + minBudgetMs) return; + + idleTimer = setTimeout(() => { + idleTimer = undefined; + if (abortSignal.aborted) return; + const idleNow = performance.now() - lastProgressAt; + if (idleNow < probeThresholdMs) { + // Progress arrived since we set this timer — reschedule. + scheduleAfterIdle(); + return; + } + startProbe(); + }, wait); + }; + + abortSignal.addEventListener( + "abort", + () => { + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + idleTimer = undefined; + } + }, + { once: true }, + ); + + scheduleAfterIdle(); + + // Return a "report progress" function the caller calls whenever the main + // Promise makes progress (e.g. a microtask completes, a chunk arrives, etc.). + // In the simple Promise case, there is no granular progress, so we rely on + // the mid-probe recovery check to avoid false positives when the Promise + // settles during the probe. + return () => { + lastProgressAt = performance.now(); + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + idleTimer = undefined; + } + scheduleAfterIdle(); + }; +} diff --git a/packages/vinext/src/server/use-cache-probe-worker.ts b/packages/vinext/src/server/use-cache-probe-worker.ts new file mode 100644 index 0000000000..413df3c7b5 --- /dev/null +++ b/packages/vinext/src/server/use-cache-probe-worker.ts @@ -0,0 +1,145 @@ +/** + * "use cache" probe worker entry point. + * + * Runs in a Node.js worker_thread. Re-imports the user module with a fresh + * ESM scope, rebuilds a minimal request ALS context from the forwarded + * snapshot, and executes the cached function. Posts `{ completed: true }` if + * the function resolves, `{ completed: false }` or `{ error: string }` + * otherwise. + * + * The fresh module scope is achieved by appending `?v=probe-` to + * the module URL, bypassing the Node.js ESM cache for that import. + */ + +import { parentPort } from "node:worker_threads"; +import { runWithPrivateCache } from "vinext/shims/cache-runtime"; +import { createRequestContext, runWithRequestContext } from "vinext/shims/unified-request-context"; +import { headersContextFromRequest } from "vinext/shims/headers"; + +type ProbeMessage = { + id: number; + modulePath: string; + functionId: string; + variant: string; + encodedArguments: string; + request: { + headers: [string, string][]; + cookieHeader: string | undefined; + urlPathname: string; + urlSearch: string; + rootParams: Record; + isDraftMode: boolean; + isHmrRefresh: boolean; + }; + timeoutMs: number; +}; + +if (!parentPort) { + throw new Error("use-cache-probe-worker must be run inside a worker_thread"); +} + +function postCompleted(id: number, completed: boolean): void { + parentPort!.postMessage({ id, completed }); +} + +function postError(id: number, error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + parentPort!.postMessage({ id, error: message }); +} + +parentPort.on("message", async (msg: ProbeMessage) => { + const { id, modulePath, functionId, encodedArguments, request, timeoutMs } = msg; + + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + // Re-import with a fresh ESM cache via query suffix. + const freshUrl = `${modulePath}?v=probe-${Date.now()}`; + const mod = await import(freshUrl); + + // Resolve the cached wrapper. In the transformed code the export is + // wrapped with registerCachedFunction(..., id, variant). The wrapper is + // usually the default export or a named export matching the original + // function name. We try the most common patterns. + let wrappedFn: ((...args: unknown[]) => Promise) | undefined; + + // Try to find a function whose internal id matches. + for (const key of Object.keys(mod)) { + const val = mod[key]; + if ( + typeof val === "function" && + (val as Record).__vinextCacheId === functionId + ) { + wrappedFn = val as (...args: unknown[]) => Promise; + break; + } + } + + // Fallback: if the module re-exported the wrapper under the original name + if (!wrappedFn) { + const exportName = functionId.split(":").pop() ?? "default"; + wrappedFn = mod[exportName] ?? mod.default; + } + + if (typeof wrappedFn !== "function") { + postCompleted(id, false); + return; + } + + // Decode arguments. The probe uses the stable-stringify JSON form that + // cache-runtime.ts produces when RSC encodeReply is unavailable, or the + // string form of encodeReply output. + let args: unknown[]; + try { + args = JSON.parse(encodedArguments); + } catch { + // If encodedArguments is not JSON (e.g. it came from encodeReply as a + // string cache key), treat it as a single string argument. + args = [encodedArguments]; + } + + // Rebuild a minimal request context from the snapshot so private caches + // that read cookies/headers resolve correctly. + const headers = new Headers(request.headers); + const headersContext = headersContextFromRequest( + new Request(`http://localhost${request.urlPathname}${request.urlSearch}`, { + headers, + }), + ); + const requestContext = createRequestContext({ + headersContext, + executionContext: null, + unstableCacheRevalidation: "foreground", + }); + + // Run inside a private-cache ALS scope so "use cache: private" behaves + // the same as in the main render. + const result = await runWithRequestContext(requestContext, () => + runWithPrivateCache(async () => { + // If the abort signal fires, turn it into a rejection so the caller + // sees it as a probe timeout (inconclusive). + const abortPromise = new Promise((_, reject) => { + if (controller.signal.aborted) { + reject(new Error("Probe aborted by timeout")); + } else { + controller.signal.addEventListener( + "abort", + () => reject(new Error("Probe aborted by timeout")), + { + once: true, + }, + ); + } + }); + return Promise.race([wrappedFn!(...args), abortPromise]); + }), + ); + + clearTimeout(timer); + postCompleted(id, true); + void result; // result is not used, we only care that it resolved + } catch (error) { + postError(id, error); + } +}); diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 0bbbd337e9..0c5122e98d 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -42,6 +42,11 @@ import { getRequestContext, runWithUnifiedStateMutation, } from "./unified-request-context.js"; +import { UseCacheTimeoutError, UseCacheDeadlockError } from "./use-cache-errors.js"; +import { + getUseCacheProbe, + type UseCacheProbeRequestSnapshot, +} from "../server/use-cache-probe-globals.js"; // --------------------------------------------------------------------------- // Cache execution context — AsyncLocalStorage for cacheLife/cacheTag @@ -339,16 +344,11 @@ export function registerCachedFunction Promise => { + // Evaluate isDev at call time so tests and HMR can toggle NODE_ENV + // without re-importing the module. + const isDev = typeof process !== "undefined" && process.env.NODE_ENV === "development"; const rsc = await getRscModule(); const keySeed = getUseCacheKeySeed(); @@ -398,8 +398,10 @@ export function registerCachedFunction Promise Promise>( return result; } +// --------------------------------------------------------------------------- +// Dev-only: timeout + deadlock probe around executeWithContext +// --------------------------------------------------------------------------- + +function getUseCacheFillTimeoutMs(): number { + const envTimeout = + typeof process !== "undefined" ? Number(process.env.__VINEXT_USE_CACHE_TIMEOUT) : NaN; + if (Number.isFinite(envTimeout) && envTimeout > 0) { + return envTimeout * 1000; + } + // Default 30s, matching Next.js experimental.useCacheTimeout default. + return 30_000; +} + +function buildProbeRequestSnapshot(): UseCacheProbeRequestSnapshot { + const ctx = isInsideUnifiedScope() ? getRequestContext() : null; + const headersContext = ctx?.headersContext; + const headers: [string, string][] = []; + let cookieHeader: string | undefined; + if (headersContext) { + for (const [k, v] of headersContext.headers.entries()) { + headers.push([k, v]); + if (k.toLowerCase() === "cookie") cookieHeader = v; + } + } + return { + headers, + cookieHeader, + urlPathname: ctx?.serverContext?.pathname ?? "/", + urlSearch: ctx?.serverContext?.searchParams?.toString() ?? "", + rootParams: (ctx?.rootParams ?? {}) as Record, + isDraftMode: false, + isHmrRefresh: false, + }; +} + +// oxlint-disable-next-line @typescript-eslint/no-explicit-any +async function executeWithDevTimeoutAndProbe Promise>( + fn: T, + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + args: any[], + variant: string, + id: string, + _cacheKey: string, +): Promise>> { + const timeoutMs = getUseCacheFillTimeoutMs(); + const timeoutError = new UseCacheTimeoutError(); + + let deadlockError: UseCacheDeadlockError | undefined; + + const devRenderAbortController = new AbortController(); + let timer: ReturnType; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => reject(timeoutError), timeoutMs); + devRenderAbortController.signal.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(deadlockError ?? (devRenderAbortController.signal.reason as Error)); + }, + { once: true }, + ); + }); + + let probeCleanup: (() => void) | undefined; + const probe = getUseCacheProbe(); + if (probe) { + try { + const { setupPromiseProbe } = await import("../server/use-cache-probe-scheduler.js"); + const requestSnapshot = buildProbeRequestSnapshot(); + const argsKey = args.length > 0 ? stableStringify(args) : ""; + deadlockError = new UseCacheDeadlockError(); + probeCleanup = setupPromiseProbe({ + id, + variant: variant || "default", + encodedArguments: argsKey, + requestSnapshot, + fillDeadlineAt: performance.now() + timeoutMs, + abortSignal: devRenderAbortController.signal, + onProbeCompleted: () => { + devRenderAbortController.abort(deadlockError); + }, + }); + } catch { + // Probe scheduler unavailable — fall back to plain timeout. + } + } + + try { + const result = await Promise.race([executeWithContext(fn, args, variant), timeoutPromise]); + clearTimeout(timer!); + probeCleanup?.(); + return result; + } catch (error) { + clearTimeout(timer!); + probeCleanup?.(); + throw error; + } +} + // --------------------------------------------------------------------------- // Unwrap Promise-augmented objects for cache key generation // --------------------------------------------------------------------------- diff --git a/packages/vinext/src/shims/use-cache-errors.ts b/packages/vinext/src/shims/use-cache-errors.ts new file mode 100644 index 0000000000..55be29f47b --- /dev/null +++ b/packages/vinext/src/shims/use-cache-errors.ts @@ -0,0 +1,60 @@ +/** + * "use cache" error classes + * + * Ported from Next.js: packages/next/src/server/use-cache/use-cache-errors.ts + * https://github.com/vercel/next.js/blob/canary/packages/next/src/server/use-cache/use-cache-errors.ts + * + * These errors surface when a "use cache" fill stalls in dev mode. + * - UseCacheTimeoutError: generic timeout (likely hanging I/O or request-specific + * APIs used inside the cache body). + * - UseCacheDeadlockError: detected via the dev probe — the cache function + * completed in isolation, suggesting module-scope state is deadlocking the + * main render. + */ + +const USE_CACHE_TIMEOUT_ERROR_CODE = "USE_CACHE_TIMEOUT"; +const USE_CACHE_DEADLOCK_ERROR_CODE = "USE_CACHE_DEADLOCK"; + +export class UseCacheTimeoutError extends Error { + digest: typeof USE_CACHE_TIMEOUT_ERROR_CODE = USE_CACHE_TIMEOUT_ERROR_CODE; + + constructor() { + super( + 'Filling a cache during prerender timed out, likely because request-specific arguments such as params, searchParams, cookies() or dynamic data were used inside "use cache".', + ); + } +} + +export class UseCacheDeadlockError extends Error { + digest: typeof USE_CACHE_DEADLOCK_ERROR_CODE = USE_CACHE_DEADLOCK_ERROR_CODE; + + constructor() { + super( + 'Filling a "use cache" entry appears to be stuck on shared state from the outer render scope. The same function completed when run in isolation, which usually means a module-scoped value (for example a top-level Map used to dedupe fetches) is joining a promise created outside the cache. "use cache" already dedupes calls with the same arguments — within a request and across requests on the same server instance — so the surrounding dedupe layer is both unnecessary and the likely cause. Remove it and rely on "use cache" alone for deduping.', + ); + } +} + +export function isUseCacheTimeoutError(err: unknown): err is UseCacheTimeoutError { + if ( + typeof err !== "object" || + err === null || + !("digest" in err) || + typeof (err as Record).digest !== "string" + ) { + return false; + } + return (err as Record).digest === USE_CACHE_TIMEOUT_ERROR_CODE; +} + +export function isUseCacheDeadlockError(err: unknown): err is UseCacheDeadlockError { + if ( + typeof err !== "object" || + err === null || + !("digest" in err) || + typeof (err as Record).digest !== "string" + ) { + return false; + } + return (err as Record).digest === USE_CACHE_DEADLOCK_ERROR_CODE; +} diff --git a/tests/use-cache-deadlock.test.ts b/tests/use-cache-deadlock.test.ts new file mode 100644 index 0000000000..d6bcdf45a0 --- /dev/null +++ b/tests/use-cache-deadlock.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; + +describe("use-cache deadlock probe", () => { + afterEach(() => { + // oxlint-disable-next-line typescript/no-explicit-any + delete (globalThis as any)[Symbol.for("vinext.dev.useCacheProbe")]; + }); + + // ------------------------------------------------------------------------- + // Error classes + // ------------------------------------------------------------------------- + + describe("UseCacheTimeoutError", () => { + it("has digest USE_CACHE_TIMEOUT", async () => { + const { UseCacheTimeoutError, isUseCacheTimeoutError } = + await import("../packages/vinext/src/shims/use-cache-errors.js"); + const err = new UseCacheTimeoutError(); + expect(err.digest).toBe("USE_CACHE_TIMEOUT"); + expect(isUseCacheTimeoutError(err)).toBe(true); + expect(err.message).toContain("Filling a cache during prerender timed out"); + }); + + it("isUseCacheTimeoutError returns false for plain errors", async () => { + const { isUseCacheTimeoutError } = + await import("../packages/vinext/src/shims/use-cache-errors.js"); + expect(isUseCacheTimeoutError(new Error("plain"))).toBe(false); + expect(isUseCacheTimeoutError(null)).toBe(false); + expect(isUseCacheTimeoutError(undefined)).toBe(false); + expect(isUseCacheTimeoutError("string")).toBe(false); + }); + }); + + describe("UseCacheDeadlockError", () => { + it("has digest USE_CACHE_DEADLOCK", async () => { + const { UseCacheDeadlockError, isUseCacheDeadlockError } = + await import("../packages/vinext/src/shims/use-cache-errors.js"); + const err = new UseCacheDeadlockError(); + expect(err.digest).toBe("USE_CACHE_DEADLOCK"); + expect(isUseCacheDeadlockError(err)).toBe(true); + expect(err.message).toContain("stuck on shared state from the outer render scope"); + }); + + it("isUseCacheDeadlockError returns false for plain errors", async () => { + const { isUseCacheDeadlockError } = + await import("../packages/vinext/src/shims/use-cache-errors.js"); + expect(isUseCacheDeadlockError(new Error("plain"))).toBe(false); + expect(isUseCacheDeadlockError(null)).toBe(false); + expect(isUseCacheDeadlockError(undefined)).toBe(false); + }); + }); + + // ------------------------------------------------------------------------- + // Probe globals + // ------------------------------------------------------------------------- + + describe("use-cache-probe-globals", () => { + it("setUseCacheProbe / getUseCacheProbe round-trip", async () => { + const { setUseCacheProbe, getUseCacheProbe } = + await import("../packages/vinext/src/server/use-cache-probe-globals.js"); + const probe = vi.fn().mockResolvedValue(true); + setUseCacheProbe(probe); + expect(getUseCacheProbe()).toBe(probe); + setUseCacheProbe(undefined); + expect(getUseCacheProbe()).toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------------- + // Dev timeout (no probe installed) + // ------------------------------------------------------------------------- + + describe("dev timeout without probe", () => { + it("throws UseCacheTimeoutError when function hangs longer than timeout", async () => { + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { UseCacheTimeoutError } = + await import("../packages/vinext/src/shims/use-cache-errors.js"); + + const oldEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "development"; + const oldTimeout = process.env.__VINEXT_USE_CACHE_TIMEOUT; + process.env.__VINEXT_USE_CACHE_TIMEOUT = "0.05"; // 50 ms + + const fn = async () => { + await new Promise(() => {}); // never resolves + return "unreachable"; + }; + const cached = registerCachedFunction(fn, "test:hang"); + + await expect(cached()).rejects.toBeInstanceOf(UseCacheTimeoutError); + + process.env.NODE_ENV = oldEnv; + if (oldTimeout === undefined) delete process.env.__VINEXT_USE_CACHE_TIMEOUT; + else process.env.__VINEXT_USE_CACHE_TIMEOUT = oldTimeout; + }, 5_000); + + it("resolves normally when function completes before timeout", async () => { + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + + const oldEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "development"; + const oldTimeout = process.env.__VINEXT_USE_CACHE_TIMEOUT; + process.env.__VINEXT_USE_CACHE_TIMEOUT = "5"; + + const fn = async (x: number) => x * 2; + const cached = registerCachedFunction(fn, "test:fast"); + + await expect(cached(7)).resolves.toBe(14); + + process.env.NODE_ENV = oldEnv; + if (oldTimeout === undefined) delete process.env.__VINEXT_USE_CACHE_TIMEOUT; + else process.env.__VINEXT_USE_CACHE_TIMEOUT = oldTimeout; + }); + }); + + // ------------------------------------------------------------------------- + // Dev timeout with probe installed + // ------------------------------------------------------------------------- + + describe("dev deadlock probe", () => { + it("throws UseCacheDeadlockError when probe completes while main hangs", async () => { + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { UseCacheDeadlockError } = + await import("../packages/vinext/src/shims/use-cache-errors.js"); + const { setUseCacheProbe } = + await import("../packages/vinext/src/server/use-cache-probe-globals.js"); + + const oldEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "development"; + const oldTimeout = process.env.__VINEXT_USE_CACHE_TIMEOUT; + const oldProbe = process.env.__VINEXT_PROBE_THRESHOLD_MS; + process.env.__VINEXT_USE_CACHE_TIMEOUT = "10"; // 10 s fill timeout + process.env.__VINEXT_PROBE_THRESHOLD_MS = "50"; // 50 ms probe threshold + + // Install a mock probe that "completes" immediately (positive signal) + setUseCacheProbe(async () => true); + + const fn = async () => { + await new Promise(() => {}); // never resolves + return "unreachable"; + }; + const cached = registerCachedFunction(fn, "test:deadlock"); + + await expect(cached()).rejects.toBeInstanceOf(UseCacheDeadlockError); + + process.env.NODE_ENV = oldEnv; + if (oldTimeout === undefined) delete process.env.__VINEXT_USE_CACHE_TIMEOUT; + else process.env.__VINEXT_USE_CACHE_TIMEOUT = oldTimeout; + if (oldProbe === undefined) delete process.env.__VINEXT_PROBE_THRESHOLD_MS; + else process.env.__VINEXT_PROBE_THRESHOLD_MS = oldProbe; + setUseCacheProbe(undefined); + }, 5_000); + + it("falls back to UseCacheTimeoutError when probe returns false", async () => { + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { UseCacheTimeoutError } = + await import("../packages/vinext/src/shims/use-cache-errors.js"); + const { setUseCacheProbe } = + await import("../packages/vinext/src/server/use-cache-probe-globals.js"); + + const oldEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "development"; + const oldTimeout = process.env.__VINEXT_USE_CACHE_TIMEOUT; + process.env.__VINEXT_USE_CACHE_TIMEOUT = "0.05"; // 50 ms + + // Probe returns false (inconclusive) + setUseCacheProbe(async () => false); + + const fn = async () => { + await new Promise(() => {}); + return "unreachable"; + }; + const cached = registerCachedFunction(fn, "test:probe-false"); + + await expect(cached()).rejects.toBeInstanceOf(UseCacheTimeoutError); + + process.env.NODE_ENV = oldEnv; + if (oldTimeout === undefined) delete process.env.__VINEXT_USE_CACHE_TIMEOUT; + else process.env.__VINEXT_USE_CACHE_TIMEOUT = oldTimeout; + setUseCacheProbe(undefined); + }, 5_000); + + it("does not throw deadlock if main resolves during probe", async () => { + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { setUseCacheProbe } = + await import("../packages/vinext/src/server/use-cache-probe-globals.js"); + + const oldEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "development"; + const oldTimeout = process.env.__VINEXT_USE_CACHE_TIMEOUT; + process.env.__VINEXT_USE_CACHE_TIMEOUT = "2"; + + // Slow probe that returns true after 150 ms, but main resolves in 80 ms + setUseCacheProbe(async () => { + await new Promise((r) => setTimeout(r, 150)); + return true; + }); + + let resolveMain: (() => void) | undefined; + const fn = async () => { + await new Promise((r) => { + resolveMain = r; + }); + return "resolved"; + }; + const cached = registerCachedFunction(fn, "test:mid-probe-recovery"); + + const promise = cached(); + // Let the probe schedule start (50 ms threshold) but resolve main before + // the probe completes. + setTimeout(() => resolveMain?.(), 80); + + await expect(promise).resolves.toBe("resolved"); + + process.env.NODE_ENV = oldEnv; + if (oldTimeout === undefined) delete process.env.__VINEXT_USE_CACHE_TIMEOUT; + else process.env.__VINEXT_USE_CACHE_TIMEOUT = oldTimeout; + setUseCacheProbe(undefined); + }, 5_000); + }); +});