Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6103e8c
feat(dev): detect 'use cache' module-scope deadlocks early in dev (#1…
Divkix May 11, 2026
f0ba0b9
ci: fix benchmarks/vinext tsconfig missing vinext path and remove red…
Divkix May 11, 2026
2c7dde8
chore: remove unused tinypool dependency causing knip failure
Divkix May 11, 2026
4b2c506
fix(tests): remove unused use-cache deadlock fixtures causing static …
Divkix May 11, 2026
5662e1a
fix(dev): address use-cache probe review issues
Divkix May 11, 2026
b4886d1
fix(use-cache): address all review issues for deadlock probe
Divkix May 11, 2026
b543d3a
fix(use-cache): address v3 review feedback on deadlock probe
Divkix May 11, 2026
c2e053f
fix(use-cache): address v4 review feedback on deadlock probe
Divkix May 11, 2026
445a0d0
fix(use-cache): address v5 review feedback on deadlock probe
Divkix May 11, 2026
41895ed
Merge branch 'main' into fix/issue-1009
Divkix May 12, 2026
fe5ac07
Merge branch 'main' into fix/issue-1009
Divkix May 12, 2026
9dc5254
refactor(server): use performance.now() in use-cache probe pool
Divkix May 12, 2026
bc998e6
fix(shims/cache-runtime): review round 6 cleanups for use-cache probe
Divkix May 12, 2026
0b10e4f
refactor(server): lazy-load use-cache probe pool and document variant…
Divkix May 12, 2026
3223445
fix(server): surface use-cache probe failures
Divkix May 14, 2026
f0a6c58
Merge branch 'main' into fix/issue-1009
Divkix May 14, 2026
f15c467
fix(shims): address use-cache probe review nits
Divkix May 14, 2026
73d4f15
Merge branch 'main' into fix/issue-1009
Divkix May 16, 2026
86678d8
fix(shims): address ask-bonk review nits for use-cache probe
Divkix May 16, 2026
242b27e
fix(tests): use vi.stubEnv instead of direct process.env.NODE_ENV ass…
Divkix May 16, 2026
42fb5cd
fix: address review feedback on use-cache probe
Divkix May 18, 2026
fe0d953
fix(shims): address ask-bonk parity review (#2, #3)
Divkix May 19, 2026
9d71da4
fix(shims): address probe review retry path
Divkix May 19, 2026
fea883d
Merge branch 'main' into fix/issue-1009
Divkix May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: static import loads probe pool at plugin registration time. initUseCacheProbePool and tearDownUseCacheProbePool are only used inside configureServer() (dev only). A dynamic import() inside configureServer would avoid loading use-cache-probe-pool.js (and its transitive imports of use-cache-probe-globals and use-cache-errors) during vite build.

Non-blocking — Vite plugins are dev-time artifacts and the module loading cost is trivial. But if bundle size of the plugin itself ever matters, this is low-hanging fruit.

import { generateRscEntry } from "./entries/app-rsc-entry.js";
import { generateSsrEntry } from "./entries/app-ssr-entry.js";
import { generateBrowserEntry } from "./entries/app-browser-entry.js";
Expand Down Expand Up @@ -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();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical: probe permanently disabled after first HMR. tearDownUseCacheProbePool() nulls _probeEnvironment and clears the probe function. But initUseCacheProbePool() is only called once (line 2146), not after each teardown. After the first file change, the probe is permanently gone.

Either re-initialize here:

Suggested change
tearDownUseCacheProbePool();
tearDownUseCacheProbePool();
// Re-initialize so probes continue working after HMR.
const rscEnv = server.environments["rsc"];
if (rscEnv) {
initUseCacheProbePool(rscEnv);
}

Or restructure tearDownUseCacheProbePool so it doesn't unset the probe function — since the environment reference is the same, you could just trust that fresh runners (created per-probe) already give you fresh code.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The teardown+reinit pattern looks correct now. tearDownUseCacheProbePool() nulls _probeEnvironment and clears the probe, then initUseCacheProbePool() sees _probeEnvironment === null, re-assigns it, and re-installs the probe closure.

One thing to watch: if invalidateAppRoutingModules() is called frequently during rapid saves, each call creates a new probe closure. The old closure is garbage-collected since setUseCacheProbe() overwrites the globalThis symbol. Any in-flight probe from the old closure will still hold a reference to _probeEnvironment via the closure capture (the _probeEnvironment! on line 47 of use-cache-probe-pool.ts). Since _probeEnvironment is reassigned to null then immediately back to the same rscEnv, this is fine — but if the environment could change, the captured null would be a problem.

}

// Node throws on unhandled 'error' events on sockets. When a browser
Expand Down Expand Up @@ -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.
Expand Down
50 changes: 49 additions & 1 deletion packages/vinext/src/server/dev-module-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moderate: exact duplicate of createDirectRunner. This function is identical to createDirectRunner above — same transport, same evaluator, same options. Each call already creates a fresh ModuleRunner with its own EvaluatedModules, so there's no need for a separate function.

Just reuse createDirectRunner in use-cache-probe-pool.ts. The JSDoc distinction ("for probes") can be a comment at the call site.

188 changes: 188 additions & 0 deletions packages/vinext/src/server/use-cache-probe-pool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/**
* use-cache-probe-pool.ts
*
* Manages isolated ModuleRunners for "use cache" deadlock probes.
*
* In dev mode, when a cache fill appears stuck, we re-run the same cache
* function in a fresh module graph. If it completes there but the main fill
* is still hung, the hang is attributable to module-scope shared state
* (e.g. a top-level Map used to dedupe fetches) from the outer render.
*
* Unlike Next.js (which uses jest-worker with real OS processes), vinext
* creates a fresh Vite ModuleRunner per probe. Each runner has its own
* EvaluatedModules instance, so top-level module state is recreated from
* scratch while still using the same Vite transform pipeline.
*
* The pool is torn down on HMR / file invalidation so the next probe
* starts with fresh transformed code.
*/

import type { DevEnvironment } from "vite";
import { createProbeRunner, type DevEnvironmentLike } from "./dev-module-runner.js";
import { ModuleRunner } from "vite/module-runner";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ModuleRunner is only used as a type annotation (line 152). Use a type-only import to avoid pulling the module at runtime:

Suggested change
import { ModuleRunner } from "vite/module-runner";
import type { ModuleRunner } from "vite/module-runner";

import { setUseCacheProbe } from "vinext/shims/use-cache-probe-globals";
import { UseCacheTimeoutError } from "vinext/shims/use-cache-errors";

let _probeEnvironment: DevEnvironmentLike | DevEnvironment | null = null;

/**
* Initialize the probe pool with the Vite dev environment.
*
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: JSDoc says "Called once during configureServer()" but the function is also called on every HMR file change (via invalidateAppRoutingModulestearDownUseCacheProbePool()initUseCacheProbePool()). The guard on line 34 correctly handles re-init, but the doc is misleading.

Suggested change
*
* Called during configureServer() when the App Router dev server starts,
* and re-called after each HMR teardown cycle.

* Called once during configureServer() when the App Router dev server starts.
*/
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.
return;
Comment on lines +36 to +39
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Misleading comment. This says "Already initialized — no-op" and "the environment is the same for the lifetime of the dev server", but invalidateAppRoutingModules() explicitly calls tearDownUseCacheProbePool() then initUseCacheProbePool() on every file change. The guard works correctly (teardown nulls _probeEnvironment, so reinit proceeds), but the comment is wrong about this being a one-time init.

Suggested change
if (_probeEnvironment) {
// Already initialized — no-op. The environment is the same for the
// lifetime of the dev server.
return;
// Guard against double-init within the same cycle (e.g., if
// initUseCacheProbePool is called without a preceding teardown).
return;

}
_probeEnvironment = 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.
const runner = createProbeRunner(_probeEnvironment!);
const { id, kind, encodedArguments, request, timeoutMs } = msg;

// Internal timeout so the probe aborts before the outer render timeout.
const deadline = Date.now() + timeoutMs;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Date.now() vs performance.now() inconsistency. The deadline here is computed with Date.now(), but the remaining-time check in cache-runtime.ts:443 uses performance.now() (fillDeadlineAt - performance.now() - 1_000). These two clocks have different origins — Date.now() is epoch-based, performance.now() is monotonic from process start. They can't be meaningfully subtracted from each other.

The deadline on this line is only used on line 121 as deadline - Date.now(), which is self-consistent. But if someone later tries to compare this deadline with fillDeadlineAt (which is performance.now()-based), it'll be wrong. Consider using performance.now() here too for consistency with the caller:

Suggested change
const deadline = Date.now() + timeoutMs;
const deadline = performance.now() + timeoutMs;

Then line 121 becomes deadline - performance.now(). The monotonic clock is also more correct for timeout math (immune to wall-clock adjustments).


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
>;
Comment on lines +60 to +66
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The runner.import("vinext/shims/cache-runtime") call loads the full cache-runtime module in the isolated graph — including the isDev check, the getProbeModules() lazy loader, and the 54s timeout setup. When the probe subsequently calls wrapped(...args) at line 121, the wrapped function enters cachedFn in the probe runner's cache-runtime. Since the probe runner also runs with NODE_ENV=development, it enters the if (isDev) block — but the _probeDepth guard at line 479 correctly short-circuits to executeWithContext before any timer allocation.

This works correctly because the _probeDepth check (line 479) happens before module loading and timer setup in the probe's cachedFn. Good — this addresses the "double dev-timeout" concern from earlier review rounds.

const registerCachedFunction = cacheRuntime.registerCachedFunction as
| (<T extends (...args: unknown[]) => Promise<unknown>>(
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 "<modulePath>:<exportName>". We split it
// to find the module and the export.
const lastColon = id.lastIndexOf(":");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edge case: function IDs containing colons in the module path. id.lastIndexOf(":") works for most Unix paths, but on Windows, absolute paths contain colons (e.g., C:\foo\bar.ts:myExport). lastIndexOf(":") would split at the right colon here, which is correct. But if the export name itself contains a colon (unlikely but not impossible with computed exports), this would split incorrectly.

This is fine for now — just flagging it as a known limitation. A comment noting the assumption ("export names don't contain colons") would be helpful.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lastIndexOf(":") approach for splitting "<modulePath>:<exportName>" works correctly for Unix paths and even Windows paths (where C:\ has a colon before the backslash — lastIndexOf finds the export separator, not the drive letter).

The comment on line 81 documents the assumption that export names don't contain colons. Worth noting that on Windows, module IDs like C:/foo/bar.ts:myExport would also work correctly since lastIndexOf returns the rightmost colon. Good.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Windows path edge case. On Windows, Vite module IDs use forward slashes internally, so C:/foo/bar.ts:myExport would split correctly at the rightmost colon. But if someone uses a non-standard module ID format or the transform emits colons in the path (e.g., virtual modules like virtual:vinext-foo:bar), lastIndexOf(":") would split at the wrong boundary.

Not blocking — the assumption is reasonable for file-system modules. But consider adding a brief comment noting this relies on the ID format being <path>:<export> with no colons in the export name.

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<string, unknown>;
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" : "";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead code. kind can never be "private" here because private cache functions (cacheVariant === "private") return at cache-runtime.ts:396 before reaching the isDev probe-scheduling block at line 401. The probe is never scheduled for private caches.

This should either be removed (simplify to const variant = "";) or documented with a comment explaining it's a safety net for future changes.

const wrapped = registerCachedFunction(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When wrapped(...args) is called, the probe runner's cache-runtime evaluates with NODE_ENV=development and enters the if (isDev) block. The _probeDepth: 1 guard prevents nested probe scheduling, but the inner 54s timeout timer and probe module loading still execute (they run before the _probeDepth check in the current code).

If the _probeDepth check is moved above the module loading / timeout setup (see my comment on cache-runtime.ts), this becomes a non-issue — the probe's inner cachedFn will early-return via executeWithContext without any timer allocation.

If that reordering isn't done, consider adding a brief comment here noting the double-timeout is expected and harmless (outer pool timeout preempts inner).

originalFn as (...args: unknown[]) => Promise<unknown>,
id,
variant,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double dev-timeout in probe. wrapped(...) enters cachedFn in the probe runner's cache-runtime, which (since NODE_ENV=development) enters the if (isDev) block and sets up another 54s timeout + probe module loading. The _probeDepth: 1 guard correctly prevents a nested probe, but the 54s timer still runs unnecessarily.

Next.js avoids this: when workStore.useCacheProbeMode is set, the wrapper takes a different code path that skips the dev-timeout/probe setup entirely.

Consider either:

  1. Checking _probeDepth > 0 earlier (before the timeout setup, not just before probe scheduling), or
  2. Adding a TODO noting this is a known overhead.

Not blocking since the outer probe pool timeout preempts the inner one, but it's wasteful.

);

// 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.
// Race against the internal timeout.
const remaining = deadline - Date.now();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the suggestion to use performance.now() for the deadline is accepted, this line should also change:

Suggested change
const remaining = deadline - Date.now();
const remaining = performance.now() >= deadline ? 0 : deadline - performance.now();

Or more simply:

const remaining = deadline - performance.now();

since the <= 0 check on the next line already handles the expired case.

if (remaining <= 0) {
return false;
}

await Promise.race([
runWithProbeRequestStore(runner, request, async () => wrapped(...args)),
new Promise<never>((_, 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();
}
}),
]);
Comment on lines +110 to +128
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant await + race: runWithProbeRequestStore is awaited on line 123, which means result is already a resolved value by line 125. The Promise.race on lines 132-141 is racing an already-resolved Promise.resolve(result) against a timeout — the resolved promise always wins instantly. This timeout never fires.

The timeout should wrap the un-awaited call:

const result = await Promise.race([
  runWithProbeRequestStore(request, async () => wrapped(...args)),
  new Promise<never>((_, reject) => {
    const t = setTimeout(() => reject(new UseCacheTimeoutError()), remaining);
    if (typeof t.unref === 'function') t.unref();
  }),
]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor timer leak inside the probe. When runWithProbeRequestStore resolves successfully, the Promise.race completes but the timeout setTimeout on line 121 is never cleared — it's only .unref()'d. The timer callback will fire after remaining ms and create a UseCacheTimeoutError that rejects into a detached promise (the catch on line 130 won't see it since execution already left the try block via the return true on line 129).

The .unref() prevents it from keeping the process alive, so this is not a blocker, but it's wasteful and will produce an unhandled rejection in some Node versions. Store the timer ID and clear it in the finally block:

Suggested change
]);
let probeTimeoutTimer: ReturnType<typeof setTimeout> | undefined;
await Promise.race([
runWithProbeRequestStore(runner, request, async () => wrapped(...args)),
new Promise<never>((_, reject) => {
probeTimeoutTimer = setTimeout(() => reject(new UseCacheTimeoutError()), remaining);
if (typeof (probeTimeoutTimer as NodeJS.Timeout).unref === "function") {
(probeTimeoutTimer as NodeJS.Timeout).unref();
}
}),
]);

Then in the finally block:

if (probeTimeoutTimer !== undefined) clearTimeout(probeTimeoutTimer);


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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A function that throws is not deadlocked. The catch here handles two distinct failure modes identically:

  1. The probe's internal timeout fires (UseCacheTimeoutError) — genuinely inconclusive, false is correct.
  2. The wrapped function throws an error — the function actually ran and completed (with a failure). If the main fill is still stuck but the probe's copy threw, that's evidence the main fill is deadlocked on shared state, not that the function itself can't run.

Consider distinguishing these:

Suggested change
return false;
} catch (err) {
// If the probe timed out, the result is inconclusive.
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.
return true;

} finally {
runner.close().catch(() => {});
}
});
}

