diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index aeb86803a..f05f826b2 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -153,6 +153,19 @@ function createRscCompatibilityId(nextConfig: ResolvedNextConfig): string { type ASTNode = ReturnType["body"][number]["parent"]; const __dirname = import.meta.dirname; + +// Lazy load the use-cache probe pool so it is only loaded when the dev +// server starts (configureServer), not during production builds. +let _probePoolModulePromise: Promise | null = + null; +function getProbePoolModule(): Promise { + _probePoolModulePromise ??= import("./server/use-cache-probe-pool.js").catch((error) => { + _probePoolModulePromise = null; + throw error; + }); + return _probePoolModulePromise; +} + type VitePluginReactModule = typeof import("@vitejs/plugin-react"); function resolveOptionalDependency(projectRoot: string, specifier: string): string | null { @@ -2381,7 +2394,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } }, - configureServer(server: ViteDevServer) { + async configureServer(server: ViteDevServer) { // Watch route files for additions/removals to invalidate route cache. const pageExtensions = fileMatcher.extensionRegex; @@ -2439,10 +2452,19 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } } - function invalidateAppRoutingModules() { + async function invalidateAppRoutingModules() { invalidateAppRouteCache(); invalidateRscEntryModule(); invalidateRootParamsModule(); + const { tearDownUseCacheProbePool, initUseCacheProbePool } = await getProbePoolModule(); + // Tear down the use-cache probe pool so the next probe starts with + // fresh code after HMR invalidation. + tearDownUseCacheProbePool(); + // Re-initialize so probes continue working after HMR. + const rscEnv = server.environments["rsc"]; + if (rscEnv) { + initUseCacheProbePool(rscEnv); + } } let appRouteTypeGeneration: Promise | null = null; @@ -2498,7 +2520,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { invalidateRouteCache(pagesDir); } if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) { - invalidateAppRoutingModules(); + invalidateAppRoutingModules().catch((err) => { + console.warn("[vinext] Failed to invalidate app routing modules:", err); + }); regenerateAppRouteTypes(); } }); @@ -2507,7 +2531,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { invalidateRouteCache(pagesDir); } if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) { - invalidateAppRoutingModules(); + invalidateAppRoutingModules().catch((err) => { + console.warn("[vinext] Failed to invalidate app routing modules:", err); + }); regenerateAppRouteTypes(); } }); @@ -2574,6 +2600,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // it is flushed to the client. // 2. Logs the full request after res finishes, using those timings. if (hasAppDir) { + // Initialize the "use cache" deadlock probe pool for the App + // Router dev server. We bind to the RSC environment because + // "use cache" functions run inside the RSC module graph. + const rscEnv = server.environments["rsc"]; + if (rscEnv) { + getProbePoolModule() + .then(({ initUseCacheProbePool }) => initUseCacheProbePool(rscEnv)) + .catch((err) => { + console.warn("[vinext] Failed to initialize use-cache probe pool:", err); + }); + } + server.middlewares.use((req, res, next) => { const url = req.url ?? "/"; // Skip Vite internals, HMR, and static assets. diff --git a/packages/vinext/src/server/dev-module-runner.ts b/packages/vinext/src/server/dev-module-runner.ts index b8d31daf9..07df21cf8 100644 --- a/packages/vinext/src/server/dev-module-runner.ts +++ b/packages/vinext/src/server/dev-module-runner.ts @@ -65,7 +65,7 @@ import type { DevEnvironment } from "vite"; * environment types — including Cloudflare's custom environments that don't * support the hot-channel-based transport. */ -type DevEnvironmentLike = { +export type DevEnvironmentLike = { fetchModule: ( id: string, importer?: string, 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 000000000..209e45962 --- /dev/null +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -0,0 +1,238 @@ +/** + * use-cache-probe-pool.ts + * + * Manages isolated ModuleRunners for "use cache" deadlock probes. + * + * In dev mode, when a cache fill appears stuck, we re-run the same cache + * function in a fresh module graph. If it completes there but the main fill + * is still hung, the hang is attributable to module-scope shared state + * (e.g. a top-level Map used to dedupe fetches) from the outer render. + * + * Unlike Next.js (which uses jest-worker with real OS processes), vinext + * creates a fresh Vite ModuleRunner per probe. Each runner has its own + * EvaluatedModules instance, so top-level module state is recreated from + * scratch while still using the same Vite transform pipeline. + * + * The pool is torn down on HMR / file invalidation so the next probe + * starts with fresh transformed code. + */ + +import type { DevEnvironment } from "vite"; +import { createDirectRunner, type DevEnvironmentLike } from "./dev-module-runner.js"; +import type { ModuleRunner } from "vite/module-runner"; +import { setUseCacheProbe } from "vinext/shims/use-cache-probe-globals"; +import type { EncodedArgsForProbe } from "vinext/shims/use-cache-probe-globals"; +import { UseCacheTimeoutError } from "vinext/shims/use-cache-errors"; + +let _probeEnvironment: DevEnvironmentLike | DevEnvironment | null = null; + +/** + * Initialize the probe pool with the Vite dev environment. + * + * Called during configureServer() when the App Router dev server starts, + * and re-called after each HMR teardown cycle. + */ +export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvironment): void { + if (_probeEnvironment) { + // Guard against double-init within the same cycle (e.g., if + // initUseCacheProbePool is called without a preceding teardown). + return; + } + _probeEnvironment = environment; + + // Capture the environment in a local variable so the probe closure is + // immune to HMR teardown setting _probeEnvironment = null mid-flight. + const env = environment; + + setUseCacheProbe(async (msg) => { + // Create a fresh runner per probe so the module graph is completely + // isolated from previous probes. Reusing runners would leave stale + // top-level state in EvaluatedModules. + // createDirectRunner creates a fresh ModuleRunner with its own isolated + // EvaluatedModules instance, which is exactly what we need for probes. + const runner = createDirectRunner(env); + const { id, encodedArguments, request, timeoutMs } = msg; + + // Internal timeout so the probe aborts before the outer render timeout. + const deadline = performance.now() + timeoutMs; + + let probeTimeoutTimer: ReturnType | undefined; + try { + // Import the cache-runtime shim in the isolated runner. + // The shim's registerCachedFunction will create fresh module-scope state. + const cacheRuntime = (await runner.import("vinext/shims/cache-runtime")) as Record< + string, + unknown + >; + const registerCachedFunction = cacheRuntime.registerCachedFunction as + | ( Promise>( + fn: T, + id: string, + variant?: string, + ) => T) + | undefined; + + if (!registerCachedFunction) { + return false; + } + + // We need to locate the original cached function module in the isolated + // runner. The function id is ":". We split it + // to find the module and the export. + // NOTE: This assumes export names don't contain colons. + const lastColon = id.lastIndexOf(":"); + const modulePath = lastColon >= 0 ? id.slice(0, lastColon) : id; + const exportName = lastColon >= 0 ? id.slice(lastColon + 1) : "default"; + + // Import the module containing the original "use cache" function. + const mod = (await runner.import(modulePath)) as Record; + const originalFn = mod[exportName]; + if (typeof originalFn !== "function") { + return false; + } + + // Wrap it with registerCachedFunction so the probe runs through the + // same cache-runtime path (fresh ALS, no shared state). + const variant = ""; // Only shared caches reach the probe block. + const wrapped = registerCachedFunction( + originalFn as (...args: unknown[]) => Promise, + id, + variant, + ); + + // Decode args via the probe runner's RSC decodeReply so + // thenable params/searchParams are reconstructed accurately. + const args = await decodeProbeArgs(runner, encodedArguments); + if (args === null) { + return false; + } + + // Run the function with a reconstructed request store so private caches + // that read cookies()/headers()/draftMode() see the same values. + // Mark the context as _probeDepth === 1 so nested 'use cache' calls + // skip probe scheduling (mirrors Next.js useCacheProbeMode). + // Race against the internal timeout. + const remaining = deadline - performance.now(); + if (remaining <= 0) { + return false; + } + + await Promise.race([ + runWithProbeRequestStore(runner, request, async () => wrapped(...args)), + new Promise((_, reject) => { + probeTimeoutTimer = setTimeout(() => reject(new UseCacheTimeoutError()), remaining); + if (typeof (probeTimeoutTimer as NodeJS.Timeout).unref === "function") { + (probeTimeoutTimer as NodeJS.Timeout).unref(); + } + }), + ]); + + return true; + } catch (err) { + // If the probe timed out, the result is inconclusive — the function + // might genuinely hang even in isolation. + if (err instanceof UseCacheTimeoutError) { + return false; + } + // The function threw — it ran to completion (with an error). If the main + // fill is still stuck, this is evidence of a deadlock on shared state. + return true; + } finally { + if (probeTimeoutTimer !== undefined) clearTimeout(probeTimeoutTimer); + runner.close().catch(() => {}); + } + }); +} + +/** + * Tear down the probe pool. Called on HMR / file invalidation so the next + * probe starts with fresh code. + */ +export function tearDownUseCacheProbePool(): void { + _probeEnvironment = null; + setUseCacheProbe(undefined); +} + +/** + * Decode probe arguments from the wire-format `EncodedArgsForProbe` using + * the probe runner's own RSC `decodeReply` so thenable params/searchParams + * are reconstructed accurately. Mirrors Next.js `use-cache-probe-worker.ts`. + */ +async function decodeProbeArgs( + runner: ModuleRunner, + encoded: EncodedArgsForProbe, +): Promise { + try { + const rsc = (await runner.import("@vitejs/plugin-rsc/react/rsc")) as { + decodeReply: ( + data: string | FormData, + options?: { temporaryReferences?: unknown }, + ) => Promise; + }; + + if (encoded.kind === "string") { + const decoded = await rsc.decodeReply(encoded.data, { temporaryReferences: undefined }); + if (!Array.isArray(decoded)) return [decoded]; + return decoded; + } + + // formdata kind: reconstruct FormData from serialized entries. + const formData = new FormData(); + for (const entry of encoded.entries) { + if (entry.length === 2 && typeof entry[1] === "string") { + formData.append(entry[0], entry[1]); + } else { + const blob = entry[1] as { kind: "blob"; bytes: string; type: string }; + const bytes = Buffer.from(blob.bytes, "base64"); + formData.append(entry[0], new File([bytes], "", { type: blob.type })); + } + } + + const decoded = await rsc.decodeReply(formData, { temporaryReferences: undefined }); + if (!Array.isArray(decoded)) return [decoded]; + return decoded; + } catch { + return null; + } +} + +/** + * Reconstruct a minimal request store in the probe runner so that + * cookies(), headers(), and draftMode() behave correctly. + */ +async function runWithProbeRequestStore( + runner: ModuleRunner, + requestSnapshot: { + headers: [string, string][]; + urlPathname: string; + urlSearch: string; + rootParams: Record; + }, + fn: () => Promise, +): Promise { + // Import the ALS-backed request-context modules through the probe runner + // so they load inside the isolated module graph, not the main runner's. + const { createRequestContext, runWithRequestContext } = (await runner.import( + "vinext/shims/unified-request-context", + )) as typeof import("vinext/shims/unified-request-context"); + + const { headersContextFromRequest } = (await runner.import( + "vinext/shims/headers", + )) as typeof import("vinext/shims/headers"); + + // Build a Request from the snapshot so headersContextFromRequest works. + const url = new URL(requestSnapshot.urlPathname + requestSnapshot.urlSearch, "http://localhost"); + const request = new Request(url, { + headers: new Headers(requestSnapshot.headers), + }); + + const headersContext = headersContextFromRequest(request); + const ctx = createRequestContext({ + headersContext, + executionContext: null, + rootParams: requestSnapshot.rootParams, + _probeDepth: 1, + }); + + return runWithRequestContext(ctx, fn); +} diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 6fb9f87f8..8b9529e68 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -37,6 +37,7 @@ import { type CacheLifeConfig, } from "./cache.js"; import { VINEXT_RSC_MARKER_HEADER } from "../server/headers.js"; +import type { EncodedArgsForProbe } from "./use-cache-probe-globals.js"; import { getOrCreateAls } from "./internal/als-registry.js"; import { isInsideUnifiedScope, @@ -190,6 +191,40 @@ function getUseCacheKeySeed(): string | undefined { return getUseCacheDeploymentIdDefine() || getUseCacheBuildIdDefine(); } +type ProbeModules = { + UseCacheTimeoutError: typeof import("./use-cache-errors.js").UseCacheTimeoutError; + UseCacheDeadlockError: typeof import("./use-cache-errors.js").UseCacheDeadlockError; + getUseCacheProbe: typeof import("./use-cache-probe-globals.js").getUseCacheProbe; +}; + +// Lazy singleton for dev-only probe modules — eliminates microtask +// overhead on every "use cache" call after the first. +let _probeModules: ProbeModules | undefined; +let _probeModulesPromise: Promise | undefined; + +async function loadProbeModules(): Promise { + const [errors, globals] = await Promise.all([ + import("./use-cache-errors.js"), + import("./use-cache-probe-globals.js"), + ]); + return { + UseCacheTimeoutError: errors.UseCacheTimeoutError, + UseCacheDeadlockError: errors.UseCacheDeadlockError, + getUseCacheProbe: globals.getUseCacheProbe, + }; +} + +async function getProbeModules(): Promise { + if (_probeModules) return _probeModules; + + _probeModulesPromise ??= loadProbeModules().catch((error) => { + _probeModulesPromise = undefined; + throw error; + }); + _probeModules = await _probeModulesPromise; + return _probeModules; +} + function buildUseCacheKey(id: string, keySeed: string | undefined, argsKey?: string): string { const scopedId = keySeed ? `build:${encodeURIComponent(keySeed)}:${id}` : id; return argsKey === undefined ? `use-cache:${scopedId}` : `use-cache:${scopedId}:${argsKey}`; @@ -328,6 +363,39 @@ function resolveCacheLife(configs: CacheLifeConfig[]): CacheLifeConfig { return result; } +// --------------------------------------------------------------------------- +// Encode probe arguments for transport to the isolated runner +// --------------------------------------------------------------------------- + +/** + * Convert an `encodeReply` result (string | FormData) into a serializable + * wire-format that the probe runner can decode via `decodeReply`. + * This mirrors Next.js `EncodedArgumentsForProbe`: string replies are passed + * verbatim, FormData entries are sent as base64-encoded blobs so they survive + * JSON serialization. + */ +type EncodedEntry = [string, string] | [string, { kind: "blob"; bytes: string; type: string }]; + +async function encodedArgumentsToProbe(encoded: string | FormData): Promise { + if (typeof encoded === "string") { + return { kind: "string", data: encoded }; + } + + const entries: EncodedEntry[] = []; + for (const [key, value] of encoded.entries()) { + if (typeof value === "string") { + entries.push([key, value]); + } else { + const bytes = new Uint8Array(await value.arrayBuffer()); + entries.push([ + key, + { kind: "blob", bytes: Buffer.from(bytes).toString("base64"), type: value.type }, + ]); + } + } + return { kind: "formdata", entries }; +} + // --------------------------------------------------------------------------- // Private per-request cache for "use cache: private" // Uses AsyncLocalStorage for request isolation so concurrent requests @@ -430,6 +498,8 @@ export function registerCachedFunction Promise 0) { // Temporary references let encodeReply handle non-serializable values @@ -444,10 +514,10 @@ export function registerCachedFunction Promise 0 ? stableStringify(args) : undefined; cacheKey = buildUseCacheKey(id, keySeed, argsKey); @@ -473,7 +543,127 @@ export function registerCachedFunction Promise 0) { + return executeWithContext(fn, args, cacheVariant); + } + + let probeModules = _probeModules; + if (!probeModules) { + probeModules = await getProbeModules(); + } + const { UseCacheTimeoutError, UseCacheDeadlockError, getUseCacheProbe } = probeModules; + + let probeTimer: ReturnType | undefined; + let timeoutTimer: ReturnType | undefined; + let probePromise: Promise | null = null; + const probe = getUseCacheProbe(); + + if (probe) { + const headers = requestCtx.headersContext?.headers; + const navCtx = requestCtx.serverContext; + const requestSnapshot = { + headers: headers ? Array.from(headers.entries()) : [], + urlPathname: navCtx?.pathname ?? "/", + urlSearch: navCtx?.searchParams?.toString() ?? "", + rootParams: Object.fromEntries( + Object.entries(requestCtx.rootParams ?? {}).map(([k, v]) => [ + k, + typeof v === "string" || Array.isArray(v) ? v : undefined, + ]), + ) as Record, + }; + + // Encode probe args via the same RSC encodeReply used for cache-key + // generation so Promise-thenable params/searchParams survive the round + // trip (avoids false-positive deadlocks from empty `{}` JSON.stringify). + let encodedArgsForProbe: EncodedArgsForProbe | null = null; + try { + if (rsc) { + if (rscEncodedArgs !== undefined) { + encodedArgsForProbe = await encodedArgumentsToProbe(rscEncodedArgs); + } else { + const tempRefs = rsc.createClientTemporaryReferenceSet(); + const encoded = await rsc.encodeReply([], { + temporaryReferences: tempRefs, + }); + encodedArgsForProbe = await encodedArgumentsToProbe(encoded); + } + } else { + // RSC not available in test environments — JSON fallback + encodedArgsForProbe = { + kind: "string", + data: JSON.stringify(args.length > 0 ? args : []), + }; + } + } catch { + // Failed to encode probe args — skip probe scheduling rather than + // send empty/wrong args and risk false-positive deadlock. + } + + if (encodedArgsForProbe) { + probePromise = new Promise((_, reject) => { + // TODO: Next.js tracks RSC stream chunk timestamps and only fires + // the probe after 10s of stream *idleness*. vinext uses a flat + // 10s timeout that fires regardless of progress, which can produce + // false-positive UseCacheDeadlockError for slow-but-streaming + // cache functions. Switch to idle-stream monitoring for parity. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/use-cache/use-cache-probe.ts + probeTimer = setTimeout(() => { + const probeInternalTimeoutMs = fillDeadlineAt - performance.now() - 1_000; + if (probeInternalTimeoutMs <= 0) return; + + probe({ + id, + encodedArguments: encodedArgsForProbe!, + request: requestSnapshot, + timeoutMs: probeInternalTimeoutMs, + }).then( + (completed) => { + if (completed) { + reject(new UseCacheDeadlockError()); + } else if (typeof console !== "undefined") { + console.warn( + `[vinext] "use cache" fill for ${id} has been idle for 10s. ` + + `Probe was also inconclusive — will hard-timeout at 54s.`, + ); + } + }, + () => {}, + ); + }, 10_000); + }); + // Swallow rejection when execution wins the race. + probePromise.catch(() => {}); + } + } + + const timeoutPromise = new Promise((_, reject) => { + timeoutTimer = setTimeout(() => reject(new UseCacheTimeoutError()), USE_CACHE_TIMEOUT_MS); + if (typeof (timeoutTimer as NodeJS.Timeout).unref === "function") { + (timeoutTimer as NodeJS.Timeout).unref(); + } + }); + // Swallow rejection when execution wins the race. + timeoutPromise.catch(() => {}); + + const executionPromise = executeWithContext(fn, args, cacheVariant); + + return Promise.race( + probePromise + ? [executionPromise, probePromise, timeoutPromise] + : [executionPromise, timeoutPromise], + ).finally(() => { + if (probeTimer !== undefined) clearTimeout(probeTimer); + if (timeoutTimer !== undefined) clearTimeout(timeoutTimer); + }); } // Shared cache ("use cache" / "use cache: remote") diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index 6032a3b77..6d1659604 100644 --- a/packages/vinext/src/shims/unified-request-context.ts +++ b/packages/vinext/src/shims/unified-request-context.ts @@ -45,6 +45,15 @@ export type UnifiedRequestContext = { /** Per-request cache for cacheForRequest(). Keyed by factory function reference. */ // oxlint-disable-next-line @typescript-eslint/no-explicit-any requestCache: WeakMap<(...args: any[]) => any, unknown>; + + // ── use-cache-probe-globals.ts ───────────────────────────────────── + /** + * Probe recursion depth for "use cache" deadlock detection. + * 0 = regular request; >0 = inside a probe re-execution. + * Prevents concurrent requests from interfering with each other's + * probe scheduling (unlike a process-global counter). + */ + _probeDepth: number; } & VinextHeadersShimState & I18nState & NavigationState & @@ -109,6 +118,7 @@ export function createRequestContext(opts?: Partial): Uni ssrContext: null, ssrHeadChildren: [], rootParams: null, + _probeDepth: 0, ...opts, }; } 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 000000000..40312bb4a --- /dev/null +++ b/packages/vinext/src/shims/use-cache-errors.ts @@ -0,0 +1,57 @@ +/** + * use-cache-errors.ts + * + * Error classes for "use cache" fill failures. + * + * 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 + */ + +const USE_CACHE_TIMEOUT_ERROR_CODE = "USE_CACHE_TIMEOUT" as const; +const USE_CACHE_DEADLOCK_ERROR_CODE = "USE_CACHE_DEADLOCK" as const; + +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".', + ); + this.name = "UseCacheTimeoutError"; + } +} + +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.', + ); + this.name = "UseCacheDeadlockError"; + } +} + +export function isUseCacheTimeoutError(err: unknown): err is UseCacheTimeoutError { + if ( + typeof err !== "object" || + err === null || + !("digest" in err) || + typeof err.digest !== "string" + ) { + return false; + } + return err.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.digest !== "string" + ) { + return false; + } + return err.digest === USE_CACHE_DEADLOCK_ERROR_CODE; +} diff --git a/packages/vinext/src/shims/use-cache-probe-globals.ts b/packages/vinext/src/shims/use-cache-probe-globals.ts new file mode 100644 index 000000000..6bcfb1dfc --- /dev/null +++ b/packages/vinext/src/shims/use-cache-probe-globals.ts @@ -0,0 +1,42 @@ +/** + * use-cache-probe-globals.ts + * + * Dev-only cross-module handoff for the "use cache" deadlock probe. + * Uses a Symbol.for on globalThis so the dev server can install the probe + * without importing dev-only code into the production cache runtime. + * + * Ported from Next.js: packages/next/src/server/use-cache/use-cache-probe-globals.ts + * https://github.com/vercel/next.js/blob/canary/packages/next/src/server/use-cache/use-cache-probe-globals.ts + */ + +const SYMBOL = Symbol.for("vinext.dev.useCacheProbe"); + +export type UseCacheProbeRequestSnapshot = { + headers: [string, string][]; + urlPathname: string; + urlSearch: string; + rootParams: Record; +}; + +/** Wire-format for encoded probe arguments */ +export type EncodedArgsForProbe = + | { kind: "string"; data: string } + | { + kind: "formdata"; + entries: Array<[string, string] | [string, { kind: "blob"; bytes: string; type: string }]>; + }; + +export type UseCacheProbe = (msg: { + id: string; + encodedArguments: EncodedArgsForProbe; + request: UseCacheProbeRequestSnapshot; + timeoutMs: number; +}) => Promise; + +export function setUseCacheProbe(fn: UseCacheProbe | undefined): void { + (globalThis as Record)[SYMBOL] = fn; +} + +export function getUseCacheProbe(): UseCacheProbe | undefined { + return (globalThis as Record)[SYMBOL] as UseCacheProbe | undefined; +} diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 2c2ede7c2..319bf9e0e 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -4509,6 +4509,127 @@ describe('"use cache" runtime', () => { }); }); +describe("use-cache errors", () => { + it("UseCacheTimeoutError has correct digest and message", 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(err.message).toContain("timed out"); + expect(isUseCacheTimeoutError(err)).toBe(true); + expect(isUseCacheTimeoutError(new Error("other"))).toBe(false); + }); + + it("UseCacheDeadlockError has correct digest and message", 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(err.message).toContain("shared state"); + expect(isUseCacheDeadlockError(err)).toBe(true); + expect(isUseCacheDeadlockError(new Error("other"))).toBe(false); + }); + + it("UseCacheTimeoutError has correct name", async () => { + const { UseCacheTimeoutError } = + await import("../packages/vinext/src/shims/use-cache-errors.js"); + const err = new UseCacheTimeoutError(); + expect(err.name).toBe("UseCacheTimeoutError"); + }); + + it("UseCacheDeadlockError has correct name", async () => { + const { UseCacheDeadlockError } = + await import("../packages/vinext/src/shims/use-cache-errors.js"); + const err = new UseCacheDeadlockError(); + expect(err.name).toBe("UseCacheDeadlockError"); + }); +}); + +describe("use-cache deadlock probe behavior", () => { + it("does not schedule probe when _probeDepth > 0", async () => { + const { setUseCacheProbe } = + await import("../packages/vinext/src/shims/use-cache-probe-globals.js"); + + let probeCalled = false; + setUseCacheProbe(async () => { + probeCalled = true; + return true; + }); + + // Ensure dev mode path is taken by resetting modules and setting NODE_ENV. + vi.stubEnv("NODE_ENV", "development"); + vi.resetModules(); + + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { createRequestContext, runWithRequestContext } = + await import("../packages/vinext/src/shims/unified-request-context.js"); + + const fn = async () => "result"; + const cached = registerCachedFunction(fn, "test:no-recurse", ""); + + // A probe re-execution sets _probeDepth === 1 on its request context. + const probeCtx = createRequestContext({ _probeDepth: 1 }); + try { + const result = await runWithRequestContext(probeCtx, () => cached()); + expect(result).toBe("result"); + expect(probeCalled).toBe(false); + } finally { + vi.unstubAllEnvs(); + setUseCacheProbe(undefined); + } + }); + + it("probe completing surfaces UseCacheDeadlockError via Promise.race", async () => { + const { setUseCacheProbe } = + await import("../packages/vinext/src/shims/use-cache-probe-globals.js"); + + // Install a probe that reports the function completed in isolation. + setUseCacheProbe(async () => true); + + vi.stubEnv("NODE_ENV", "development"); + vi.useFakeTimers(); + vi.resetModules(); + + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { isUseCacheDeadlockError } = + await import("../packages/vinext/src/shims/use-cache-errors.js"); + + let resolveHung!: (v: string) => void; + const hungFn = () => + new Promise((resolve) => { + resolveHung = resolve; + }); + const cached = registerCachedFunction(hungFn, "test:deadlock-race", ""); + + try { + const promise = cached(); + // Attach a handler immediately so Node never sees this as unhandled. + let caughtError: Error | undefined; + promise.catch((err) => { + caughtError = err as Error; + }); + + // Advance to the 10 s probe timer. + await vi.advanceTimersByTimeAsync(10_000); + // Flush microtasks until the deadlock rejection propagates. + // Loop avoids fragility if the promise chain length changes. + for (let i = 0; i < 10 && !caughtError; i++) { + await Promise.resolve(); + } + + expect(isUseCacheDeadlockError(caughtError)).toBe(true); + expect(caughtError?.message).toContain("shared state"); + } finally { + resolveHung?.("cleanup"); + vi.useRealTimers(); + vi.unstubAllEnvs(); + setUseCacheProbe(undefined); + } + }); +}); + describe("replyToCacheKey deterministic hashing", () => { it("returns string replies as-is", async () => { const { replyToCacheKey } = await import("../packages/vinext/src/shims/cache-runtime.js");