From 6103e8c74e9c0a25034290046f06e19e21430852 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sun, 10 May 2026 18:32:49 -0700 Subject: [PATCH 01/19] feat(dev): detect 'use cache' module-scope deadlocks early in dev (#1126) Implements a dev-only probe that detects when a 'use cache' fill is stuck on module-scope shared state (e.g. a top-level Map used to dedupe fetches). When the cache fill stream is idle for 10s in dev mode, a probe re-runs the cache function in a fresh Vite ModuleRunner with an isolated module graph. If the probe completes while the main fill is still hung, the fill is aborted with a UseCacheDeadlockError pointing at module-scope state as the likely cause. If the probe also hangs or errors, it falls back to the existing UseCacheTimeoutError. Key files: - shims/use-cache-errors.ts: UseCacheTimeoutError, UseCacheDeadlockError - shims/use-cache-probe-globals.ts: cross-module probe handoff via globalThis - server/use-cache-probe-scheduler.ts: idle detection + probe scheduling - server/use-cache-probe-pool.ts: fresh ModuleRunner pool for isolated execution - server/dev-module-runner.ts: createProbeRunner() for fresh module graphs - shims/cache-runtime.ts: dev-mode fill with probe + timeout racing - index.ts: init probe pool in App Router dev server, teardown on HMR Also adds fixture pages for deadlock and recovery scenarios, plus unit tests. Ports behavior from Next.js #93500 and #93538. Closes #1126. --- packages/vinext/package.json | 1 + packages/vinext/src/index.ts | 12 + .../vinext/src/server/dev-module-runner.ts | 50 +++- .../vinext/src/server/use-cache-probe-pool.ts | 215 ++++++++++++++++++ .../src/server/use-cache-probe-scheduler.ts | 173 ++++++++++++++ packages/vinext/src/shims/cache-runtime.ts | 66 +++++- packages/vinext/src/shims/use-cache-errors.ts | 55 +++++ .../src/shims/use-cache-probe-globals.ts | 38 ++++ pnpm-lock.yaml | 3 + .../app-basic/app/use-cache-deadlock/page.tsx | 36 +++ .../app-basic/app/use-cache-recovery/page.tsx | 22 ++ tests/shims.test.ts | 92 ++++++++ 12 files changed, 761 insertions(+), 2 deletions(-) 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/shims/use-cache-errors.ts create mode 100644 packages/vinext/src/shims/use-cache-probe-globals.ts create mode 100644 tests/fixtures/app-basic/app/use-cache-deadlock/page.tsx create mode 100644 tests/fixtures/app-basic/app/use-cache-recovery/page.tsx diff --git a/packages/vinext/package.json b/packages/vinext/package.json index 64788dd6b..5d0f2c4d9 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -68,6 +68,7 @@ "@vercel/og": "catalog:", "image-size": "catalog:", "magic-string": "catalog:", + "tinypool": "2.1.0", "vite-plugin-commonjs": "catalog:", "vite-tsconfig-paths": "catalog:" }, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 726f47f5b..a5b073990 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 { initUseCacheProbePool, tearDownUseCacheProbePool } 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"; @@ -2041,6 +2042,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { invalidateAppRouteCache(); invalidateRscEntryModule(); invalidateRootParamsModule(); + // Tear down the use-cache probe pool so the next probe starts with + // fresh code after HMR invalidation. + tearDownUseCacheProbePool(); } // Node throws on unhandled 'error' events on sockets. When a browser @@ -2134,6 +2138,14 @@ 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) { + initUseCacheProbePool(rscEnv); + } + 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..8fdf1d0a5 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, @@ -129,3 +129,51 @@ export function createDirectRunner(environment: DevEnvironmentLike | DevEnvironm new ESModulesEvaluator(), ); } + +/** + * Create a fresh ModuleRunner with its own isolated module graph. + * + * Used by the "use cache" deadlock probe to re-import a cache function + * with zero shared module-scope state from the main request pipeline. + * Each probe gets a brand-new `EvaluatedModules` instance so top-level + * `Map`s, `Promise`s, etc. are recreated from scratch. + * + * The transport delegates to the same `environment.fetchModule()`, so + * Vite transforms and HMR still work, but the *evaluated* module cache + * is completely separate. + */ +export function createProbeRunner(environment: DevEnvironmentLike | DevEnvironment): ModuleRunner { + return new ModuleRunner( + { + transport: { + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + invoke: async (payload: any) => { + const { name, data: args } = payload.data; + if (name === "fetchModule") { + const [id, importer, options] = args as [ + string, + string | undefined, + { cached?: boolean; startOffset?: number } | undefined, + ]; + return { + result: await environment.fetchModule(id, importer, options), + }; + } + if (name === "getBuiltins") { + return { result: [] }; + } + return { + error: { + name: "Error", + message: `[vinext] Unexpected ModuleRunner invoke: ${name}`, + }, + }; + }, + }, + createImportMeta: createNodeImportMeta, + sourcemapInterceptor: false, + hmr: false, + }, + new ESModulesEvaluator(), + ); +} 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..beac9417f --- /dev/null +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -0,0 +1,215 @@ +/** + * use-cache-probe-pool.ts + * + * Manages a pool of 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 { createProbeRunner, type DevEnvironmentLike } from "./dev-module-runner.js"; +import { ModuleRunner } from "vite/module-runner"; +import { setUseCacheProbe } from "vinext/shims/use-cache-probe-globals"; +import { UseCacheTimeoutError } from "vinext/shims/use-cache-errors"; + +let _activeProbeRunners: ModuleRunner[] | null = null; +let _environment: DevEnvironmentLike | DevEnvironment | null = null; +const MAX_RUNNERS = 4; + +function getProbeRunner(): ModuleRunner { + if (!_activeProbeRunners || _activeProbeRunners.length === 0) { + throw new Error("[vinext] use cache probe pool not initialized"); + } + // Round-robin across runners for basic load distribution. + const runner = _activeProbeRunners.shift()!; + _activeProbeRunners.push(runner); + return runner; +} + +/** + * Initialize the probe pool with a set of fresh ModuleRunners bound to the + * given Vite dev environment. + * + * Called once during configureServer() when the App Router dev server starts. + */ +export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvironment): void { + if (_activeProbeRunners) { + // Already initialized — no-op. The environment is the same for the + // lifetime of the dev server. + return; + } + _environment = environment; + _activeProbeRunners = []; + for (let i = 0; i < MAX_RUNNERS; i++) { + _activeProbeRunners.push(createProbeRunner(environment)); + } + + setUseCacheProbe(async (msg) => { + const runner = getProbeRunner(); + const { id, kind, encodedArguments, request, timeoutMs } = msg; + + // Internal timeout so the probe aborts before the outer render timeout. + const deadline = Date.now() + timeoutMs; + + 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. + 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 = kind === "private" ? "private" : ""; + const wrapped = registerCachedFunction( + originalFn as (...args: unknown[]) => Promise, + id, + variant, + ); + + // Decode the arguments (simple JSON fallback; RSC encodeReply is + // not available in the probe because we lack the client environment). + // For deadlock detection, the exact argument values matter less than + // the fact that the function body executes with a fresh module scope. + let args: unknown[] = []; + if (typeof encodedArguments === "string") { + try { + args = JSON.parse(encodedArguments); + if (!Array.isArray(args)) args = [args]; + } catch { + args = []; + } + } + + // Run the function with a reconstructed request store so private caches + // that read cookies()/headers()/draftMode() see the same values. + const result = await runWithProbeRequestStore(request, async () => wrapped(...args)); + + // Wait for the result, but enforce the internal timeout. + const remaining = deadline - Date.now(); + if (remaining <= 0) { + return false; + } + + // If we got here, the probe completed. + await Promise.race([ + Promise.resolve(result), + new Promise((_, reject) => { + const t = setTimeout(() => reject(new UseCacheTimeoutError()), remaining); + // Ensure timer is cleaned up on success via unref if available. + if (typeof (t as NodeJS.Timeout).unref === "function") { + (t as NodeJS.Timeout).unref(); + } + }), + ]); + + return true; + } catch { + // Probe failure is inconclusive — the function might genuinely hang + // even in isolation, or the module import failed. Fall back to the + // regular timeout. + return false; + } + }); +} + +/** + * Tear down the probe pool. Called on HMR / file invalidation so the next + * probe starts with fresh code. + */ +export function tearDownUseCacheProbePool(): void { + if (_activeProbeRunners) { + for (const runner of _activeProbeRunners) { + runner.close().catch(() => {}); + } + _activeProbeRunners = null; + } + _environment = null; + setUseCacheProbe(undefined); +} + +/** + * Reconstruct a minimal request store in the probe runner so that + * cookies(), headers(), and draftMode() behave correctly. + */ +async function runWithProbeRequestStore( + requestSnapshot: { + headers: [string, string][]; + cookieHeader: string | undefined; + urlPathname: string; + urlSearch: string; + rootParams: Record; + isDraftMode: boolean; + isHmrRefresh: boolean; + }, + fn: () => Promise, +): Promise { + // Import the ALS-backed request-context modules in the isolated runner. + const unifiedCtx = (async () => { + // These imports run inside the probe runner's module graph. + // We dynamic-import them because the probe runner doesn't share + // module state with the main runner. + const { createRequestContext, runWithRequestContext } = + (await import("vinext/shims/unified-request-context")) as typeof import("vinext/shims/unified-request-context"); + + const { headersContextFromRequest } = + (await 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 as Record, + }); + + return runWithRequestContext(ctx, fn); + })(); + + return unifiedCtx; +} 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 000000000..8297c3821 --- /dev/null +++ b/packages/vinext/src/server/use-cache-probe-scheduler.ts @@ -0,0 +1,173 @@ +/** + * use-cache-probe-scheduler.ts + * + * Dev-only idle-deadline probe scheduler for "use cache" fills. + * + * Wraps the cache fill stream in a TransformStream and tracks chunk activity. + * If the stream is idle for PROBE_THRESHOLD_MS (10s), schedules a probe that + * re-runs the cache function in a fresh module scope. If the probe completes + * while the main fill is still hung, the caller aborts the fill with a + * UseCacheDeadlockError. + * + * 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 + */ + +import { + getUseCacheProbe, + type UseCacheProbeRequestSnapshot, +} from "vinext/shims/use-cache-probe-globals"; + +const PROBE_THRESHOLD_MS = 10_000; +const MIN_PROBE_BUDGET_MS = 3_000; + +type CacheContextWithProbeFields = { + readonly functionId: string; + readonly handlerKind: string; +}; + +type SetupOptions = { + cacheContext: CacheContextWithProbeFields; + encodedArguments: string | FormData; + /** + * Absolute monotonic deadline (in performance.now() units) at which the + * outer cache fill will be aborted by the dev render-timeout timer. + */ + fillDeadlineAt: number; + /** + * Called once if the probe ran the cache function to completion in isolation + * while the main fill was still pending. + */ + onProbeCompleted: () => void; + /** + * AbortSignal that fires when the probe should stop watching (fill settled, + * timeout fired, upstream cancel, etc.). + */ + abortSignal: AbortSignal; + /** + * The outer request store snapshot so the probe can reconstruct cookies(), + * headers(), draftMode(), etc. in the isolated run. + */ + requestSnapshot: UseCacheProbeRequestSnapshot; + /** + * Cache stream to track. Each chunk resets the idle timer. + */ + stream: ReadableStream; +}; + +/** + * Schedule an idle-deadline probe over a cache fill stream (dev-only). + * + * Returns the input stream unchanged when scheduling should be skipped. + */ +export function setupProbeScheduler(opts: SetupOptions): ReadableStream { + const { + cacheContext, + encodedArguments, + fillDeadlineAt, + stream, + abortSignal, + onProbeCompleted, + requestSnapshot, + } = opts; + + // Skip if the remaining budget is too short for a meaningful probe. + if (fillDeadlineAt - performance.now() < PROBE_THRESHOLD_MS + MIN_PROBE_BUDGET_MS) { + return stream; + } + + const probe = getUseCacheProbe(); + if (!probe) { + return stream; + } + + let lastChunkAt = performance.now(); + let idleTimer: ReturnType | undefined; + + const startProbe = () => { + if (abortSignal.aborted) { + return; + } + + const probeStartedAtChunk = lastChunkAt; + // Reserve a 1s buffer so the probe's internal timeout fires before the + // outer render timeout. + const probeInternalTimeoutMs = fillDeadlineAt - performance.now() - 1_000; + + if (probeInternalTimeoutMs <= 0) { + return; + } + + probe({ + id: cacheContext.functionId, + kind: cacheContext.handlerKind, + encodedArguments, + request: requestSnapshot, + timeoutMs: probeInternalTimeoutMs, + }).then( + (completed) => { + // Mid-probe recovery: chunks arrived while the probe was running. + if (lastChunkAt > probeStartedAtChunk) { + 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 - lastChunkAt; + const wait = Math.max(0, PROBE_THRESHOLD_MS - idleFor); + + // Skip scheduling if the outer fill timeout will fire before the probe + // could even start with a minimum useful budget. + if (fillDeadlineAt - now < wait + MIN_PROBE_BUDGET_MS) { + return; + } + + idleTimer = setTimeout(() => { + idleTimer = undefined; + if (abortSignal.aborted) { + return; + } + const idleNow = performance.now() - lastChunkAt; + if (idleNow < PROBE_THRESHOLD_MS) { + // A chunk 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 stream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + lastChunkAt = performance.now(); + scheduleAfterIdle(); + controller.enqueue(chunk); + }, + }), + ); +} diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 0bbbd337e..421c4bafc 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -42,6 +42,8 @@ import { getRequestContext, runWithUnifiedStateMutation, } from "./unified-request-context.js"; +import { UseCacheTimeoutError, UseCacheDeadlockError } from "./use-cache-errors.js"; +import { getUseCacheProbe, type UseCacheProbeRequestSnapshot } from "./use-cache-probe-globals.js"; // --------------------------------------------------------------------------- // Cache execution context — AsyncLocalStorage for cacheLife/cacheTag @@ -399,7 +401,69 @@ export function registerCachedFunction Promise | null = null; + const probe = getUseCacheProbe(); + + if (probe) { + // Capture the current request store snapshot for the probe. + const requestCtx = getRequestContext(); + const headers = requestCtx.headersContext?.headers; + const navCtx = requestCtx.serverContext; + const requestSnapshot: UseCacheProbeRequestSnapshot = { + headers: headers ? Array.from(headers.entries()) : [], + cookieHeader: headers?.get("cookie") ?? undefined, + urlPathname: navCtx?.pathname ?? "/", + urlSearch: navCtx?.searchParams?.toString() ?? "", + rootParams: requestCtx.rootParams ?? {}, + isDraftMode: false, + isHmrRefresh: false, + }; + + probePromise = new Promise((_, reject) => { + setTimeout(() => { + const probeInternalTimeoutMs = fillDeadlineAt - performance.now() - 1_000; + if (probeInternalTimeoutMs <= 0) return; + + probe({ + id, + kind: cacheVariant, + encodedArguments: JSON.stringify(args), + request: requestSnapshot, + timeoutMs: probeInternalTimeoutMs, + }).then( + (completed) => { + if (completed) reject(deadlockError); + }, + () => {}, + ); + }, 10_000); + }); + // Swallow rejection when execution wins the race. + probePromise.catch(() => {}); + } + + const timeoutPromise = new Promise((_, reject) => { + const t = setTimeout(() => reject(timeoutError), USE_CACHE_TIMEOUT_MS); + if (typeof (t as NodeJS.Timeout).unref === "function") { + (t as NodeJS.Timeout).unref(); + } + }); + // Swallow rejection when execution wins the race. + timeoutPromise.catch(() => {}); + + const executionPromise = executeWithContext(fn, args, cacheVariant); + + const promises: Promise[] = [executionPromise]; + if (probePromise) promises.push(probePromise); + promises.push(timeoutPromise); + + return Promise.race(promises); } // Shared cache ("use cache" / "use cache: remote") 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..7f5b79c06 --- /dev/null +++ b/packages/vinext/src/shims/use-cache-errors.ts @@ -0,0 +1,55 @@ +/** + * 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".', + ); + } +} + +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.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..adde9a412 --- /dev/null +++ b/packages/vinext/src/shims/use-cache-probe-globals.ts @@ -0,0 +1,38 @@ +/** + * 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][]; + cookieHeader: string | undefined; + urlPathname: string; + urlSearch: string; + rootParams: Record; + isDraftMode: boolean; + isHmrRefresh: boolean; +}; + +export type UseCacheProbe = (msg: { + id: string; + kind: string; + encodedArguments: string | FormData; + 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/pnpm-lock.yaml b/pnpm-lock.yaml index 2c460939c..d49292402 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -791,6 +791,9 @@ importers: magic-string: specifier: 'catalog:' version: 0.30.21 + tinypool: + specifier: 2.1.0 + version: 2.1.0 vite: specifier: npm:@voidzero-dev/vite-plus-core@0.1.17 version: '@voidzero-dev/vite-plus-core@0.1.17(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3)(yaml@2.8.3)' diff --git a/tests/fixtures/app-basic/app/use-cache-deadlock/page.tsx b/tests/fixtures/app-basic/app/use-cache-deadlock/page.tsx new file mode 100644 index 000000000..0dc47f408 --- /dev/null +++ b/tests/fixtures/app-basic/app/use-cache-deadlock/page.tsx @@ -0,0 +1,36 @@ +"use cache"; + +// Simulates a module-scope deadlock pattern: a top-level Map used to dedupe +// fetches, where the cache function awaits a promise created by the outer scope. +// In dev mode, the probe should detect this and surface a UseCacheDeadlockError. + +const dedupeMap = new Map>(); + +function getDedupedData(key: string): Promise { + const existing = dedupeMap.get(key); + if (existing) return existing; + + // Create a promise that only resolves when explicitly signaled. + // In the main request, something else (the outer render) is supposed to + // resolve this, but since the render is blocked on the cache fill, it never + // does — causing a deadlock. + const promise = new Promise(() => { + // Intentionally never resolves — simulates hanging on outer-scope state. + }); + + dedupeMap.set(key, promise); + return promise; +} + +export default async function UseCacheDeadlockPage() { + // This will hang forever because getDedupedData creates a never-resolving + // promise and stores it in the module-scope Map. + await getDedupedData("deadlock-key"); + + return ( +
+

Use Cache Deadlock Test

+

This page should not render

+
+ ); +} diff --git a/tests/fixtures/app-basic/app/use-cache-recovery/page.tsx b/tests/fixtures/app-basic/app/use-cache-recovery/page.tsx new file mode 100644 index 000000000..2c2a0be29 --- /dev/null +++ b/tests/fixtures/app-basic/app/use-cache-recovery/page.tsx @@ -0,0 +1,22 @@ +"use cache"; + +// Simulates a slow "use cache" function that takes time but eventually +// completes. The probe should NOT fire a deadlock error for this — the +// function is genuinely slow, not deadlocked on module-scope state. + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export default async function UseCacheRecoveryPage() { + // Wait 12 seconds — longer than the 10s probe threshold, but the function + // completes on its own (no module-scope deadlock). + await delay(12_000); + + return ( +
+