/**
* Tear down the probe pool. Called on HMR / file invalidation so the next
* probe starts with fresh code.
*/
export function tearDownUseCacheProbePool(): void {
_probeEnvironment = null;
setUseCacheProbe(undefined);
}

/**
* Reconstruct a minimal request store in the probe runner so that
* cookies(), headers(), and draftMode() behave correctly.
*/
async function runWithProbeRequestStore<T>(
runner: ModuleRunner,
requestSnapshot: {
headers: [string, string][];
cookieHeader: string | undefined;
urlPathname: string;
urlSearch: string;
rootParams: Record<string, unknown>;
isDraftMode: boolean;
isHmrRefresh: boolean;
},
fn: () => Promise<T>,
): Promise<T> {
// 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<string, string | string[]>,
});

return runWithRequestContext(ctx, fn);
}
71 changes: 70 additions & 1 deletion packages/vinext/src/shims/cache-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import {
getRequestContext,
runWithUnifiedStateMutation,
} from "./unified-request-context.js";
import { UseCacheTimeoutError, UseCacheDeadlockError } from "./use-cache-errors.js";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Production bundle size concern. UseCacheTimeoutError, UseCacheDeadlockError, getUseCacheProbe, and isInsideUseCacheProbe are unconditionally imported at the top of cache-runtime.ts. These are only used inside the if (isDev) branch (line 407), but since isDev is a runtime check (process.env.NODE_ENV === "development"), bundlers cannot tree-shake these imports — they'll be included in production builds.

