Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
46 changes: 42 additions & 4 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,19 @@ function createRscCompatibilityId(nextConfig: ResolvedNextConfig): string {
type ASTNode = ReturnType<typeof parseAst>["body"][number]["parent"];

const __dirname = import.meta.dirname;

// Lazy load the use-cache probe pool so it is only loaded when the dev
// server starts (configureServer), not during production builds.
let _probePoolModulePromise: Promise<typeof import("./server/use-cache-probe-pool.js")> | null =
null;
function getProbePoolModule(): Promise<typeof import("./server/use-cache-probe-pool.js")> {
_probePoolModulePromise ??= import("./server/use-cache-probe-pool.js").catch((error) => {
_probePoolModulePromise = null;
throw error;
});
return _probePoolModulePromise;
}

type VitePluginReactModule = typeof import("@vitejs/plugin-react");

function resolveOptionalDependency(projectRoot: string, specifier: string): string | null {
Expand Down Expand Up @@ -2381,7 +2394,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
}
},

configureServer(server: ViteDevServer) {
async configureServer(server: ViteDevServer) {
// Watch route files for additions/removals to invalidate route cache.
const pageExtensions = fileMatcher.extensionRegex;

Expand Down Expand Up @@ -2439,10 +2452,19 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
}
}

function invalidateAppRoutingModules() {
async function invalidateAppRoutingModules() {
invalidateAppRouteCache();
invalidateRscEntryModule();
invalidateRootParamsModule();
const { tearDownUseCacheProbePool, initUseCacheProbePool } = await getProbePoolModule();
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.

Latency on HMR invalidation. invalidateAppRoutingModules is now async because it awaits getProbePoolModule(). The three synchronous invalidation calls above (lines 2064-2066) run immediately, but the probe teardown+reinit is deferred to the next microtask. This means there's a brief window where routes are invalidated but the old probe closure is still active.

In practice this is fine — the old probe closure captured env locally and will still work correctly if it fires during this window. Just noting the ordering for future readers.

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 await getProbePoolModule() here makes invalidateAppRoutingModules async, but the three synchronous invalidation calls above (lines 2064-2066) still run immediately. After the first call, _probePoolModule is cached so the await resolves in one tick. The ordering is safe.

One subtlety: if getProbePoolModule() rejects (e.g., import error), the tearDownUseCacheProbePool() call is skipped and the old probe closure remains active. The .catch() handler at the call sites (lines 2095, 2105) will log the error but the stale probe closure could fire on the next deadlocked call with potentially outdated module references. In practice, if the dynamic import fails once, it'll likely fail on every subsequent HMR cycle too, so this is a terminal failure mode anyway.

Non-blocking.

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: invalidateAppRoutingModules is now async, but the three synchronous invalidation calls above (lines 2225-2227) still execute synchronously and immediately. The probe teardown/reinit is deferred to after getProbePoolModule() resolves. This ordering is fine — the synchronous invalidations don't depend on the probe pool, and after the first call _probePoolModulePromise is cached so the await resolves in one tick.

One subtlety worth noting for future maintainers: if getProbePoolModule() rejects (e.g., import error), tearDownUseCacheProbePool() is skipped and the old probe closure remains active. The .catch() handler at the call sites (lines 2256, 2266) will log the error, which is sufficient for a dev-only diagnostic.

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.

Note: making invalidateAppRoutingModules async changes error handling for the synchronous calls. The three calls on lines 2225-2227 (invalidateAppRouteCache, invalidateRscEntryModule, invalidateRootParamsModule) are synchronous. Previously, if they threw, the error would propagate to the watcher callback. Now that this function is async, a throw from any of them becomes a rejected promise, caught only by the .catch() at the call sites (lines 2256, 2266).

This is fine since the call sites do log the error, but the behavior change is worth being aware of — previously a throw from invalidateAppRouteCache would crash the watcher handler (visible in the console); now it's a console.warn.

// 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.

// Re-initialize so probes continue working after HMR.
const rscEnv = server.environments["rsc"];
if (rscEnv) {
initUseCacheProbePool(rscEnv);
}
Comment on lines +2455 to +2467
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.

One thing to watch: if getProbePoolModule() rejects (transient import failure), the catch((err) => ...) at the call sites logs a warning but tearDownUseCacheProbePool() is never called. The old probe closure from before the HMR cycle remains active on globalThis. This means the old closure (which captured env locally) will still fire for the next deadlocked call.

This is actually fine — the captured env is the same object, and the old probe closure creates fresh runners per probe, so there's no stale-code issue. But the initUseCacheProbePool guard (line 36) means the next successful invalidateAppRoutingModules will see _probeEnvironment still set from the previous init and skip re-initialization. The teardown from that cycle will null it and the subsequent init will proceed, so it self-corrects after one extra cycle.

Non-blocking — the retry-on-error pattern in getProbePoolModule() (line 155-157) handles the transient case correctly.

}

let appRouteTypeGeneration: Promise<void> | null = null;
Expand Down Expand Up @@ -2498,7 +2520,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
invalidateRouteCache(pagesDir);
}
if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) {
invalidateAppRoutingModules();
invalidateAppRoutingModules().catch((err) => {
console.warn("[vinext] Failed to invalidate app routing modules:", err);
});
regenerateAppRouteTypes();
}
});
Expand All @@ -2507,7 +2531,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
invalidateRouteCache(pagesDir);
}
if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) {
invalidateAppRoutingModules();
invalidateAppRoutingModules().catch((err) => {
console.warn("[vinext] Failed to invalidate app routing modules:", err);
});
regenerateAppRouteTypes();
}
});
Expand Down Expand Up @@ -2574,6 +2600,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// it is flushed to the client.
// 2. Logs the full request after res finishes, using those timings.
if (hasAppDir) {
// Initialize the "use cache" deadlock probe pool for the App
// Router dev server. We bind to the RSC environment because
// "use cache" functions run inside the RSC module graph.
const rscEnv = server.environments["rsc"];
if (rscEnv) {
getProbePoolModule()
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: this is fire-and-forget, so the first request arriving before the probe pool finishes initializing will silently skip deadlock detection. This is fine for a dev-only diagnostic — just noting it for awareness. The error logging in .catch() is the right pattern here (good fix from a prior round).

.then(({ initUseCacheProbePool }) => initUseCacheProbePool(rscEnv))
.catch((err) => {
console.warn("[vinext] Failed to initialize use-cache probe pool:", err);
});
}

server.middlewares.use((req, res, next) => {
const url = req.url ?? "/";
// Skip Vite internals, HMR, and static assets.
Expand Down
2 changes: 1 addition & 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
238 changes: 238 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,238 @@
/**
* use-cache-probe-pool.ts
*
* Manages isolated ModuleRunners for "use cache" deadlock probes.
*
* In dev mode, when a cache fill appears stuck, we re-run the same cache
* function in a fresh module graph. If it completes there but the main fill
* is still hung, the hang is attributable to module-scope shared state
* (e.g. a top-level Map used to dedupe fetches) from the outer render.
*
* Unlike Next.js (which uses jest-worker with real OS processes), vinext
* creates a fresh Vite ModuleRunner per probe. Each runner has its own
* EvaluatedModules instance, so top-level module state is recreated from
* scratch while still using the same Vite transform pipeline.
*
* The pool is torn down on HMR / file invalidation so the next probe
* starts with fresh transformed code.
*/

import type { DevEnvironment } from "vite";
import { createDirectRunner, type DevEnvironmentLike } from "./dev-module-runner.js";
import type { ModuleRunner } from "vite/module-runner";
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: ModuleRunner on line 22 is import type, which avoids pulling the module at runtime. The DevEnvironment import on line 20 is also import type. Both are correct.

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

let _probeEnvironment: DevEnvironmentLike | DevEnvironment | null = null;

/**
* Initialize the probe pool with the Vite dev environment.
*
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 during configureServer() when the App Router dev server starts,
* and re-called after each HMR teardown cycle.
*/
export function initUseCacheProbePool(environment: DevEnvironmentLike | DevEnvironment): void {
if (_probeEnvironment) {
// Guard against double-init within the same cycle (e.g., if
// initUseCacheProbePool is called without a preceding teardown).
return;
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;

// Capture the environment in a local variable so the probe closure is
// immune to HMR teardown setting _probeEnvironment = null mid-flight.
const env = 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.

Good: Capturing env in a local variable at closure creation time makes the probe immune to tearDownUseCacheProbePool() nulling _probeEnvironment mid-flight during HMR invalidation.

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. Capturing env in a local variable makes the probe closure immune to tearDownUseCacheProbePool() nulling _probeEnvironment during HMR invalidation. This was a race condition identified in prior reviews and is correctly fixed.


setUseCacheProbe(async (msg) => {
// Create a fresh runner per probe so the module graph is completely
// isolated from previous probes. Reusing runners would leave stale
// top-level state in EvaluatedModules.
// createDirectRunner creates a fresh ModuleRunner with its own isolated
// EvaluatedModules instance, which is exactly what we need for probes.
const runner = createDirectRunner(env);
const { id, encodedArguments, request, timeoutMs } = msg;

// Internal timeout so the probe aborts before the outer render timeout.
const deadline = performance.now() + timeoutMs;
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: performance.now() is the right choice here — consistent with the caller (cache-runtime.ts:428 uses performance.now() for fillDeadlineAt). The monotonic clock is also correct for timeout arithmetic (immune to wall-clock adjustments). Good fix from the prior round's Date.now() feedback.


let probeTimeoutTimer: ReturnType<typeof setTimeout> | undefined;
try {
// Import the cache-runtime shim in the isolated runner.
// The shim's registerCachedFunction will create fresh module-scope state.
const cacheRuntime = (await runner.import("vinext/shims/cache-runtime")) as Record<
string,
unknown
>;
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.
// NOTE: This assumes export names don't contain colons.
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 = ""; // Only shared caches reach the probe block.
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: the comment is fine, but the kind field in the incoming msg is never read here (the variant is hardcoded). Consider removing kind from UseCacheProbe message type entirely — it would simplify both the caller in cache-runtime.ts and this consumer.

Non-blocking.

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 nit: Since this is always "" (private caches return before the probe block in cache-runtime.ts), the comment is slightly misleading — it implies there's a conditional that was evaluated. Could be simplified to just const variant = ""; with no comment, or a one-liner like // Always "" — only shared caches reach the probe.

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: The kind field in the probe message (set in cache-runtime.ts:547) is always "" because private caches return at line 456 before reaching the isDev block. This variable faithfully reflects that, but the field still exists in the UseCacheProbe message type and is serialized/deserialized for no purpose.

Consider removing kind from the message type and this consumer in a follow-up. Not worth changing now, just noting for cleanup.

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 args via the probe runner's RSC decodeReply so
// thenable params/searchParams are reconstructed accurately.
const args = await decodeProbeArgs(runner, encodedArguments);
if (args === null) {
return false;
}

// Run the function with a reconstructed request store so private caches
// that read cookies()/headers()/draftMode() see the same values.
// Mark the context as _probeDepth === 1 so nested 'use cache' calls
// skip probe scheduling (mirrors Next.js useCacheProbeMode).
// Race against the internal timeout.
const remaining = deadline - performance.now();
if (remaining <= 0) {
return false;
}

await Promise.race([
runWithProbeRequestStore(runner, request, async () => wrapped(...args)),
new Promise<never>((_, reject) => {
probeTimeoutTimer = setTimeout(() => reject(new UseCacheTimeoutError()), remaining);
if (typeof (probeTimeoutTimer as NodeJS.Timeout).unref === "function") {
(probeTimeoutTimer 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 (err) {
// If the probe timed out, the result is inconclusive — the function
// might genuinely hang even in isolation.
if (err instanceof UseCacheTimeoutError) {
Comment on lines +131 to +134
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: error classification in catch. Distinguishing UseCacheTimeoutError (inconclusive → false) from other errors (function ran and threw → evidence of deadlock → true) is correct and matches the intended semantics.

return false;
}
// The function threw — it ran to completion (with an error). If the main
// fill is still stuck, this is evidence of a deadlock on shared state.
return true;
} finally {
if (probeTimeoutTimer !== undefined) clearTimeout(probeTimeoutTimer);
runner.close().catch(() => {});
}
});
}

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

/**
* Decode probe arguments from the wire-format `EncodedArgsForProbe` using
* the probe runner's own RSC `decodeReply` so thenable params/searchParams
* are reconstructed accurately. Mirrors Next.js `use-cache-probe-worker.ts`.
*/
async function decodeProbeArgs(
runner: ModuleRunner,
encoded: EncodedArgsForProbe,
): Promise<unknown[] | null> {
try {
const rsc = (await runner.import("@vitejs/plugin-rsc/react/rsc")) as {
decodeReply: (
data: string | FormData,
options?: { temporaryReferences?: unknown },
) => Promise<unknown[]>;
};

if (encoded.kind === "string") {
const decoded = await rsc.decodeReply(encoded.data, { temporaryReferences: undefined });
if (!Array.isArray(decoded)) return [decoded];
return decoded;
}

// formdata kind: reconstruct FormData from serialized entries.
const formData = new FormData();
for (const entry of encoded.entries) {
if (entry.length === 2 && typeof entry[1] === "string") {
formData.append(entry[0], entry[1]);
} else {
const blob = entry[1] as { kind: "blob"; bytes: string; type: string };
const bytes = Buffer.from(blob.bytes, "base64");
formData.append(entry[0], new File([bytes], "", { type: blob.type }));
}
}

const decoded = await rsc.decodeReply(formData, { temporaryReferences: undefined });
if (!Array.isArray(decoded)) return [decoded];
return decoded;
} catch {
return null;
}
}

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

return runWithRequestContext(ctx, fn);
}
Loading
Loading