Use Cache Recovery Test

+

Slow but working

+
+ ); +} diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 0d660cbf9..ca0bfe0f9 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -2905,6 +2905,98 @@ 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); + }); +}); + +describe("use-cache probe scheduler", () => { + it("setupProbeScheduler returns stream unchanged when no probe is installed", async () => { + const { setupProbeScheduler } = + await import("../packages/vinext/src/server/use-cache-probe-scheduler.js"); + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.close(); + }, + }); + + const abortController = new AbortController(); + const result = setupProbeScheduler({ + cacheContext: { functionId: "test:fn", handlerKind: "" }, + encodedArguments: "[]", + fillDeadlineAt: performance.now() + 60_000, + stream, + abortSignal: abortController.signal, + onProbeCompleted: () => {}, + requestSnapshot: { + headers: [], + cookieHeader: undefined, + urlPathname: "/", + urlSearch: "", + rootParams: {}, + isDraftMode: false, + isHmrRefresh: false, + }, + }); + + // When no probe is installed globally, the stream should pass through unchanged. + expect(result).toBe(stream); + }); + + it("setupProbeScheduler returns stream unchanged when budget is too short", async () => { + const { setupProbeScheduler } = + await import("../packages/vinext/src/server/use-cache-probe-scheduler.js"); + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.close(); + }, + }); + + const abortController = new AbortController(); + const result = setupProbeScheduler({ + cacheContext: { functionId: "test:fn", handlerKind: "" }, + encodedArguments: "[]", + // Budget is less than PROBE_THRESHOLD_MS + MIN_PROBE_BUDGET_MS + fillDeadlineAt: performance.now() + 5_000, + stream, + abortSignal: abortController.signal, + onProbeCompleted: () => {}, + requestSnapshot: { + headers: [], + cookieHeader: undefined, + urlPathname: "/", + urlSearch: "", + rootParams: {}, + isDraftMode: false, + isHmrRefresh: false, + }, + }); + + expect(result).toBe(stream); + }); +}); + describe("replyToCacheKey deterministic hashing", () => { it("returns string replies as-is", async () => { const { replyToCacheKey } = await import("../packages/vinext/src/shims/cache-runtime.js"); From f0ba0b9bf2dcedc6915bdd6dff5272e7ba503b13 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sun, 10 May 2026 18:38:43 -0700 Subject: [PATCH 02/19] ci: fix benchmarks/vinext tsconfig missing vinext path and remove redundant type union --- benchmarks/vinext/tsconfig.json | 3 ++- packages/vinext/src/server/request-pipeline.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/benchmarks/vinext/tsconfig.json b/benchmarks/vinext/tsconfig.json index d899beda7..39af51225 100644 --- a/benchmarks/vinext/tsconfig.json +++ b/benchmarks/vinext/tsconfig.json @@ -12,7 +12,8 @@ "resolveJsonModule": true, "isolatedModules": true, "paths": { - "@/*": ["./*"] + "@/*": ["./*"], + "vinext": ["../../packages/vinext/src/index.ts"] } }, "include": ["**/*.ts", "**/*.tsx"], diff --git a/packages/vinext/src/server/request-pipeline.ts b/packages/vinext/src/server/request-pipeline.ts index 28ba25ec0..d71e45c71 100644 --- a/packages/vinext/src/server/request-pipeline.ts +++ b/packages/vinext/src/server/request-pipeline.ts @@ -601,7 +601,7 @@ export function filterInternalHeaders(headers: Headers): Headers { return filtered; } -function getRequestCf(request: Request): unknown | undefined { +function getRequestCf(request: Request): unknown { const cf = Reflect.get(request, "cf"); return cf === undefined ? undefined : cf; } From 2c7dde8b287741c90affb78d6a88a57b293d94ba Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sun, 10 May 2026 18:44:25 -0700 Subject: [PATCH 03/19] chore: remove unused tinypool dependency causing knip failure --- packages/vinext/package.json | 1 - pnpm-lock.yaml | 3 --- 2 files changed, 4 deletions(-) diff --git a/packages/vinext/package.json b/packages/vinext/package.json index 5d0f2c4d9..64788dd6b 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -68,7 +68,6 @@ "@vercel/og": "catalog:", "image-size": "catalog:", "magic-string": "catalog:", - "tinypool": "2.1.0", "vite-plugin-commonjs": "catalog:", "vite-tsconfig-paths": "catalog:" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d49292402..2c460939c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -791,9 +791,6 @@ importers: magic-string: specifier: 'catalog:' version: 0.30.21 - tinypool: - specifier: 2.1.0 - version: 2.1.0 vite: specifier: npm:@voidzero-dev/vite-plus-core@0.1.17 version: '@voidzero-dev/vite-plus-core@0.1.17(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3)(yaml@2.8.3)' From 4b2c50690096d4c8002c92769500182ef7a1b5ce Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sun, 10 May 2026 18:55:28 -0700 Subject: [PATCH 04/19] fix(tests): remove unused use-cache deadlock fixtures causing static export timeout The use-cache-deadlock and use-cache-recovery fixture pages in app-basic were causing the App Router Static export tests to hang: - use-cache-deadlock creates a never-resolving Promise (intentional deadlock) - use-cache-recovery delays for 12 seconds These fixtures are not referenced by any tests (the probe scheduler is tested in isolation via direct module imports). Removing them fixes the integration test timeouts in CI. --- .../app-basic/app/use-cache-deadlock/page.tsx | 36 ------------------- .../app-basic/app/use-cache-recovery/page.tsx | 22 ------------ 2 files changed, 58 deletions(-) delete mode 100644 tests/fixtures/app-basic/app/use-cache-deadlock/page.tsx delete mode 100644 tests/fixtures/app-basic/app/use-cache-recovery/page.tsx diff --git a/tests/fixtures/app-basic/app/use-cache-deadlock/page.tsx b/tests/fixtures/app-basic/app/use-cache-deadlock/page.tsx deleted file mode 100644 index 0dc47f408..000000000 --- a/tests/fixtures/app-basic/app/use-cache-deadlock/page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use cache"; - -// Simulates a module-scope deadlock pattern: a top-level Map used to dedupe -// fetches, where the cache function awaits a promise created by the outer scope. -// In dev mode, the probe should detect this and surface a UseCacheDeadlockError. - -const dedupeMap = new Map>(); - -function getDedupedData(key: string): Promise { - const existing = dedupeMap.get(key); - if (existing) return existing; - - // Create a promise that only resolves when explicitly signaled. - // In the main request, something else (the outer render) is supposed to - // resolve this, but since the render is blocked on the cache fill, it never - // does — causing a deadlock. - const promise = new Promise(() => { - // Intentionally never resolves — simulates hanging on outer-scope state. - }); - - dedupeMap.set(key, promise); - return promise; -} - -export default async function UseCacheDeadlockPage() { - // This will hang forever because getDedupedData creates a never-resolving - // promise and stores it in the module-scope Map. - await getDedupedData("deadlock-key"); - - return ( -
-

Use Cache Deadlock Test

-

This page should not render

-
- ); -} diff --git a/tests/fixtures/app-basic/app/use-cache-recovery/page.tsx b/tests/fixtures/app-basic/app/use-cache-recovery/page.tsx deleted file mode 100644 index 2c2a0be29..000000000 --- a/tests/fixtures/app-basic/app/use-cache-recovery/page.tsx +++ /dev/null @@ -1,22 +0,0 @@ -"use cache"; - -// Simulates a slow "use cache" function that takes time but eventually -// completes. The probe should NOT fire a deadlock error for this — the -// function is genuinely slow, not deadlocked on module-scope state. - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export default async function UseCacheRecoveryPage() { - // Wait 12 seconds — longer than the 10s probe threshold, but the function - // completes on its own (no module-scope deadlock). - await delay(12_000); - - return ( -
-