The PR description says "tree-shakes out of production builds" but that's only true if the bundler can statically determine the isDev branch is dead. Vite's production build does replace process.env.NODE_ENV with "production" in some contexts, but this depends on the build pipeline and environment.

Consider either:

  1. Using a dynamic import() inside the isDev branch so the modules are only loaded at runtime in dev, or
  2. Verifying that vinext's production build pipeline actually eliminates the dead isDev branch (and document this assumption)

import { getUseCacheProbe, type UseCacheProbeRequestSnapshot } from "./use-cache-probe-globals.js";

// ---------------------------------------------------------------------------
// Cache execution context — AsyncLocalStorage for cacheLife/cacheTag
Expand Down Expand Up @@ -399,7 +401,74 @@ export function registerCachedFunction<T extends (...args: any[]) => Promise<any
// In dev mode, always execute fresh — skip shared cache lookup/storage.
// This ensures HMR changes are reflected immediately.
if (isDev) {
return executeWithContext(fn, args, cacheVariant);
const USE_CACHE_TIMEOUT_MS = 54_000;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded timeout. Next.js reads experimental.useCacheTimeout (configurable in next.config.js). 54s matches the Next.js default (staticPageGenerationTimeout * 0.9 = 60 * 0.9), so this is correct for now. Worth a TODO for when vinext adds config support:

Suggested change
const USE_CACHE_TIMEOUT_MS = 54_000;
const USE_CACHE_TIMEOUT_MS = 54_000; // TODO: read from vinext config (Next.js: experimental.useCacheTimeout)

const fillDeadlineAt = performance.now() + USE_CACHE_TIMEOUT_MS;

const timeoutError = new UseCacheTimeoutError();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-allocating error objects is unusual and slightly wasteful. timeoutError and deadlockError are constructed eagerly on every "use cache" invocation in dev mode, but most invocations complete quickly and never use either error. These constructors capture a stack trace (Error.captureStackTrace), which has real cost.

Consider constructing them lazily — only when the timeout or deadlock actually fires:

// In the setTimeout callback:
reject(new UseCacheTimeoutError());

// In the probe .then callback:
reject(new UseCacheDeadlockError());

The downside is that the stack trace points to the timer callback instead of the registerCachedFunction call site, which is arguably less useful anyway (the call site context is in id). The upside is avoiding stack trace capture on every cache call.

Non-blocking, but worth considering for dev perf if "use cache" is called frequently.

const deadlockError = new UseCacheDeadlockError();

let probeTimer: ReturnType<typeof setTimeout> | undefined;
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
let probePromise: Promise<never> | null = null;
const probe = getUseCacheProbe();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical: recursive probe via shared globalThis. getUseCacheProbe() reads from globalThis[Symbol.for(...)], which is shared across all ModuleRunner instances. When the probe runner re-evaluates cache-runtime.ts and calls registerCachedFunctiongetUseCacheProbe(), it sees the same probe function and schedules another 10s timer, spawning yet another ModuleRunner.

The recursion eventually terminates because probeInternalTimeoutMs shrinks to ≤0, but this creates unnecessary runners and timers.

Fix: guard against recursive probing. Simplest approach — add a flag:

let _insideProbe = false;

and in the probe callback (use-cache-probe-pool.ts), set _insideProbe = true before calling the wrapped function (or temporarily unset the probe on globalThis).


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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cookieHeader is captured but never consumed. runWithProbeRequestStore reconstructs headers from the headers array (which already includes the cookie header entry). The cookieHeader field is never read. Either remove it from the snapshot or use it in the probe to set the cookie jar explicitly.

urlPathname: navCtx?.pathname ?? "/",
urlSearch: navCtx?.searchParams?.toString() ?? "",
rootParams: requestCtx.rootParams ?? {},
isDraftMode: false,
isHmrRefresh: false,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: isDraftMode and isHmrRefresh are hardcoded to false and never read from the actual request context. If these are intentionally unused, either remove them from UseCacheProbeRequestSnapshot or add a comment explaining why they're always false (e.g., "not relevant for deadlock detection").

};