Use Cache Recovery Test

-

Slow but working

-
- ); -} From 5662e1a56671c9500a8a14a62dc9bd3a3be654b7 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 11 May 2026 01:43:16 -0700 Subject: [PATCH 05/19] fix(dev): address use-cache probe review issues - Fix runWithProbeRequestStore to use runner.import() instead of native import() so ALS/request context is set on the isolated probe runner's modules, not the main runner's. - Fix timer leak in cache-runtime.ts: both probe and timeout timers are now cleared in .finally() when executionPromise wins the race. - Remove dead code use-cache-probe-scheduler.ts and its orphaned tests. - Remove _environment dead store from probe pool. - Fix redundant await + Promise.race pattern where the timeout could never fire (result was already resolved). - Fix stale module state: create a fresh runner per probe and close() it afterward instead of round-robin reusing from a fixed pool. - Revert unrelated benchmarks/vinext/tsconfig.json vinext path mapping. - Revert unrelated request-pipeline.ts getRequestCf type change. --- benchmarks/vinext/tsconfig.json | 3 +- .../vinext/src/server/request-pipeline.ts | 2 +- .../vinext/src/server/use-cache-probe-pool.ts | 105 ++++------- .../src/server/use-cache-probe-scheduler.ts | 173 ------------------ packages/vinext/src/shims/cache-runtime.ts | 15 +- tests/shims.test.ts | 70 ------- 6 files changed, 51 insertions(+), 317 deletions(-) delete mode 100644 packages/vinext/src/server/use-cache-probe-scheduler.ts diff --git a/benchmarks/vinext/tsconfig.json b/benchmarks/vinext/tsconfig.json index 39af51225..d899beda7 100644 --- a/benchmarks/vinext/tsconfig.json +++ b/benchmarks/vinext/tsconfig.json @@ -12,8 +12,7 @@ "resolveJsonModule": true, "isolatedModules": true, "paths": { - "@/*": ["./*"], - "vinext": ["../../packages/vinext/src/index.ts"] + "@/*": ["./*"] } }, "include": ["**/*.ts", "**/*.tsx"], diff --git a/packages/vinext/src/server/request-pipeline.ts b/packages/vinext/src/server/request-pipeline.ts index d71e45c71..28ba25ec0 100644 --- a/packages/vinext/src/server/request-pipeline.ts +++ b/packages/vinext/src/server/request-pipeline.ts @@ -601,7 +601,7 @@ export function filterInternalHeaders(headers: Headers): Headers { return filtered; } -function getRequestCf(request: Request): unknown { +function getRequestCf(request: Request): unknown | undefined { const cf = Reflect.get(request, "cf"); return cf === undefined ? undefined : cf; } diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index beac9417f..ceeb09747 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -1,7 +1,7 @@ /** * use-cache-probe-pool.ts * - * Manages a pool of isolated ModuleRunners for "use cache" deadlock probes. + * 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 @@ -23,40 +23,26 @@ import { ModuleRunner } from "vite/module-runner"; import { setUseCacheProbe } from "vinext/shims/use-cache-probe-globals"; import { UseCacheTimeoutError } from "vinext/shims/use-cache-errors"; -let _activeProbeRunners: ModuleRunner[] | null = null; -let _environment: DevEnvironmentLike | DevEnvironment | null = null; -const MAX_RUNNERS = 4; - -function getProbeRunner(): ModuleRunner { - if (!_activeProbeRunners || _activeProbeRunners.length === 0) { - throw new Error("[vinext] use cache probe pool not initialized"); - } - // Round-robin across runners for basic load distribution. - const runner = _activeProbeRunners.shift()!; - _activeProbeRunners.push(runner); - return runner; -} +let _probeEnvironment: DevEnvironmentLike | DevEnvironment | null = null; /** - * Initialize the probe pool with a set of fresh ModuleRunners bound to the - * given Vite dev environment. + * Initialize the probe pool with the Vite dev environment. * * Called once during configureServer() when the App Router dev server starts. */ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvironment): void { - if (_activeProbeRunners) { + if (_probeEnvironment) { // Already initialized — no-op. The environment is the same for the // lifetime of the dev server. return; } - _environment = environment; - _activeProbeRunners = []; - for (let i = 0; i < MAX_RUNNERS; i++) { - _activeProbeRunners.push(createProbeRunner(environment)); - } + _probeEnvironment = environment; setUseCacheProbe(async (msg) => { - const runner = getProbeRunner(); + // 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. + const runner = createProbeRunner(_probeEnvironment!); const { id, kind, encodedArguments, request, timeoutMs } = msg; // Internal timeout so the probe aborts before the outer render timeout. @@ -120,17 +106,14 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // Run the function with a reconstructed request store so private caches // that read cookies()/headers()/draftMode() see the same values. - const result = await runWithProbeRequestStore(request, async () => wrapped(...args)); - - // Wait for the result, but enforce the internal timeout. + // Race against the internal timeout. const remaining = deadline - Date.now(); if (remaining <= 0) { return false; } - // If we got here, the probe completed. await Promise.race([ - Promise.resolve(result), + runWithProbeRequestStore(runner, request, async () => wrapped(...args)), new Promise((_, reject) => { const t = setTimeout(() => reject(new UseCacheTimeoutError()), remaining); // Ensure timer is cleaned up on success via unref if available. @@ -146,6 +129,8 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // even in isolation, or the module import failed. Fall back to the // regular timeout. return false; + } finally { + runner.close().catch(() => {}); } }); } @@ -155,13 +140,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir * probe starts with fresh code. */ export function tearDownUseCacheProbePool(): void { - if (_activeProbeRunners) { - for (const runner of _activeProbeRunners) { - runner.close().catch(() => {}); - } - _activeProbeRunners = null; - } - _environment = null; + _probeEnvironment = null; setUseCacheProbe(undefined); } @@ -170,6 +149,7 @@ export function tearDownUseCacheProbePool(): void { * cookies(), headers(), and draftMode() behave correctly. */ async function runWithProbeRequestStore( + runner: ModuleRunner, requestSnapshot: { headers: [string, string][]; cookieHeader: string | undefined; @@ -181,35 +161,28 @@ async function runWithProbeRequestStore( }, fn: () => Promise, ): Promise { - // Import the ALS-backed request-context modules in the isolated runner. - const unifiedCtx = (async () => { - // These imports run inside the probe runner's module graph. - // We dynamic-import them because the probe runner doesn't share - // module state with the main runner. - const { createRequestContext, runWithRequestContext } = - (await import("vinext/shims/unified-request-context")) as typeof import("vinext/shims/unified-request-context"); - - const { headersContextFromRequest } = - (await 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 as Record, - }); - - return runWithRequestContext(ctx, fn); - })(); - - return unifiedCtx; + // 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 as Record, + }); + + return runWithRequestContext(ctx, fn); } diff --git a/packages/vinext/src/server/use-cache-probe-scheduler.ts b/packages/vinext/src/server/use-cache-probe-scheduler.ts deleted file mode 100644 index 8297c3821..000000000 --- a/packages/vinext/src/server/use-cache-probe-scheduler.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * use-cache-probe-scheduler.ts - * - * Dev-only idle-deadline probe scheduler for "use cache" fills. - * - * Wraps the cache fill stream in a TransformStream and tracks chunk activity. - * If the stream is idle for PROBE_THRESHOLD_MS (10s), schedules a probe that - * re-runs the cache function in a fresh module scope. If the probe completes - * while the main fill is still hung, the caller aborts the fill with a - * UseCacheDeadlockError. - * - * 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 - */ - -import { - getUseCacheProbe, - type UseCacheProbeRequestSnapshot, -} from "vinext/shims/use-cache-probe-globals"; - -const PROBE_THRESHOLD_MS = 10_000; -const MIN_PROBE_BUDGET_MS = 3_000; - -type CacheContextWithProbeFields = { - readonly functionId: string; - readonly handlerKind: string; -}; - -type SetupOptions = { - cacheContext: CacheContextWithProbeFields; - encodedArguments: string | FormData; - /** - * Absolute monotonic deadline (in performance.now() units) at which the - * outer cache fill will be aborted by the dev render-timeout timer. - */ - fillDeadlineAt: number; - /** - * Called once if the probe ran the cache function to completion in isolation - * while the main fill was still pending. - */ - onProbeCompleted: () => void; - /** - * AbortSignal that fires when the probe should stop watching (fill settled, - * timeout fired, upstream cancel, etc.). - */ - abortSignal: AbortSignal; - /** - * The outer request store snapshot so the probe can reconstruct cookies(), - * headers(), draftMode(), etc. in the isolated run. - */ - requestSnapshot: UseCacheProbeRequestSnapshot; - /** - * Cache stream to track. Each chunk resets the idle timer. - */ - stream: ReadableStream; -}; - -/** - * Schedule an idle-deadline probe over a cache fill stream (dev-only). - * - * Returns the input stream unchanged when scheduling should be skipped. - */ -export function setupProbeScheduler(opts: SetupOptions): ReadableStream { - const { - cacheContext, - encodedArguments, - fillDeadlineAt, - stream, - abortSignal, - onProbeCompleted, - requestSnapshot, - } = opts; - - // Skip if the remaining budget is too short for a meaningful probe. - if (fillDeadlineAt - performance.now() < PROBE_THRESHOLD_MS + MIN_PROBE_BUDGET_MS) { - return stream; - } - - const probe = getUseCacheProbe(); - if (!probe) { - return stream; - } - - let lastChunkAt = performance.now(); - let idleTimer: ReturnType | undefined; - - const startProbe = () => { - if (abortSignal.aborted) { - return; - } - - const probeStartedAtChunk = lastChunkAt; - // Reserve a 1s buffer so the probe's internal timeout fires before the - // outer render timeout. - const probeInternalTimeoutMs = fillDeadlineAt - performance.now() - 1_000; - - if (probeInternalTimeoutMs <= 0) { - return; - } - - probe({ - id: cacheContext.functionId, - kind: cacheContext.handlerKind, - encodedArguments, - request: requestSnapshot, - timeoutMs: probeInternalTimeoutMs, - }).then( - (completed) => { - // Mid-probe recovery: chunks arrived while the probe was running. - if (lastChunkAt > probeStartedAtChunk) { - 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 - lastChunkAt; - const wait = Math.max(0, PROBE_THRESHOLD_MS - idleFor); - - // Skip scheduling if the outer fill timeout will fire before the probe - // could even start with a minimum useful budget. - if (fillDeadlineAt - now < wait + MIN_PROBE_BUDGET_MS) { - return; - } - - idleTimer = setTimeout(() => { - idleTimer = undefined; - if (abortSignal.aborted) { - return; - } - const idleNow = performance.now() - lastChunkAt; - if (idleNow < PROBE_THRESHOLD_MS) { - // A chunk 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 stream.pipeThrough( - new TransformStream({ - transform(chunk, controller) { - lastChunkAt = performance.now(); - scheduleAfterIdle(); - controller.enqueue(chunk); - }, - }), - ); -} diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 421c4bafc..ae088c34c 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -407,6 +407,8 @@ export function registerCachedFunction Promise | undefined; + let timeoutTimer: ReturnType | undefined; let probePromise: Promise | null = null; const probe = getUseCacheProbe(); @@ -426,7 +428,7 @@ export function registerCachedFunction Promise((_, reject) => { - setTimeout(() => { + probeTimer = setTimeout(() => { const probeInternalTimeoutMs = fillDeadlineAt - performance.now() - 1_000; if (probeInternalTimeoutMs <= 0) return; @@ -449,9 +451,9 @@ export function registerCachedFunction Promise((_, reject) => { - const t = setTimeout(() => reject(timeoutError), USE_CACHE_TIMEOUT_MS); - if (typeof (t as NodeJS.Timeout).unref === "function") { - (t as NodeJS.Timeout).unref(); + timeoutTimer = setTimeout(() => reject(timeoutError), USE_CACHE_TIMEOUT_MS); + if (typeof (timeoutTimer as NodeJS.Timeout).unref === "function") { + (timeoutTimer as NodeJS.Timeout).unref(); } }); // Swallow rejection when execution wins the race. @@ -463,7 +465,10 @@ export function registerCachedFunction Promise { + if (probeTimer !== undefined) clearTimeout(probeTimer); + if (timeoutTimer !== undefined) clearTimeout(timeoutTimer); + }); } // Shared cache ("use cache" / "use cache: remote") diff --git a/tests/shims.test.ts b/tests/shims.test.ts index ca0bfe0f9..6a63e81b5 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -2927,76 +2927,6 @@ describe("use-cache errors", () => { }); }); -describe("use-cache probe scheduler", () => { - it("setupProbeScheduler returns stream unchanged when no probe is installed", async () => { - const { setupProbeScheduler } = - await import("../packages/vinext/src/server/use-cache-probe-scheduler.js"); - - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array([1, 2, 3])); - controller.close(); - }, - }); - - const abortController = new AbortController(); - const result = setupProbeScheduler({ - cacheContext: { functionId: "test:fn", handlerKind: "" }, - encodedArguments: "[]", - fillDeadlineAt: performance.now() + 60_000, - stream, - abortSignal: abortController.signal, - onProbeCompleted: () => {}, - requestSnapshot: { - headers: [], - cookieHeader: undefined, - urlPathname: "/", - urlSearch: "", - rootParams: {}, - isDraftMode: false, - isHmrRefresh: false, - }, - }); - - // When no probe is installed globally, the stream should pass through unchanged. - expect(result).toBe(stream); - }); - - it("setupProbeScheduler returns stream unchanged when budget is too short", async () => { - const { setupProbeScheduler } = - await import("../packages/vinext/src/server/use-cache-probe-scheduler.js"); - - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array([1, 2, 3])); - controller.close(); - }, - }); - - const abortController = new AbortController(); - const result = setupProbeScheduler({ - cacheContext: { functionId: "test:fn", handlerKind: "" }, - encodedArguments: "[]", - // Budget is less than PROBE_THRESHOLD_MS + MIN_PROBE_BUDGET_MS - fillDeadlineAt: performance.now() + 5_000, - stream, - abortSignal: abortController.signal, - onProbeCompleted: () => {}, - requestSnapshot: { - headers: [], - cookieHeader: undefined, - urlPathname: "/", - urlSearch: "", - rootParams: {}, - isDraftMode: false, - isHmrRefresh: false, - }, - }); - - expect(result).toBe(stream); - }); -}); - describe("replyToCacheKey deterministic hashing", () => { it("returns string replies as-is", async () => { const { replyToCacheKey } = await import("../packages/vinext/src/shims/cache-runtime.js"); From b4886d133f1dec6411914d414cba77f089fde91b Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 11 May 2026 02:07:32 -0700 Subject: [PATCH 06/19] fix(use-cache): address all review issues for deadlock probe - Re-init probe pool after HMR teardown so probes survive file changes - Add inside-probe guard flag to prevent recursive probing via globalThis - Remove duplicate createProbeRunner, reuse createDirectRunner - Change ModuleRunner import to type-only in use-cache-probe-pool - Set this.name in UseCacheTimeoutError and UseCacheDeadlockError - Remove unused isDraftMode/isHmrRefresh from UseCacheProbeRequestSnapshot - Add test for isInsideUseCacheProbe guard behavior --- packages/vinext/src/index.ts | 5 ++ .../vinext/src/server/dev-module-runner.ts | 48 ------------------- .../vinext/src/server/use-cache-probe-pool.ts | 14 +++--- packages/vinext/src/shims/cache-runtime.ts | 10 ++-- packages/vinext/src/shims/use-cache-errors.ts | 2 + .../src/shims/use-cache-probe-globals.ts | 11 ++++- tests/shims.test.ts | 48 +++++++++++++++++++ 7 files changed, 78 insertions(+), 60 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index a5b073990..fecf9c912 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2045,6 +2045,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // 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); + } } // Node throws on unhandled 'error' events on sockets. When a browser diff --git a/packages/vinext/src/server/dev-module-runner.ts b/packages/vinext/src/server/dev-module-runner.ts index 8fdf1d0a5..07df21cf8 100644 --- a/packages/vinext/src/server/dev-module-runner.ts +++ b/packages/vinext/src/server/dev-module-runner.ts @@ -129,51 +129,3 @@ export function createDirectRunner(environment: DevEnvironmentLike | DevEnvironm new ESModulesEvaluator(), ); } - -/** - * Create a fresh ModuleRunner with its own isolated module graph. - * - * Used by the "use cache" deadlock probe to re-import a cache function - * with zero shared module-scope state from the main request pipeline. - * Each probe gets a brand-new `EvaluatedModules` instance so top-level - * `Map`s, `Promise`s, etc. are recreated from scratch. - * - * The transport delegates to the same `environment.fetchModule()`, so - * Vite transforms and HMR still work, but the *evaluated* module cache - * is completely separate. - */ -export function createProbeRunner(environment: DevEnvironmentLike | DevEnvironment): ModuleRunner { - return new ModuleRunner( - { - transport: { - // oxlint-disable-next-line @typescript-eslint/no-explicit-any - invoke: async (payload: any) => { - const { name, data: args } = payload.data; - if (name === "fetchModule") { - const [id, importer, options] = args as [ - string, - string | undefined, - { cached?: boolean; startOffset?: number } | undefined, - ]; - return { - result: await environment.fetchModule(id, importer, options), - }; - } - if (name === "getBuiltins") { - return { result: [] }; - } - return { - error: { - name: "Error", - message: `[vinext] Unexpected ModuleRunner invoke: ${name}`, - }, - }; - }, - }, - createImportMeta: createNodeImportMeta, - sourcemapInterceptor: false, - hmr: false, - }, - new ESModulesEvaluator(), - ); -} diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index ceeb09747..c39c8570a 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -18,9 +18,9 @@ */ import type { DevEnvironment } from "vite"; -import { createProbeRunner, type DevEnvironmentLike } from "./dev-module-runner.js"; -import { ModuleRunner } from "vite/module-runner"; -import { setUseCacheProbe } from "vinext/shims/use-cache-probe-globals"; +import { createDirectRunner, type DevEnvironmentLike } from "./dev-module-runner.js"; +import type { ModuleRunner } from "vite/module-runner"; +import { setUseCacheProbe, setInsideUseCacheProbe } from "vinext/shims/use-cache-probe-globals"; import { UseCacheTimeoutError } from "vinext/shims/use-cache-errors"; let _probeEnvironment: DevEnvironmentLike | DevEnvironment | null = null; @@ -42,12 +42,15 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // 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. - const runner = createProbeRunner(_probeEnvironment!); + // createDirectRunner creates a fresh ModuleRunner with its own isolated + // EvaluatedModules instance, which is exactly what we need for probes. + const runner = createDirectRunner(_probeEnvironment!); const { id, kind, encodedArguments, request, timeoutMs } = msg; // Internal timeout so the probe aborts before the outer render timeout. const deadline = Date.now() + timeoutMs; + setInsideUseCacheProbe(true); try { // Import the cache-runtime shim in the isolated runner. // The shim's registerCachedFunction will create fresh module-scope state. @@ -130,6 +133,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // regular timeout. return false; } finally { + setInsideUseCacheProbe(false); runner.close().catch(() => {}); } }); @@ -156,8 +160,6 @@ async function runWithProbeRequestStore( urlPathname: string; urlSearch: string; rootParams: Record; - isDraftMode: boolean; - isHmrRefresh: boolean; }, fn: () => Promise, ): Promise { diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index ae088c34c..34ae666f0 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -43,7 +43,11 @@ import { runWithUnifiedStateMutation, } from "./unified-request-context.js"; import { UseCacheTimeoutError, UseCacheDeadlockError } from "./use-cache-errors.js"; -import { getUseCacheProbe, type UseCacheProbeRequestSnapshot } from "./use-cache-probe-globals.js"; +import { + getUseCacheProbe, + isInsideUseCacheProbe, + type UseCacheProbeRequestSnapshot, +} from "./use-cache-probe-globals.js"; // --------------------------------------------------------------------------- // Cache execution context — AsyncLocalStorage for cacheLife/cacheTag @@ -412,7 +416,7 @@ export function registerCachedFunction Promise | null = null; const probe = getUseCacheProbe(); - if (probe) { + if (probe && !isInsideUseCacheProbe()) { // Capture the current request store snapshot for the probe. const requestCtx = getRequestContext(); const headers = requestCtx.headersContext?.headers; @@ -423,8 +427,6 @@ export function registerCachedFunction Promise((_, reject) => { diff --git a/packages/vinext/src/shims/use-cache-errors.ts b/packages/vinext/src/shims/use-cache-errors.ts index 7f5b79c06..40312bb4a 100644 --- a/packages/vinext/src/shims/use-cache-errors.ts +++ b/packages/vinext/src/shims/use-cache-errors.ts @@ -17,6 +17,7 @@ export class UseCacheTimeoutError extends Error { 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"; } } @@ -27,6 +28,7 @@ export class UseCacheDeadlockError extends Error { 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"; } } diff --git a/packages/vinext/src/shims/use-cache-probe-globals.ts b/packages/vinext/src/shims/use-cache-probe-globals.ts index adde9a412..b2e92363e 100644 --- a/packages/vinext/src/shims/use-cache-probe-globals.ts +++ b/packages/vinext/src/shims/use-cache-probe-globals.ts @@ -10,6 +10,7 @@ */ const SYMBOL = Symbol.for("vinext.dev.useCacheProbe"); +const INSIDE_PROBE_SYMBOL = Symbol.for("vinext.dev.useCacheProbe.inside"); export type UseCacheProbeRequestSnapshot = { headers: [string, string][]; @@ -17,8 +18,6 @@ export type UseCacheProbeRequestSnapshot = { urlPathname: string; urlSearch: string; rootParams: Record; - isDraftMode: boolean; - isHmrRefresh: boolean; }; export type UseCacheProbe = (msg: { @@ -36,3 +35,11 @@ export function setUseCacheProbe(fn: UseCacheProbe | undefined): void { export function getUseCacheProbe(): UseCacheProbe | undefined { return (globalThis as Record)[SYMBOL] as UseCacheProbe | undefined; } + +export function setInsideUseCacheProbe(value: boolean): void { + (globalThis as Record)[INSIDE_PROBE_SYMBOL] = value; +} + +export function isInsideUseCacheProbe(): boolean { + return !!(globalThis as Record)[INSIDE_PROBE_SYMBOL]; +} diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 6a63e81b5..c05f6acc2 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -2925,6 +2925,54 @@ describe("use-cache errors", () => { 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 isInsideUseCacheProbe is true", async () => { + const { setUseCacheProbe, setInsideUseCacheProbe } = + 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 + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "development"; + vi.resetModules(); + + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + + const fn = async () => "result"; + const cached = registerCachedFunction(fn, "test:no-recurse", ""); + + setInsideUseCacheProbe(true); + const result = await cached(); + setInsideUseCacheProbe(false); + + expect(result).toBe("result"); + expect(probeCalled).toBe(false); + + process.env.NODE_ENV = originalNodeEnv; + setUseCacheProbe(undefined); + }); }); describe("replyToCacheKey deterministic hashing", () => { From b543d3ab0e6c4dda8b751a1970384eb4df7f28b1 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 11 May 2026 10:26:02 -0700 Subject: [PATCH 07/19] fix(use-cache): address v3 review feedback on deadlock probe - Convert isInsideUseCacheProbe from global boolean to refcount to fix concurrent probe race condition. - Store and clearTimeout() the probe timeout instead of only .unref(). - Use dynamic import() for dev-only probe modules inside the isDev branch so they tree-shake out of production builds. - Wrap test assertions in try/finally to prevent state poisoning on failure. - Add comment documenting colon assumption in function ID splitting. --- .../vinext/src/server/use-cache-probe-pool.ts | 10 ++++++---- packages/vinext/src/shims/cache-runtime.ts | 16 +++++++++------- .../src/shims/use-cache-probe-globals.ts | 7 +++++-- tests/shims.test.ts | 18 ++++++++++-------- 4 files changed, 30 insertions(+), 21 deletions(-) diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index c39c8570a..9e28a3f70 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -50,6 +50,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // Internal timeout so the probe aborts before the outer render timeout. const deadline = Date.now() + timeoutMs; + let probeTimeoutTimer: ReturnType | undefined; setInsideUseCacheProbe(true); try { // Import the cache-runtime shim in the isolated runner. @@ -73,6 +74,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // 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"; @@ -118,10 +120,9 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir await Promise.race([ runWithProbeRequestStore(runner, request, async () => wrapped(...args)), new Promise((_, reject) => { - const t = setTimeout(() => reject(new UseCacheTimeoutError()), remaining); - // Ensure timer is cleaned up on success via unref if available. - if (typeof (t as NodeJS.Timeout).unref === "function") { - (t as NodeJS.Timeout).unref(); + probeTimeoutTimer = setTimeout(() => reject(new UseCacheTimeoutError()), remaining); + if (typeof (probeTimeoutTimer as NodeJS.Timeout).unref === "function") { + (probeTimeoutTimer as NodeJS.Timeout).unref(); } }), ]); @@ -134,6 +135,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir return false; } finally { setInsideUseCacheProbe(false); + if (probeTimeoutTimer !== undefined) clearTimeout(probeTimeoutTimer); runner.close().catch(() => {}); } }); diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 34ae666f0..425d1113b 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -42,12 +42,6 @@ import { getRequestContext, runWithUnifiedStateMutation, } from "./unified-request-context.js"; -import { UseCacheTimeoutError, UseCacheDeadlockError } from "./use-cache-errors.js"; -import { - getUseCacheProbe, - isInsideUseCacheProbe, - type UseCacheProbeRequestSnapshot, -} from "./use-cache-probe-globals.js"; // --------------------------------------------------------------------------- // Cache execution context — AsyncLocalStorage for cacheLife/cacheTag @@ -408,6 +402,14 @@ export function registerCachedFunction Promise Promise)[INSIDE_PROBE_SYMBOL] = value; + const current = ((globalThis as Record)[INSIDE_PROBE_SYMBOL] as number) || 0; + (globalThis as Record)[INSIDE_PROBE_SYMBOL] = value + ? current + 1 + : Math.max(0, current - 1); } export function isInsideUseCacheProbe(): boolean { - return !!(globalThis as Record)[INSIDE_PROBE_SYMBOL]; + return (((globalThis as Record)[INSIDE_PROBE_SYMBOL] as number) || 0) > 0; } diff --git a/tests/shims.test.ts b/tests/shims.test.ts index c05f6acc2..ccda3d39e 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -2964,14 +2964,16 @@ describe("use-cache deadlock probe behavior", () => { const cached = registerCachedFunction(fn, "test:no-recurse", ""); setInsideUseCacheProbe(true); - const result = await cached(); - setInsideUseCacheProbe(false); - - expect(result).toBe("result"); - expect(probeCalled).toBe(false); - - process.env.NODE_ENV = originalNodeEnv; - setUseCacheProbe(undefined); + let result: unknown; + try { + result = await cached(); + expect(result).toBe("result"); + expect(probeCalled).toBe(false); + } finally { + setInsideUseCacheProbe(false); + process.env.NODE_ENV = originalNodeEnv; + setUseCacheProbe(undefined); + } }); }); From c2e053f7eb454ffd1b2d0d1d94aa8025839cdb36 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 11 May 2026 10:35:17 -0700 Subject: [PATCH 08/19] fix(use-cache): address v4 review feedback on deadlock probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Distinguish UseCacheTimeoutError from other thrown errors in probe catch block. A function that throws completed (with an error) and is evidence of a deadlock in the main fill, so return true. Only a probe that times out is genuinely inconclusive (return false). - Capture env in a local variable in initUseCacheProbePool to avoid race condition where HMR teardown sets _probeEnvironment = null mid-flight. - Fix rootParams type mismatch: UseCacheProbeRequestSnapshot now uses Record matching RootParams, eliminating the unsafe cast in the probe pool. - Add comment explaining getRequestContext() default behavior when called outside a request scope. - Add fake-timer unit test covering the core deadlock-detection path: probe completes → deadlockError rejection → Promise.race surfaces UseCacheDeadlockError. --- .../vinext/src/server/use-cache-probe-pool.ts | 24 ++++++---- packages/vinext/src/shims/cache-runtime.ts | 5 ++ .../src/shims/use-cache-probe-globals.ts | 2 +- tests/shims.test.ts | 47 +++++++++++++++++++ 4 files changed, 69 insertions(+), 9 deletions(-) diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index 9e28a3f70..f2ccf548b 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -38,13 +38,17 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir } _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(_probeEnvironment!); + const runner = createDirectRunner(env); const { id, kind, encodedArguments, request, timeoutMs } = msg; // Internal timeout so the probe aborts before the outer render timeout. @@ -128,11 +132,15 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir ]); return true; - } catch { - // Probe failure is inconclusive — the function might genuinely hang - // even in isolation, or the module import failed. Fall back to the - // regular timeout. - return false; + } 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 { setInsideUseCacheProbe(false); if (probeTimeoutTimer !== undefined) clearTimeout(probeTimeoutTimer); @@ -161,7 +169,7 @@ async function runWithProbeRequestStore( cookieHeader: string | undefined; urlPathname: string; urlSearch: string; - rootParams: Record; + rootParams: Record; }, fn: () => Promise, ): Promise { @@ -185,7 +193,7 @@ async function runWithProbeRequestStore( const ctx = createRequestContext({ headersContext, executionContext: null, - rootParams: requestSnapshot.rootParams as Record, + rootParams: requestSnapshot.rootParams, }); return runWithRequestContext(ctx, fn); diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 425d1113b..c89266fa6 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -420,6 +420,11 @@ export function registerCachedFunction Promise; + rootParams: Record; }; export type UseCacheProbe = (msg: { diff --git a/tests/shims.test.ts b/tests/shims.test.ts index ccda3d39e..8643bae21 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -2975,6 +2975,53 @@ describe("use-cache deadlock probe behavior", () => { 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); + + const originalNodeEnv = process.env.NODE_ENV; + process.env.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 the microtask that carries the deadlock rejection. + await Promise.resolve(); + + expect(isUseCacheDeadlockError(caughtError)).toBe(true); + expect(caughtError?.message).toContain("shared state"); + } finally { + resolveHung?.("cleanup"); + vi.useRealTimers(); + process.env.NODE_ENV = originalNodeEnv; + setUseCacheProbe(undefined); + } + }); }); describe("replyToCacheKey deterministic hashing", () => { From 445a0d0047ce1faeaadf604139c162c130b40e72 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 11 May 2026 12:23:00 -0700 Subject: [PATCH 09/19] fix(use-cache): address v5 review feedback on deadlock probe - Add console.warn when probe is inconclusive (returns false) so developers get a signal at 10s instead of silently waiting 54s. - Fix misleading initUseCacheProbePool comment to reflect HMR teardown and reinit cycle, not one-time init. - Remove dead private variant branch in probe pool (private caches return before probe scheduling); document with comment and remove unused kind destructuring. - Add explicit type cast for rootParams at snapshot capture site to fix Record vs Record mismatch. - Remove cookieHeader from UseCacheProbeRequestSnapshot (never consumed; headers array already includes cookie). - Wrap JSON.stringify(args) in try/catch with fallback to prevent crash on circular references or BigInts. - Add comment in test about vi.resetModules() mixing module instances. --- .../vinext/src/server/use-cache-probe-pool.ts | 11 +++++---- packages/vinext/src/shims/cache-runtime.ts | 23 +++++++++++++++---- .../src/shims/use-cache-probe-globals.ts | 1 - tests/shims.test.ts | 6 ++++- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index f2ccf548b..1a97e50c6 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -32,8 +32,8 @@ let _probeEnvironment: DevEnvironmentLike | DevEnvironment | null = null; */ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvironment): void { if (_probeEnvironment) { - // Already initialized — no-op. The environment is the same for the - // lifetime of the dev server. + // Guard against double-init within the same cycle (e.g., if + // initUseCacheProbePool is called without a preceding teardown). return; } _probeEnvironment = environment; @@ -49,7 +49,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // 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, kind, encodedArguments, request, timeoutMs } = msg; + const { id, encodedArguments, request, timeoutMs } = msg; // Internal timeout so the probe aborts before the outer render timeout. const deadline = Date.now() + timeoutMs; @@ -92,7 +92,9 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // Wrap it with registerCachedFunction so the probe runs through the // same cache-runtime path (fresh ALS, no shared state). - const variant = kind === "private" ? "private" : ""; + // Private cache functions return before reaching the probe scheduling + // code, so kind can never be "private" here. Keep "" for safety. + const variant = ""; const wrapped = registerCachedFunction( originalFn as (...args: unknown[]) => Promise, id, @@ -166,7 +168,6 @@ async function runWithProbeRequestStore( runner: ModuleRunner, requestSnapshot: { headers: [string, string][]; - cookieHeader: string | undefined; urlPathname: string; urlSearch: string; rootParams: Record; diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index c89266fa6..9714b1471 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -430,10 +430,12 @@ export function registerCachedFunction Promise, }; probePromise = new Promise((_, reject) => { @@ -444,12 +446,25 @@ export function registerCachedFunction Promise { + try { + return JSON.stringify(args); + } catch { + return "[]"; + } + })(), request: requestSnapshot, timeoutMs: probeInternalTimeoutMs, }).then( (completed) => { - if (completed) reject(deadlockError); + if (completed) { + reject(deadlockError); + } 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.`, + ); + } }, () => {}, ); diff --git a/packages/vinext/src/shims/use-cache-probe-globals.ts b/packages/vinext/src/shims/use-cache-probe-globals.ts index a25048d70..353f79b59 100644 --- a/packages/vinext/src/shims/use-cache-probe-globals.ts +++ b/packages/vinext/src/shims/use-cache-probe-globals.ts @@ -14,7 +14,6 @@ const INSIDE_PROBE_SYMBOL = Symbol.for("vinext.dev.useCacheProbe.inside"); export type UseCacheProbeRequestSnapshot = { headers: [string, string][]; - cookieHeader: string | undefined; urlPathname: string; urlSearch: string; rootParams: Record; diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 8643bae21..cedf4c822 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -2952,7 +2952,11 @@ describe("use-cache deadlock probe behavior", () => { return true; }); - // Ensure dev mode path is taken by resetting modules and setting NODE_ENV + // Ensure dev mode path is taken by resetting modules and setting NODE_ENV. + // Note: vi.resetModules() re-evaluates cache-runtime.js with the new + // NODE_ENV, but probe-globals.js (imported above) is from the original + // module evaluation. Both use Symbol.for on globalThis, so they still + // share the same global state despite being different module instances. const originalNodeEnv = process.env.NODE_ENV; process.env.NODE_ENV = "development"; vi.resetModules(); From 9dc52547e9695d85440ccac3b770bf3e81b27221 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 12 May 2026 10:49:31 -0700 Subject: [PATCH 10/19] refactor(server): use performance.now() in use-cache probe pool - Switch deadline calculation from Date.now() to performance.now() for higher-resolution timing in probe timeout tracking. - Remove dead comment about private caches that no longer applies. --- packages/vinext/src/server/use-cache-probe-pool.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index 1a97e50c6..1779b845b 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -52,7 +52,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir const { id, encodedArguments, request, timeoutMs } = msg; // Internal timeout so the probe aborts before the outer render timeout. - const deadline = Date.now() + timeoutMs; + const deadline = performance.now() + timeoutMs; let probeTimeoutTimer: ReturnType | undefined; setInsideUseCacheProbe(true); @@ -92,8 +92,6 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // Wrap it with registerCachedFunction so the probe runs through the // same cache-runtime path (fresh ALS, no shared state). - // Private cache functions return before reaching the probe scheduling - // code, so kind can never be "private" here. Keep "" for safety. const variant = ""; const wrapped = registerCachedFunction( originalFn as (...args: unknown[]) => Promise, @@ -118,7 +116,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // Run the function with a reconstructed request store so private caches // that read cookies()/headers()/draftMode() see the same values. // Race against the internal timeout. - const remaining = deadline - Date.now(); + const remaining = deadline - performance.now(); if (remaining <= 0) { return false; } From bc998e6805de7c8d8d20dff97bb1a8e27e901811 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 12 May 2026 10:51:05 -0700 Subject: [PATCH 11/19] fix(shims/cache-runtime): review round 6 cleanups for use-cache probe - Replace unsafe rootParams type assertion with explicit narrowing - Lazy-construct UseCacheTimeoutError / UseCacheDeadlockError in dev mode - Hoist dev-only dynamic imports to a lazy singleton to cut per-call microtask overhead --- packages/vinext/src/shims/cache-runtime.ts | 54 +++++++++++++++------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 15ffa37a5..52e878bbc 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -117,6 +117,30 @@ function getUseCacheKeySeed(): string | undefined { return getUseCacheDeploymentIdDefine() || getUseCacheBuildIdDefine(); } +// Lazy singleton for dev-only probe modules — eliminates microtask +// overhead on every "use cache" call after the first. +let _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; + isInsideUseCacheProbe: typeof import("./use-cache-probe-globals.js").isInsideUseCacheProbe; + } + | undefined; + +async function loadProbeModules() { + 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, + isInsideUseCacheProbe: globals.isInsideUseCacheProbe, + }; +} + 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}`; @@ -403,16 +427,12 @@ export function registerCachedFunction Promise | undefined; let timeoutTimer: ReturnType | undefined; @@ -433,10 +453,12 @@ export function registerCachedFunction Promise, + rootParams: Object.fromEntries( + Object.entries(requestCtx.rootParams ?? {}).map(([k, v]) => [ + k, + typeof v === "string" || Array.isArray(v) ? v : undefined, + ]), + ) as Record, }; probePromise = new Promise((_, reject) => { @@ -459,7 +481,7 @@ export function registerCachedFunction Promise { if (completed) { - reject(deadlockError); + reject(new UseCacheDeadlockError()); } else if (typeof console !== "undefined") { console.warn( `[vinext] "use cache" fill for ${id} has been idle for 10s. ` + @@ -476,7 +498,7 @@ export function registerCachedFunction Promise((_, reject) => { - timeoutTimer = setTimeout(() => reject(timeoutError), USE_CACHE_TIMEOUT_MS); + timeoutTimer = setTimeout(() => reject(new UseCacheTimeoutError()), USE_CACHE_TIMEOUT_MS); if (typeof (timeoutTimer as NodeJS.Timeout).unref === "function") { (timeoutTimer as NodeJS.Timeout).unref(); } From 0b10e4fe246f42c19f955bffa9fe9d3ef813a533 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 12 May 2026 11:15:07 -0700 Subject: [PATCH 12/19] refactor(server): lazy-load use-cache probe pool and document variant constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 7 follow-up: - use-cache-probe-pool.ts: Add comment explaining why — private caches return before the probe block in cache-runtime.ts, so the probe is only ever scheduled for shared caches where variant is always "". - index.ts: Convert static import of use-cache-probe-pool to lazy dynamic import via getProbePoolModule(). This avoids loading probe-globals and use-cache-errors modules during production builds where configureServer() is never called. Made configureServer async and updated call sites. --- packages/vinext/src/index.ts | 25 ++++++++++++++----- .../vinext/src/server/use-cache-probe-pool.ts | 3 +++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 6da1cf770..dd69b4036 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -16,7 +16,6 @@ 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 { initUseCacheProbePool, tearDownUseCacheProbePool } 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"; @@ -140,6 +139,17 @@ installSocketErrorBackstop(); 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 _probePoolModule: typeof import("./server/use-cache-probe-pool.js") | null = null; +async function getProbePoolModule() { + if (!_probePoolModule) { + _probePoolModule = await import("./server/use-cache-probe-pool.js"); + } + return _probePoolModule; +} + type VitePluginReactModule = typeof import("@vitejs/plugin-react"); function resolveOptionalDependency(projectRoot: string, specifier: string): string | null { @@ -1992,7 +2002,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; @@ -2050,10 +2060,11 @@ 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(); @@ -2081,7 +2092,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { invalidateRouteCache(pagesDir); } if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) { - invalidateAppRoutingModules(); + invalidateAppRoutingModules().catch(() => {}); } }); server.watcher.on("unlink", (filePath: string) => { @@ -2089,7 +2100,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { invalidateRouteCache(pagesDir); } if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) { - invalidateAppRoutingModules(); + invalidateAppRoutingModules().catch(() => {}); } }); @@ -2160,7 +2171,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // "use cache" functions run inside the RSC module graph. const rscEnv = server.environments["rsc"]; if (rscEnv) { - initUseCacheProbePool(rscEnv); + getProbePoolModule() + .then(({ initUseCacheProbePool }) => initUseCacheProbePool(rscEnv)) + .catch(() => {}); } server.middlewares.use((req, res, next) => { diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index 1779b845b..56b68caf6 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -92,6 +92,9 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // Wrap it with registerCachedFunction so the probe runs through the // same cache-runtime path (fresh ALS, no shared state). + // Private caches return before the probe block in cache-runtime.ts, + // so the probe is only ever scheduled for shared caches where variant + // is always "". const variant = ""; const wrapped = registerCachedFunction( originalFn as (...args: unknown[]) => Promise, From 322344589a454e2fc045b79c7be98c3d420f1ccd Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Wed, 13 May 2026 21:42:32 -0700 Subject: [PATCH 13/19] fix(server): surface use-cache probe failures --- packages/vinext/src/index.ts | 12 +++++++++--- packages/vinext/src/shims/cache-runtime.ts | 1 - packages/vinext/src/shims/use-cache-probe-globals.ts | 1 - 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index dd69b4036..6a5403827 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2092,7 +2092,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { invalidateRouteCache(pagesDir); } if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) { - invalidateAppRoutingModules().catch(() => {}); + invalidateAppRoutingModules().catch((err) => { + console.warn("[vinext] Failed to invalidate app routing modules:", err); + }); } }); server.watcher.on("unlink", (filePath: string) => { @@ -2100,7 +2102,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { invalidateRouteCache(pagesDir); } if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) { - invalidateAppRoutingModules().catch(() => {}); + invalidateAppRoutingModules().catch((err) => { + console.warn("[vinext] Failed to invalidate app routing modules:", err); + }); } }); @@ -2173,7 +2177,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (rscEnv) { getProbePoolModule() .then(({ initUseCacheProbePool }) => initUseCacheProbePool(rscEnv)) - .catch(() => {}); + .catch((err) => { + console.warn("[vinext] Failed to initialize use-cache probe pool:", err); + }); } server.middlewares.use((req, res, next) => { diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 52e878bbc..f83c5add1 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -468,7 +468,6 @@ export function registerCachedFunction Promise { try { return JSON.stringify(args); diff --git a/packages/vinext/src/shims/use-cache-probe-globals.ts b/packages/vinext/src/shims/use-cache-probe-globals.ts index 353f79b59..fb96c90e9 100644 --- a/packages/vinext/src/shims/use-cache-probe-globals.ts +++ b/packages/vinext/src/shims/use-cache-probe-globals.ts @@ -21,7 +21,6 @@ export type UseCacheProbeRequestSnapshot = { export type UseCacheProbe = (msg: { id: string; - kind: string; encodedArguments: string | FormData; request: UseCacheProbeRequestSnapshot; timeoutMs: number; From f15c46721f83ab83b4bef09b77c59b0f354e6c93 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Thu, 14 May 2026 14:59:51 -0700 Subject: [PATCH 14/19] fix(shims): address use-cache probe review nits --- .../vinext/src/server/use-cache-probe-pool.ts | 20 +++++------- packages/vinext/src/shims/cache-runtime.ts | 32 +++++++++++++------ .../src/shims/use-cache-probe-globals.ts | 2 +- tests/shims.test.ts | 1 + 4 files changed, 32 insertions(+), 23 deletions(-) diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index 56b68caf6..4d48fe04b 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -28,7 +28,8 @@ let _probeEnvironment: DevEnvironmentLike | DevEnvironment | null = null; /** * Initialize the probe pool with the Vite dev environment. * - * Called once during configureServer() when the App Router dev server starts. + * 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) { @@ -92,10 +93,7 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // Wrap it with registerCachedFunction so the probe runs through the // same cache-runtime path (fresh ALS, no shared state). - // Private caches return before the probe block in cache-runtime.ts, - // so the probe is only ever scheduled for shared caches where variant - // is always "". - const variant = ""; + const variant = ""; // Only shared caches reach the probe block. const wrapped = registerCachedFunction( originalFn as (...args: unknown[]) => Promise, id, @@ -107,13 +105,11 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // For deadlock detection, the exact argument values matter less than // the fact that the function body executes with a fresh module scope. let args: unknown[] = []; - if (typeof encodedArguments === "string") { - try { - args = JSON.parse(encodedArguments); - if (!Array.isArray(args)) args = [args]; - } catch { - args = []; - } + try { + args = JSON.parse(encodedArguments); + if (!Array.isArray(args)) args = [args]; + } catch { + args = []; } // Run the function with a reconstructed request store so private caches diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index f83c5add1..dac72d113 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -117,18 +117,19 @@ 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; + isInsideUseCacheProbe: typeof import("./use-cache-probe-globals.js").isInsideUseCacheProbe; +}; + // Lazy singleton for dev-only probe modules — eliminates microtask // overhead on every "use cache" call after the first. -let _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; - isInsideUseCacheProbe: typeof import("./use-cache-probe-globals.js").isInsideUseCacheProbe; - } - | undefined; +let _probeModules: ProbeModules | undefined; +let _probeModulesPromise: Promise | undefined; -async function loadProbeModules() { +async function loadProbeModules(): Promise { const [errors, globals] = await Promise.all([ import("./use-cache-errors.js"), import("./use-cache-probe-globals.js"), @@ -141,6 +142,17 @@ async function loadProbeModules() { }; } +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}`; @@ -432,7 +444,7 @@ export function registerCachedFunction Promise | undefined; let timeoutTimer: ReturnType | undefined; diff --git a/packages/vinext/src/shims/use-cache-probe-globals.ts b/packages/vinext/src/shims/use-cache-probe-globals.ts index fb96c90e9..39b09e851 100644 --- a/packages/vinext/src/shims/use-cache-probe-globals.ts +++ b/packages/vinext/src/shims/use-cache-probe-globals.ts @@ -21,7 +21,7 @@ export type UseCacheProbeRequestSnapshot = { export type UseCacheProbe = (msg: { id: string; - encodedArguments: string | FormData; + encodedArguments: string; request: UseCacheProbeRequestSnapshot; timeoutMs: number; }) => Promise; diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 57d2f3a5b..81183a43e 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -3105,6 +3105,7 @@ describe("use-cache deadlock probe behavior", () => { await vi.advanceTimersByTimeAsync(10_000); // Flush the microtask that carries the deadlock rejection. await Promise.resolve(); + await Promise.resolve(); expect(isUseCacheDeadlockError(caughtError)).toBe(true); expect(caughtError?.message).toContain("shared state"); From 86678d81ef5a210ab213e9827ea4fe73d71fd970 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sat, 16 May 2026 16:13:23 -0700 Subject: [PATCH 15/19] fix(shims): address ask-bonk review nits for use-cache probe - Eliminate getProbeModules() microtask latency by checking _probeModules synchronously before awaiting (actionable item) - Make getProbePoolModule() return cached promise directly via ??=, removing unnecessary async/await boundary (non-blocking) - Replace hardcoded Promise.resolve() flushes with a resilient loop in deadlock probe test (non-blocking) --- packages/vinext/src/index.ts | 11 +++++------ packages/vinext/src/shims/cache-runtime.ts | 6 +++++- tests/shims.test.ts | 8 +++++--- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index baa142b8c..89a93c211 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -149,12 +149,11 @@ 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 _probePoolModule: typeof import("./server/use-cache-probe-pool.js") | null = null; -async function getProbePoolModule() { - if (!_probePoolModule) { - _probePoolModule = await import("./server/use-cache-probe-pool.js"); - } - return _probePoolModule; +let _probePoolModulePromise: Promise | null = + null; +function getProbePoolModule(): Promise { + _probePoolModulePromise ??= import("./server/use-cache-probe-pool.js"); + return _probePoolModulePromise; } type VitePluginReactModule = typeof import("@vitejs/plugin-react"); diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index dac72d113..28876b716 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -439,12 +439,16 @@ export function registerCachedFunction Promise | undefined; let timeoutTimer: ReturnType | undefined; diff --git a/tests/shims.test.ts b/tests/shims.test.ts index e0b5e9bce..2cee6825d 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -4280,9 +4280,11 @@ describe("use-cache deadlock probe behavior", () => { // Advance to the 10 s probe timer. await vi.advanceTimersByTimeAsync(10_000); - // Flush the microtask that carries the deadlock rejection. - await Promise.resolve(); - await Promise.resolve(); + // 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"); From 242b27e5578e6ecd21e5069df8342f19037efdca Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sat, 16 May 2026 16:28:43 -0700 Subject: [PATCH 16/19] fix(tests): use vi.stubEnv instead of direct process.env.NODE_ENV assignment Direct assignment to process.env.NODE_ENV is now a TS2540 type error (read-only property). Use Vitest's vi.stubEnv / vi.unstubAllEnvs instead. --- tests/shims.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 2cee6825d..bde11f691 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -4223,8 +4223,7 @@ describe("use-cache deadlock probe behavior", () => { // NODE_ENV, but probe-globals.js (imported above) is from the original // module evaluation. Both use Symbol.for on globalThis, so they still // share the same global state despite being different module instances. - const originalNodeEnv = process.env.NODE_ENV; - process.env.NODE_ENV = "development"; + vi.stubEnv("NODE_ENV", "development"); vi.resetModules(); const { registerCachedFunction } = @@ -4241,7 +4240,7 @@ describe("use-cache deadlock probe behavior", () => { expect(probeCalled).toBe(false); } finally { setInsideUseCacheProbe(false); - process.env.NODE_ENV = originalNodeEnv; + vi.unstubAllEnvs(); setUseCacheProbe(undefined); } }); @@ -4253,8 +4252,7 @@ describe("use-cache deadlock probe behavior", () => { // Install a probe that reports the function completed in isolation. setUseCacheProbe(async () => true); - const originalNodeEnv = process.env.NODE_ENV; - process.env.NODE_ENV = "development"; + vi.stubEnv("NODE_ENV", "development"); vi.useFakeTimers(); vi.resetModules(); @@ -4291,7 +4289,7 @@ describe("use-cache deadlock probe behavior", () => { } finally { resolveHung?.("cleanup"); vi.useRealTimers(); - process.env.NODE_ENV = originalNodeEnv; + vi.unstubAllEnvs(); setUseCacheProbe(undefined); } }); From 42fb5cda5ad421412208badf40fd9a4038dd83f0 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 18 May 2026 10:58:46 -0700 Subject: [PATCH 17/19] fix: address review feedback on use-cache probe 1. Probe argument encoding now uses RSC encodeReply instead of JSON.stringify. The wire-format mirrors Next.js EncodedArgumentsForProbe (string | formdata with base64-encoded blobs). The probe runner decodes via decodeReply so thenable params/searchParams survive the round-trip, eliminating false- positive deadlocks. 2. Replaced process-global setInsideUseCacheProbe with request-scoped UnifiedRequestContext._probeDepth. The main request has depth 0 and schedules probes normally; the probe re-execution sets depth 1 on the isolated runner's context, preventing recursive probes. Concurrent requests no longer interfere with each other's probe scheduling. 3. Updated tests to use request context instead of the deprecated global flag. --- .../vinext/src/server/use-cache-probe-pool.ts | 66 ++++++-- packages/vinext/src/shims/cache-runtime.ts | 143 ++++++++++++------ .../src/shims/unified-request-context.ts | 10 ++ .../src/shims/use-cache-probe-globals.ts | 37 ++++- tests/shims.test.ts | 17 +-- 5 files changed, 193 insertions(+), 80 deletions(-) diff --git a/packages/vinext/src/server/use-cache-probe-pool.ts b/packages/vinext/src/server/use-cache-probe-pool.ts index 4d48fe04b..209e45962 100644 --- a/packages/vinext/src/server/use-cache-probe-pool.ts +++ b/packages/vinext/src/server/use-cache-probe-pool.ts @@ -20,7 +20,8 @@ import type { DevEnvironment } from "vite"; import { createDirectRunner, type DevEnvironmentLike } from "./dev-module-runner.js"; import type { ModuleRunner } from "vite/module-runner"; -import { setUseCacheProbe, setInsideUseCacheProbe } from "vinext/shims/use-cache-probe-globals"; +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; @@ -56,7 +57,6 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir const deadline = performance.now() + timeoutMs; let probeTimeoutTimer: ReturnType | undefined; - setInsideUseCacheProbe(true); try { // Import the cache-runtime shim in the isolated runner. // The shim's registerCachedFunction will create fresh module-scope state. @@ -100,20 +100,17 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir variant, ); - // Decode the arguments (simple JSON fallback; RSC encodeReply is - // not available in the probe because we lack the client environment). - // For deadlock detection, the exact argument values matter less than - // the fact that the function body executes with a fresh module scope. - let args: unknown[] = []; - try { - args = JSON.parse(encodedArguments); - if (!Array.isArray(args)) args = [args]; - } catch { - args = []; + // 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) { @@ -141,7 +138,6 @@ export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvir // fill is still stuck, this is evidence of a deadlock on shared state. return true; } finally { - setInsideUseCacheProbe(false); if (probeTimeoutTimer !== undefined) clearTimeout(probeTimeoutTimer); runner.close().catch(() => {}); } @@ -157,6 +153,49 @@ export function tearDownUseCacheProbePool(): void { 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. @@ -192,6 +231,7 @@ async function runWithProbeRequestStore( 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 28876b716..e7882738b 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, @@ -121,7 +122,6 @@ 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; - isInsideUseCacheProbe: typeof import("./use-cache-probe-globals.js").isInsideUseCacheProbe; }; // Lazy singleton for dev-only probe modules — eliminates microtask @@ -138,7 +138,6 @@ async function loadProbeModules(): Promise { UseCacheTimeoutError: errors.UseCacheTimeoutError, UseCacheDeadlockError: errors.UseCacheDeadlockError, getUseCacheProbe: globals.getUseCacheProbe, - isInsideUseCacheProbe: globals.isInsideUseCacheProbe, }; } @@ -291,6 +290,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 @@ -443,26 +475,19 @@ export function registerCachedFunction Promise | undefined; let timeoutTimer: ReturnType | undefined; let probePromise: Promise | null = null; const probe = getUseCacheProbe(); - if (probe && !isInsideUseCacheProbe()) { - // Capture the current request store snapshot for the probe. - // getRequestContext() never returns null — it creates a default - // context when called outside a request scope. For deadlock detection, - // the exact headers/pathname matter less than the function body - // executing with a fresh module scope, so the default "/" pathname - // and empty headers are acceptable. - const requestCtx = getRequestContext(); + // Request-scoped guard: only schedule a probe from the outer + // request (_probeDepth === 0), not from inside a probe re-execution + // itself. This replaces the deprecated process-global counter which + // caused cross-request interference. + const requestCtx = getRequestContext(); + if (probe && (requestCtx._probeDepth ?? 0) === 0) { const headers = requestCtx.headersContext?.headers; const navCtx = requestCtx.serverContext; const requestSnapshot = { @@ -477,39 +502,59 @@ export function registerCachedFunction Promise, }; - probePromise = new Promise((_, reject) => { - probeTimer = setTimeout(() => { - const probeInternalTimeoutMs = fillDeadlineAt - performance.now() - 1_000; - if (probeInternalTimeoutMs <= 0) return; - - probe({ - id, - encodedArguments: (() => { - try { - return JSON.stringify(args); - } catch { - return "[]"; - } - })(), - 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(() => {}); + // 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) { + const tempRefs = rsc.createClientTemporaryReferenceSet(); + const processedArgs = args.length > 0 ? (unwrapThenableObjects(args) as unknown[]) : []; + const encoded = await rsc.encodeReply(processedArgs, { + 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) => { + 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) => { 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-probe-globals.ts b/packages/vinext/src/shims/use-cache-probe-globals.ts index 39b09e851..78f476c28 100644 --- a/packages/vinext/src/shims/use-cache-probe-globals.ts +++ b/packages/vinext/src/shims/use-cache-probe-globals.ts @@ -10,7 +10,11 @@ */ const SYMBOL = Symbol.for("vinext.dev.useCacheProbe"); -const INSIDE_PROBE_SYMBOL = Symbol.for("vinext.dev.useCacheProbe.inside"); + +// DEPRECATED: use UnifiedRequestContext._probeDepth instead. +// Kept for backwards compat so existing tests still compile. +// oxlint-disable-next-line no-unused-vars +const _INSIDE_PROBE_SYMBOL = Symbol.for("vinext.dev.useCacheProbe.inside"); export type UseCacheProbeRequestSnapshot = { headers: [string, string][]; @@ -19,9 +23,17 @@ export type UseCacheProbeRequestSnapshot = { 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: string; + encodedArguments: EncodedArgsForProbe; request: UseCacheProbeRequestSnapshot; timeoutMs: number; }) => Promise; @@ -34,13 +46,22 @@ export function getUseCacheProbe(): UseCacheProbe | undefined { return (globalThis as Record)[SYMBOL] as UseCacheProbe | undefined; } -export function setInsideUseCacheProbe(value: boolean): void { - const current = ((globalThis as Record)[INSIDE_PROBE_SYMBOL] as number) || 0; - (globalThis as Record)[INSIDE_PROBE_SYMBOL] = value - ? current + 1 - : Math.max(0, current - 1); +/** + * @deprecated Use `getRequestContext()._probeDepth` instead. + * Kept for backwards compatibility — now a no-op. + */ +export function setInsideUseCacheProbe(_value: boolean): void { + // globalThis-based counter is deprecated because concurrent requests + // share globalThis, causing cross-request interference. The real guard + // is UnifiedRequestContext._probeDepth. } +/** + * @deprecated Use `(getRequestContext()._probeDepth ?? 0) > 0` instead. + * Kept for backwards compatibility — always returns false. + */ export function isInsideUseCacheProbe(): boolean { - return (((globalThis as Record)[INSIDE_PROBE_SYMBOL] as number) || 0) > 0; + // globalThis-based counter is deprecated because concurrent requests + // share globalThis, causing cross-request interference. + return false; } diff --git a/tests/shims.test.ts b/tests/shims.test.ts index bde11f691..ff532ea0d 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -4208,8 +4208,8 @@ describe("use-cache errors", () => { }); describe("use-cache deadlock probe behavior", () => { - it("does not schedule probe when isInsideUseCacheProbe is true", async () => { - const { setUseCacheProbe, setInsideUseCacheProbe } = + 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; @@ -4219,27 +4219,24 @@ describe("use-cache deadlock probe behavior", () => { }); // Ensure dev mode path is taken by resetting modules and setting NODE_ENV. - // Note: vi.resetModules() re-evaluates cache-runtime.js with the new - // NODE_ENV, but probe-globals.js (imported above) is from the original - // module evaluation. Both use Symbol.for on globalThis, so they still - // share the same global state despite being different module instances. 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", ""); - setInsideUseCacheProbe(true); - let result: unknown; + // A probe re-execution sets _probeDepth === 1 on its request context. + const probeCtx = createRequestContext({ _probeDepth: 1 }); try { - result = await cached(); + const result = await runWithRequestContext(probeCtx, () => cached()); expect(result).toBe("result"); expect(probeCalled).toBe(false); } finally { - setInsideUseCacheProbe(false); vi.unstubAllEnvs(); setUseCacheProbe(undefined); } From fe0d953ee34c002e1699927b3736cb95b9aff6ef Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 19 May 2026 08:56:17 -0700 Subject: [PATCH 18/19] fix(shims): address ask-bonk parity review (#2, #3) - Add TODO documenting flat 10s timeout parity gap vs Next.js idle-stream monitoring - Skip redundant dev timeout/probe machinery when inside probe re-execution (_probeDepth > 0); probe pool already has its own Promise.race timeout - Remove dead deprecated code: _INSIDE_PROBE_SYMBOL, setInsideUseCacheProbe, isInsideUseCacheProbe -- never imported anywhere in the codebase --- packages/vinext/src/shims/cache-runtime.ts | 14 ++++++++++- .../src/shims/use-cache-probe-globals.ts | 25 ------------------- 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index e7882738b..1ea71b89c 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -487,7 +487,13 @@ export function registerCachedFunction Promise 0) { + // Inside a probe re-execution — skip the dev timeout/probe machinery. + // The probe pool already has its own internal timeout via Promise.race. + return executeWithContext(fn, args, cacheVariant); + } + + if (probe) { const headers = requestCtx.headersContext?.headers; const navCtx = requestCtx.serverContext; const requestSnapshot = { @@ -528,6 +534,12 @@ export function registerCachedFunction 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; diff --git a/packages/vinext/src/shims/use-cache-probe-globals.ts b/packages/vinext/src/shims/use-cache-probe-globals.ts index 78f476c28..6bcfb1dfc 100644 --- a/packages/vinext/src/shims/use-cache-probe-globals.ts +++ b/packages/vinext/src/shims/use-cache-probe-globals.ts @@ -11,11 +11,6 @@ const SYMBOL = Symbol.for("vinext.dev.useCacheProbe"); -// DEPRECATED: use UnifiedRequestContext._probeDepth instead. -// Kept for backwards compat so existing tests still compile. -// oxlint-disable-next-line no-unused-vars -const _INSIDE_PROBE_SYMBOL = Symbol.for("vinext.dev.useCacheProbe.inside"); - export type UseCacheProbeRequestSnapshot = { headers: [string, string][]; urlPathname: string; @@ -45,23 +40,3 @@ export function setUseCacheProbe(fn: UseCacheProbe | undefined): void { export function getUseCacheProbe(): UseCacheProbe | undefined { return (globalThis as Record)[SYMBOL] as UseCacheProbe | undefined; } - -/** - * @deprecated Use `getRequestContext()._probeDepth` instead. - * Kept for backwards compatibility — now a no-op. - */ -export function setInsideUseCacheProbe(_value: boolean): void { - // globalThis-based counter is deprecated because concurrent requests - // share globalThis, causing cross-request interference. The real guard - // is UnifiedRequestContext._probeDepth. -} - -/** - * @deprecated Use `(getRequestContext()._probeDepth ?? 0) > 0` instead. - * Kept for backwards compatibility — always returns false. - */ -export function isInsideUseCacheProbe(): boolean { - // globalThis-based counter is deprecated because concurrent requests - // share globalThis, causing cross-request interference. - return false; -} From 9d71da416bf6a2311c9f4f35cce993d4e0e1375a Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 19 May 2026 16:50:52 -0700 Subject: [PATCH 19/19] fix(shims): address probe review retry path --- packages/vinext/src/index.ts | 5 ++++- packages/vinext/src/shims/cache-runtime.ts | 20 +++++++++----------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 89a93c211..30f0b4b84 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -152,7 +152,10 @@ const __dirname = import.meta.dirname; let _probePoolModulePromise: Promise | null = null; function getProbePoolModule(): Promise { - _probePoolModulePromise ??= import("./server/use-cache-probe-pool.js"); + _probePoolModulePromise ??= import("./server/use-cache-probe-pool.js").catch((error) => { + _probePoolModulePromise = null; + throw error; + }); return _probePoolModulePromise; } diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 1ea71b89c..ec1f8b679 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -471,6 +471,15 @@ export function registerCachedFunction Promise 0) { + return executeWithContext(fn, args, cacheVariant); + } + let probeModules = _probeModules; if (!probeModules) { probeModules = await getProbeModules(); @@ -482,17 +491,6 @@ export function registerCachedFunction Promise | null = null; const probe = getUseCacheProbe(); - // Request-scoped guard: only schedule a probe from the outer - // request (_probeDepth === 0), not from inside a probe re-execution - // itself. This replaces the deprecated process-global counter which - // caused cross-request interference. - const requestCtx = getRequestContext(); - if ((requestCtx._probeDepth ?? 0) > 0) { - // Inside a probe re-execution — skip the dev timeout/probe machinery. - // The probe pool already has its own internal timeout via Promise.race. - return executeWithContext(fn, args, cacheVariant); - } - if (probe) { const headers = requestCtx.headersContext?.headers; const navCtx = requestCtx.serverContext;