probePromise = new Promise<never>((_, reject) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The probe timer fires even if execution completes normally, but the reject is harmless. When executionPromise wins the Promise.race at line 472, the .finally() at line 472-475 clears both timers. Since clearTimeout(probeTimer) runs synchronously after the race resolves, the 10s timer is cleared before it fires.

This is correct — the prior review's timer-leak concern has been addressed. The .finally() cleanup is the right pattern here.

probeTimer = setTimeout(() => {
const probeInternalTimeoutMs = fillDeadlineAt - performance.now() - 1_000;
if (probeInternalTimeoutMs <= 0) return;

probe({
id,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: id here always has cacheVariant as "" (never "private") because private caches return at line 424-433 before reaching this isDev block. The message shape carries the data faithfully, but if you ever want to simplify the probe interface, kind / cacheVariant can be dropped from the message since it's structurally always "".

kind: cacheVariant,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cacheVariant here is never "private" (private caches return at line 412-422 before reaching this block), and the probe pool ignores this field entirely (it hardcodes variant = ""). This is dead data — it's computed, serialized, and sent but never read.

Non-blocking, but removing it would simplify the message and avoid confusion.

encodedArguments: JSON.stringify(args),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: JSON.stringify(args) can throw. If args contains circular references or BigInts, this will throw and the entire cachedFn will reject — not just the probe scheduling. This is unlikely for typical "use cache" arguments, but wrapping in try/catch with a fallback (empty string or "[]") would be more defensive:

Suggested change
encodedArguments: JSON.stringify(args),
encodedArguments: (() => { try { return JSON.stringify(args); } catch { return "[]"; } })(),

Alternatively, move the serialization into a helper.

request: requestSnapshot,
timeoutMs: probeInternalTimeoutMs,
}).then(
(completed) => {
if (completed) reject(deadlockError);
},
() => {},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconclusive probe silently dangles. When completed is false (the probe also hung in isolation), nothing happens — probePromise has no resolve path and will never settle. The developer gets zero feedback until the 54s hard timeout fires.

This isn't a correctness bug (the timeout backstop works), but it defeats the purpose of early detection for the inconclusive case. At minimum, consider logging a dev-mode warning so the developer gets a signal before waiting 44 more seconds:

Suggested change
() => {},
.then(
(completed) => {
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.`,
);
}
},
() => {},
);

);
}, 10_000);
});
// Swallow rejection when execution wins the race.
probePromise.catch(() => {});
}

const timeoutPromise = new Promise<never>((_, reject) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good pattern. The 54s timeout promise is correctly .unref()'d so it doesn't keep the Node process alive, and the .catch(() => {}) on line 506 prevents unhandled rejections when executionPromise wins the race. The .finally() on line 514-517 clears both timers. This is tight — no leaks, no orphaned rejections.

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.
timeoutPromise.catch(() => {});

const executionPromise = executeWithContext(fn, args, cacheVariant);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

executeWithContext is started after the probe and timeout promises are constructed. This means if executeWithContext completes synchronously (or in a single microtask), the probePromise and timeoutPromise rejections become orphaned. The .catch(() => {}) handlers on lines 474 and 484 prevent unhandled rejection crashes, so this is safe — but it's worth noting that the order matters: if executeWithContext were started before the promises were set up, a fast return could resolve before the race is constructed.

The current order is correct. Just flagging it as a subtlety that a future refactor should preserve.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ordering note (for future maintainers): executionPromise is started after probePromise and timeoutPromise are constructed. This ordering is correct — if executeWithContext completed synchronously (or in a single microtask), Promise.race would still resolve with its value since it's first in the promises array (line 511). The .catch(() => {}) handlers on lines 497 and 507 prevent unhandled rejections from the losing promises. Sound design.


const promises: Promise<unknown>[] = [executionPromise];
if (probePromise) promises.push(probePromise);
promises.push(timeoutPromise);

return Promise.race(promises).finally(() => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The promises array is typed Promise<unknown>[], which discards the generic return type of executionPromise. When executionPromise wins the race (the normal fast path), the resolved value is typed unknown instead of the function's actual return type.

This is masked today by the (...args: any[]) => Promise<any> signature on cachedFn, but it's a latent type hole. Consider either:

  1. Using a typed overload:
Suggested change
return Promise.race(promises).finally(() => {
return (Promise.race(promises) as Promise<Awaited<ReturnType<T>>>).finally(() => {
  1. Or typing the array to preserve the return type:
const promises: [Promise<Awaited<ReturnType<T>>>, ...Promise<never>[]] = [executionPromise];

Non-blocking since the outer signature is any, but worth cleaning up.

if (probeTimer !== undefined) clearTimeout(probeTimer);
if (timeoutTimer !== undefined) clearTimeout(timeoutTimer);
});
}

// Shared cache ("use cache" / "use cache: remote")
Expand Down
Loading
Loading