feat(dev): detect 'use cache' module-scope deadlocks early in dev (#1126)#1157
feat(dev): detect 'use cache' module-scope deadlocks early in dev (#1126)#1157Divkix wants to merge 14 commits into
Conversation
…oudflare#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 cloudflare#1126.
commit: |
…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.
|
/bigbonk review |
There was a problem hiding this comment.
Review: use cache deadlock probe
The overall direction — detect module-scope deadlocks in dev by re-running the cache function in an isolated ModuleRunner — makes sense and mirrors the Next.js approach well. However, there are several issues that need to be addressed before this can merge.
Critical
-
runWithProbeRequestStoreuses nativeimport()instead ofrunner.import(). The entire point of the probe pool is that it runs in an isolated module graph. ButrunWithProbeRequestStore(lines 189-193 ofuse-cache-probe-pool.ts) uses nativeimport("vinext/shims/...")— which resolves to the main process's module instances. This means the ALS context, headers, and request context are set on the main runner's modules, not the probe runner's. The probe'sfn()runs with the main-runner's ALS, defeating the isolation guarantee. These imports should go through the probe runner viarunner.import(). -
use-cache-probe-scheduler.tsis dead code. It is never imported by any production code — only by the test file. The actual probe logic was inlined directly intocache-runtime.ts(lines 403-467). This 173-line file ships in the package for no reason. Either use it (by havingcache-runtime.tsdelegate to it) or remove it. -
Timer leak on successful execution. In
cache-runtime.ts, both the 10s probesetTimeout(line 429) and the 54s timeoutsetTimeout(line 452) are never cleared whenexecutionPromisewins thePromise.race. The 10s timer will still fire 10 seconds later and invoke the probe on an already-completed function. The 54s timer is.unref()'d so it won't keep the process alive, but the 10s probe timer is not unref'd and will fire unconditionally. Both timers should be cleared when execution completes.
Moderate
-
_environmentis stored but never read. The module-scope variable_environmentinuse-cache-probe-pool.tsis assigned on init (line 52) and nulled on teardown (line 164), but is never used anywhere. It should be removed. -
Redundant
awaitafterrunWithProbeRequestStorealready awaited. Inuse-cache-probe-pool.tslines 123-141,runWithProbeRequestStoreis awaited (assigning toresult), thenresultis passed throughPromise.resolve(result)in anotherPromise.race. If the function already completed viaawait, the secondPromise.racewith a timeout is a no-op —resultis already resolved. The timeout race should wrap the un-awaited call, or this entire section should be restructured. -
Round-robin pool reuses stale module state. The pool creates 4 runners at init and round-robins them. But the whole point is to get fresh module-scope state. The second time a runner is used, its evaluated modules cache already has the cached function's module loaded with the previous run's top-level state. Either create a fresh runner per probe (and dispose it afterward), or call
runner.close()+ recreate between uses. -
benchmarks/vinext/tsconfig.jsonchange is unrelated. Adding avinextpath mapping to the benchmarks tsconfig is not related to the deadlock probe feature. If it's needed for a separate reason, it should be a separate commit/PR.
Minor
-
Fixture pages were added then removed in follow-up commits. The PR description mentions
use-cache-deadlockanduse-cache-recoveryfixture pages but commit4b2c506removed them because they caused CI timeouts. This means the feature has no integration-level test coverage — only unit tests for the error classes and the scheduler's bail-out paths. The deadlock detection path itself (probe completes → deadlock error raised) has zero test coverage. -
Probe
encodedArgumentsusesJSON.stringifywhich loses non-serializable values. The probe falls back toJSON.stringify(args)(line 436 incache-runtime.ts, line 114 inuse-cache-probe-pool.ts). For the stated purpose ("exact argument values matter less than the fact that the function body executes"), this is acknowledged. But if the function reads its arguments (e.g., uses a passed-in object reference), the JSON round-trip could cause the probe to succeed where the real call fails (false positive deadlock detection). Worth documenting this limitation more explicitly.
| // 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"); |
There was a problem hiding this comment.
Critical: These import() calls use the host process's native module resolution — they load the main runner's module instances, not the probe runner's isolated graph. The comment says "These imports run inside the probe runner's module graph" but that's not what happens with native import().
To actually run in the probe's isolated graph, these would need to go through runner.import("vinext/shims/unified-request-context") and runner.import("vinext/shims/headers"). But the function doesn't have access to the runner — it would need to be passed in or this logic restructured.
As-is, the ALS context is set on the main runner's modules, so cookies()/headers() inside the probe function will read from the main runner's ALS, not the isolated one. This defeats the purpose of request-store reconstruction.
| probePromise = new Promise<never>((_, 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(() => {}); |
There was a problem hiding this comment.
Timer leak: When executionPromise wins the Promise.race (the normal fast path), this 10s setTimeout is never cleared. It will fire 10 seconds later and invoke the full probe machinery — creating a fresh ModuleRunner import, running the cached function again — on an already-completed call. This is wasteful and could cause confusing log output or errors.
The probePromise.catch(() => {}) swallows the rejection, but the probe itself still runs. You need to clear the timer when execution completes:
| probePromise = new Promise<never>((_, 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(() => {}); | |
| let probeTimer: ReturnType<typeof setTimeout> | undefined; | |
| probePromise = new Promise<never>((_, reject) => { | |
| probeTimer = 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(() => {}); |
Then after Promise.race resolves, clear both timers.
| import { UseCacheTimeoutError } from "vinext/shims/use-cache-errors"; | ||
|
|
||
| let _activeProbeRunners: ModuleRunner[] | null = null; | ||
| let _environment: DevEnvironmentLike | DevEnvironment | null = null; |
There was a problem hiding this comment.
Dead store: _environment is assigned on line 52 and nulled on line 164, but is never read anywhere. Remove it.
| // 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<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(); | ||
| } | ||
| }), | ||
| ]); |
There was a problem hiding this comment.
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();
}),
]);| 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; |
There was a problem hiding this comment.
Stale module state on reuse: The pool round-robins across 4 pre-created runners, but ModuleRunner caches evaluated modules internally. The second time a runner is used for the same function, the module's top-level scope has already been evaluated — the Maps, Promises, etc. that the probe is supposed to test with fresh state are already initialized from the previous probe run.
This means the first probe for a given function works correctly, but subsequent probes on the same runner reuse stale module state and may not detect the deadlock.
Either:
- Create a fresh runner per probe and
close()it after, or - Call
runner.close()and replace the runner in the pool after each use
| * | ||
| * Returns the input stream unchanged when scheduling should be skipped. | ||
| */ | ||
| export function setupProbeScheduler(opts: SetupOptions): ReadableStream<Uint8Array> { |
There was a problem hiding this comment.
Dead code: setupProbeScheduler is never imported by any production code. The probe logic was inlined directly into cache-runtime.ts instead. This entire 173-line file ships in the package but is only used in tests.
Either:
- Have
cache-runtime.tsdelegate to this scheduler (which has better structure — idle-stream tracking, mid-probe recovery via chunk monitoring), or - Remove this file entirely
| "paths": { | ||
| "@/*": ["./*"] | ||
| "@/*": ["./*"], | ||
| "vinext": ["../../packages/vinext/src/index.ts"] |
There was a problem hiding this comment.
This change is unrelated to the deadlock probe feature. If the benchmarks tsconfig needs a vinext path mapping, that should be a separate commit or PR.
| } | ||
|
|
||
| function getRequestCf(request: Request): unknown | undefined { | ||
| function getRequestCf(request: Request): unknown { |
There was a problem hiding this comment.
This type change (unknown | undefined → unknown) is unrelated to the deadlock probe feature. unknown | undefined does simplify to unknown so this is technically a no-op, but it should be in a separate commit.
|
Review submitted. Here's a summary of the findings: 3 critical issues:
4 moderate issues: 1 minor issue: |
- 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.
|
/bigbonk review |
There was a problem hiding this comment.
Review: use cache deadlock probe (v2)
Significant progress since the last round — the dead-code scheduler file was removed, unrelated changes cleaned up, the pool was changed from round-robin to fresh-runner-per-probe (good), and timer cleanup was added in the .finally() block. The overall architecture (fresh ModuleRunner per probe, Symbol.for on globalThis for cross-graph handoff) is sound.
However, there are still several issues that need fixing before this can merge.
Critical
-
Probe permanently disabled after first HMR invalidation.
tearDownUseCacheProbePool()nulls_probeEnvironmentand clears the probe function viasetUseCacheProbe(undefined). ButinitUseCacheProbePool()is only called once — inconfigureServer()— and is never called again after HMR. TheinvalidateAppRoutingModules()function callstearDownUseCacheProbePool()on every file change, so after the very first edit in dev mode, the probe is permanently gone. The comment says "the next probe starts with fresh code" but there's no code path to re-initialize. This needs to either (a) re-init ininvalidateAppRoutingModules()after teardown, or (b) restructure teardown to only clear the runners/state without unsetting the probe function (since the environment persists). -
Recursive probe via shared
globalThis.Symbol.for("vinext.dev.useCacheProbe")is visible to allModuleRunnerinstances in the same Node.js process. When the probe runner importscache-runtime.tsand callsregisterCachedFunction, that code path hits theisDevbranch, callsgetUseCacheProbe(), finds the probe function onglobalThis, and schedules another 10s probe timer — creating a recursive probe chain. The timeout arithmetic will eventually bail out (probeInternalTimeoutMs <= 0), but until then you're creating unnecessaryModuleRunnerinstances and timers. The probe runner's cache-runtime should not see the probe function. Either (a) unset the probe onglobalThisbefore calling the wrapped function and restore it after, or (b) add a flag/guard so the probe-within-probe path is skipped.
Moderate
-
createProbeRunneris an exact copy ofcreateDirectRunner. The two functions are identical — same transport, same evaluator, same options. The only difference is the JSDoc comment. Just reusecreateDirectRunner; the fact that each call creates a newModuleRunner(with its ownEvaluatedModules) already gives you isolation. There's no need for a separate function. -
ModuleRunnerimport is unused as a value. Line 22 importsModuleRunnerfromvite/module-runner, but it's only used as a type annotation on line 152 (runner: ModuleRunner). This should beimport type { ModuleRunner }to avoid pulling the module at runtime unnecessarily. -
Error classes don't call
super()with the rightname. NeitherUseCacheTimeoutErrornorUseCacheDeadlockErrorsetsthis.name. By default,Errorsubclasses inheritthis.name = "Error", so stack traces showError: Filling a cache...instead ofUseCacheTimeoutError: Filling a cache.... Addthis.name = "UseCacheTimeoutError"(or the constructor name) in each constructor.
Minor
-
No integration test coverage for the actual deadlock detection. The tests only cover the error classes (digest values, message content). The core behavior — probe completes → deadlock error raised, probe times out → timeout error raised, fast execution → no probe fires — has zero test coverage. The fixture pages were removed due to CI timeouts, which is understandable, but a unit-level test that mocks the probe function and verifies the
Promise.racebehavior incache-runtime.tswould be valuable and wouldn't have timeout issues. -
isDraftModeandisHmrRefreshare always hardcoded tofalse. Incache-runtime.tslines 426-427, both fields are hardcoded. If these are genuinely never needed for the probe, they should be removed fromUseCacheProbeRequestSnapshotto avoid confusion. If they might be needed later, add a TODO comment.
| invalidateRootParamsModule(); | ||
| // Tear down the use-cache probe pool so the next probe starts with | ||
| // fresh code after HMR invalidation. | ||
| tearDownUseCacheProbePool(); |
There was a problem hiding this comment.
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:
| 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.
| let probeTimer: ReturnType<typeof setTimeout> | undefined; | ||
| let timeoutTimer: ReturnType<typeof setTimeout> | undefined; | ||
| let probePromise: Promise<never> | null = null; | ||
| const probe = getUseCacheProbe(); |
There was a problem hiding this comment.
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 registerCachedFunction → getUseCacheProbe(), 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).
| 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(), | ||
| ); | ||
| } |
There was a problem hiding this comment.
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.
|
|
||
| import type { DevEnvironment } from "vite"; | ||
| import { createProbeRunner, type DevEnvironmentLike } from "./dev-module-runner.js"; | ||
| import { ModuleRunner } from "vite/module-runner"; |
There was a problem hiding this comment.
ModuleRunner is only used as a type annotation (line 152). Use a type-only import to avoid pulling the module at runtime:
| import { ModuleRunner } from "vite/module-runner"; | |
| import type { ModuleRunner } from "vite/module-runner"; |
|
|
||
| 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".', |
There was a problem hiding this comment.
Neither error class sets this.name. Stack traces will show Error: Filling a cache... instead of UseCacheTimeoutError: Filling a cache....
| '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".', | |
| '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'; |
Same for UseCacheDeadlockError.
| urlSearch: navCtx?.searchParams?.toString() ?? "", | ||
| rootParams: requestCtx.rootParams ?? {}, | ||
| isDraftMode: false, | ||
| isHmrRefresh: false, |
There was a problem hiding this comment.
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").
|
Review submitted. Here's a summary of the findings: 2 critical issues:
3 moderate issues: 2 minor issues: |
- 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
|
/bigbonk review |
There was a problem hiding this comment.
Review: use cache deadlock probe (v3)
Good progress from the prior rounds — the previous critical issues (dead scheduler file, native import() vs runner.import(), round-robin pool reuse, timer leaks in the outer cache-runtime.ts) have all been addressed. The architecture is now sound: fresh ModuleRunner per probe, isInsideUseCacheProbe() guard to prevent recursion, timer cleanup via .finally(), and proper HMR teardown+reinit.
Two issues remain that should be fixed before merge.
| // Internal timeout so the probe aborts before the outer render timeout. | ||
| const deadline = Date.now() + timeoutMs; | ||
|
|
||
| setInsideUseCacheProbe(true); |
There was a problem hiding this comment.
Concurrency bug: isInsideUseCacheProbe is a global boolean, not a counter or async-scoped flag. If two "use cache" functions deadlock concurrently, two probes will run. The first probe to finish sets setInsideUseCacheProbe(false) in finally, re-enabling probe scheduling for the second probe's inner registerCachedFunction calls — which haven't finished yet and are still inside a probe context.
The window is small but real: probe A finishes → sets flag to false → probe B's inner registerCachedFunction call reads isInsideUseCacheProbe() as false → schedules a nested probe → recursive ModuleRunner creation.
This should be a refcount instead of a boolean:
| setInsideUseCacheProbe(true); | |
| _insideProbeRefCount++; |
With a corresponding _insideProbeRefCount-- in finally, and isInsideUseCacheProbe() returning _insideProbeRefCount > 0.
Alternatively, since this runs in Node.js, an AsyncLocalStorage<boolean> scoped to each probe invocation would be even more robust and avoid any shared-state concerns.
| (t as NodeJS.Timeout).unref(); | ||
| } | ||
| }), | ||
| ]); |
There was a problem hiding this comment.
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:
| ]); | |
| 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);| getRequestContext, | ||
| runWithUnifiedStateMutation, | ||
| } from "./unified-request-context.js"; | ||
| import { UseCacheTimeoutError, UseCacheDeadlockError } from "./use-cache-errors.js"; |
There was a problem hiding this comment.
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:
- Using a dynamic
import()inside theisDevbranch so the modules are only loaded at runtime in dev, or - Verifying that vinext's production build pipeline actually eliminates the dead
isDevbranch (and document this assumption)
| return (globalThis as Record<symbol, unknown>)[SYMBOL] as UseCacheProbe | undefined; | ||
| } | ||
|
|
||
| export function setInsideUseCacheProbe(value: boolean): void { |
There was a problem hiding this comment.
See probe-pool.ts comment about concurrency. setInsideUseCacheProbe sets a single boolean on globalThis, which is shared across all concurrent probe invocations. If two probes overlap, the first to finish will clear the flag while the second is still running.
This should be a refcount:
| export function setInsideUseCacheProbe(value: boolean): void { | |
| export function setInsideUseCacheProbe(value: boolean): void { | |
| const current = ((globalThis as Record<symbol, unknown>)[INSIDE_PROBE_SYMBOL] as number) || 0; | |
| (globalThis as Record<symbol, unknown>)[INSIDE_PROBE_SYMBOL] = value ? current + 1 : Math.max(0, current - 1); | |
| } |
And isInsideUseCacheProbe returns count > 0:
| export function setInsideUseCacheProbe(value: boolean): void { | |
| export function isInsideUseCacheProbe(): boolean { | |
| return (((globalThis as Record<symbol, unknown>)[INSIDE_PROBE_SYMBOL] as number) || 0) > 0; | |
| } |
| // 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(":"); |
There was a problem hiding this comment.
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.
| expect(result).toBe("result"); | ||
| expect(probeCalled).toBe(false); | ||
|
|
||
| process.env.NODE_ENV = originalNodeEnv; |
There was a problem hiding this comment.
Test doesn't restore NODE_ENV on failure. If the assertion on line 2970 or 2971 throws, process.env.NODE_ENV and the probe global are never restored, potentially poisoning subsequent tests.
Use a try/finally or afterEach cleanup:
| process.env.NODE_ENV = originalNodeEnv; | |
| process.env.NODE_ENV = originalNodeEnv; | |
| setUseCacheProbe(undefined); | |
| setInsideUseCacheProbe(false); |
Better yet, wrap the cleanup in finally:
try {
expect(result).toBe("result");
expect(probeCalled).toBe(false);
} finally {
process.env.NODE_ENV = originalNodeEnv;
setUseCacheProbe(undefined);
setInsideUseCacheProbe(false);
}|
Review submitted. Here's a summary of the findings: 1 issue requiring changes:
4 moderate/minor issues:
|
- 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.
|
/bigbonk review |
There was a problem hiding this comment.
Review: use cache deadlock probe (v4)
The PR has improved significantly through the review rounds. The architecture is right: fresh ModuleRunner per probe with isolated EvaluatedModules, runner.import() for ALS modules (not native import()), refcount-based recursion guard, timer cleanup in .finally(), and HMR teardown+reinit. Previous critical issues (dead scheduler file, native import(), round-robin reuse, permanent HMR disable) are all fixed.
One issue still needs a fix, and a few things are worth flagging.
Requires changes
- Probe catch-all swallows the
UseCacheTimeoutErrorit created. Inuse-cache-probe-pool.ts:131, thecatchblock catches all errors and returnsfalse. But thePromise.raceat line 120-128 can reject withUseCacheTimeoutError(from the timeout timer at line 123). That rejection is caught at line 131, and the probe returnsfalse("inconclusive"). This means the probe timeout is silently treated as "probe failed" rather than "probe also timed out", which is the correct semantic (a timed-out probe is genuinely inconclusive). However, if the wrapped function throws a real error (not a timeout), the samecatchswallows it — the probe returnsfalsewhen it should arguably returntrue(the function completed, just with an error, which is still evidence it's not deadlocked). A function that throws is not stuck. Consider: if the caught error is not aUseCacheTimeoutError, the function ran to completion (albeit with a failure), which is evidence of a deadlock in the main fill. Returntruein that case.
Moderate
-
requestSnapshot.rootParamsroundtrips through JSON but containsunknownvalues. The snapshot capturesrequestCtx.rootParams ?? {}(typedRecord<string, unknown>) and passes it to the probe. The probe reconstructs it viacreateRequestContext({ rootParams: ... as Record<string, string | string[]> }). IfrootParamscontains non-string values at runtime, theascast silently drops the type safety. This works today because route params are always strings, but the type mismatch could mask future bugs. -
No integration test for the core deadlock-detection path. The unit tests cover error classes and the recursion guard (
isInsideUseCacheProbeskips probe scheduling). The actual path — probe completes →reject(deadlockError)→Promise.racesurfacesUseCacheDeadlockError— has zero test coverage. The fixture pages were removed due to CI timeouts, which is understandable, but a focused unit test that mocks the probe function to returntrueafter the 10s timer fires (using fake timers) would cover this without real timeouts.
| // 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; |
There was a problem hiding this comment.
A function that throws is not deadlocked. The catch here handles two distinct failure modes identically:
- The probe's internal timeout fires (
UseCacheTimeoutError) — genuinely inconclusive,falseis correct. - 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:
| 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; |
| let probePromise: Promise<never> | null = null; | ||
| const probe = getUseCacheProbe(); | ||
|
|
||
| if (probe && !isInsideUseCacheProbe()) { |
There was a problem hiding this comment.
getRequestContext() never returns null — it creates a default context. If registerCachedFunction is somehow called outside a request scope (e.g., during module evaluation), getRequestContext() returns a fresh context with all-null fields. The snapshot will then have headers: [], urlPathname: "/", etc. — the probe will run with an empty request context.
This is probably fine for deadlock detection (the function body matters more than headers), but worth noting that navCtx?.pathname on line 429 will always be null here because serverContext defaults to null, so urlPathname will always be "/". If this is intentional, a comment would help.
| const USE_CACHE_TIMEOUT_MS = 54_000; | ||
| const fillDeadlineAt = performance.now() + USE_CACHE_TIMEOUT_MS; | ||
|
|
||
| const [ |
There was a problem hiding this comment.
Dynamic import() inside the hot path helps with tree-shaking but adds per-call overhead in dev. Every invocation of a "use cache" function in dev mode does two dynamic imports (use-cache-errors.js and use-cache-probe-globals.js). After the first call these are cached by the module system so the cost is minimal, but it's worth noting that bundlers do cache dynamic import results — the tree-shaking benefit is real.
One alternative: hoist the imports to module scope behind a lazy singleton pattern:
let _probeModules: { UseCacheTimeoutError: ..., getUseCacheProbe: ... } | undefined;
async function getProbeModules() {
return _probeModules ??= { ...(await import('./use-cache-errors.js')), ...(await import('./use-cache-probe-globals.js')) };
}Not blocking — the current approach is correct.
|
|
||
| import type { DevEnvironment } from "vite"; | ||
| import { createDirectRunner, type DevEnvironmentLike } from "./dev-module-runner.js"; | ||
| import type { ModuleRunner } from "vite/module-runner"; |
There was a problem hiding this comment.
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.
| invalidateRootParamsModule(); | ||
| // Tear down the use-cache probe pool so the next probe starts with | ||
| // fresh code after HMR invalidation. | ||
| tearDownUseCacheProbePool(); |
There was a problem hiding this comment.
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.
| return (globalThis as Record<symbol, unknown>)[SYMBOL] as UseCacheProbe | undefined; | ||
| } | ||
|
|
||
| export function setInsideUseCacheProbe(value: boolean): void { |
There was a problem hiding this comment.
Good: the refcount approach fixes the concurrency bug from the prior review. setInsideUseCacheProbe(true) increments, setInsideUseCacheProbe(false) decrements, and isInsideUseCacheProbe() checks > 0. This correctly handles overlapping probes.
Minor nit: the function name setInsideUseCacheProbe(value: boolean) with refcount semantics is a bit misleading — enterUseCacheProbe() / leaveUseCacheProbe() would be clearer, but not worth changing now.
| // 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!); |
There was a problem hiding this comment.
Race condition with _probeEnvironment! capture. The probe closure captures _probeEnvironment by reference from the module scope. If tearDownUseCacheProbePool() is called (setting _probeEnvironment = null) while a probe is in-flight, this line reads null and the ! assertion crashes.
The window is narrow (HMR invalidation while a probe is running), but it exists. Consider capturing the environment at closure creation time:
| const runner = createDirectRunner(_probeEnvironment!); | |
| const env = _probeEnvironment!; | |
| const runner = createDirectRunner(env); |
Or even better, capture it in initUseCacheProbePool and close over the local:
const env = environment;
setUseCacheProbe(async (msg) => {
const runner = createDirectRunner(env);
...
});| rootParams: requestCtx.rootParams ?? {}, | ||
| }; | ||
|
|
||
| probePromise = new Promise<never>((_, reject) => { |
There was a problem hiding this comment.
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.
|
Review submitted. Here's the summary: 1 issue requiring changes:
2 moderate issues:
Additional observations (non-blocking):
|
- 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<string, string | string[] | undefined> 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.
|
/bigbonk review |
There was a problem hiding this comment.
Review: use cache deadlock probe (v5)
The PR has come a long way through four review rounds — the major structural issues (dead scheduler file, native import() vs runner.import(), round-robin pool reuse, permanent HMR disable, recursive probing) are all fixed. The architecture is sound. Fresh ModuleRunner per probe, refcount-based recursion guard, timer cleanup in .finally(), and HMR teardown+reinit is the right approach.
There are still a few things that need attention before this can merge.
Requires changes
-
Probe
probePromisenever settles when probe returnsfalse(inconclusive). This is the most impactful remaining issue. When the probe fires (after 10s) and returnsfalse(inconclusive — it also hung in isolation), the.then()callback at line 451 does nothing (completedisfalse, so theif (completed)doesn't reject). ButprobePromiseis aPromise<never>that has no resolve path — it can only settle viareject(). In the race at line 477, theprobePromisethen just dangles. This is fine when execution eventually completes or the timeout fires, but it means the probe result is silently swallowed and the 54s timeout becomes the only backstop. Consider: when the probe returnsfalse, should the code log a warning ("cache fill may be stuck, probe was also inconclusive") to give the developer some signal before the 54s hard timeout? -
initUseCacheProbePoolearly-return guard prevents reinit after HMR teardown (almost).tearDownUseCacheProbePool()sets_probeEnvironment = null, and theninitUseCacheProbePool()checksif (_probeEnvironment)and returns early if truthy. Since teardown nulls it first, reinit will proceed — but the comment on line 35 says "Already initialized — no-op. The environment is the same for the lifetime of the dev server." This is misleading because HMR explicitly tears down and reinits. The comment should reflect the actual usage pattern to avoid future confusion.
Moderate
-
privatevariant in probe pool is dead code. The private cache branch incache-runtime.ts(line 387) returns before theisDevdeadlock-detection branch (line 401). Private cache functions never reach the probe scheduling code. Thekind === "private" ? "private" : ""logic at line 95 ofuse-cache-probe-pool.tscan never produce"private"because the probe is never scheduled for private caches. Not a bug, but dead/misleading code. -
rootParamstype mismatch persists. The snapshot capturesrequestCtx.rootParams ?? {}which has typeRecord<string, unknown>(fromUnifiedRequestContext), butUseCacheProbeRequestSnapshotdeclares it asRecord<string, string | string[] | undefined>. TherunWithProbeRequestStoreparameter also usesRecord<string, string | string[] | undefined>. This works because route params are always strings at runtime, but it's an implicit assumption. An explicitascast at the capture site or a narrowing helper would make this intentional. -
cookieHeadercaptured but never used. The snapshot capturescookieHeader: headers?.get("cookie") ?? undefinedat line 433, butrunWithProbeRequestStorenever reads it — it reconstructs headers from theheadersarray (which already includes the Cookie header). Either removecookieHeaderfrom the snapshot type and capture, or use it.
| (completed) => { | ||
| if (completed) reject(deadlockError); | ||
| }, | ||
| () => {}, |
There was a problem hiding this comment.
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:
| () => {}, | |
| .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.`, | |
| ); | |
| } | |
| }, | |
| () => {}, | |
| ); |
| if (_probeEnvironment) { | ||
| // Already initialized — no-op. The environment is the same for the | ||
| // lifetime of the dev server. | ||
| return; |
There was a problem hiding this comment.
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.
| 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; |
|
|
||
| // Wrap it with registerCachedFunction so the probe runs through the | ||
| // same cache-runtime path (fresh ALS, no shared state). | ||
| const variant = kind === "private" ? "private" : ""; |
There was a problem hiding this comment.
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 navCtx = requestCtx.serverContext; | ||
| const requestSnapshot = { | ||
| headers: headers ? Array.from(headers.entries()) : [], | ||
| cookieHeader: headers?.get("cookie") ?? undefined, |
There was a problem hiding this comment.
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.
| cookieHeader: string | undefined; | ||
| urlPathname: string; | ||
| urlSearch: string; | ||
| rootParams: Record<string, string | string[] | undefined>; |
There was a problem hiding this comment.
Type mismatch with source. rootParams is declared as Record<string, string | string[] | undefined> here, but UnifiedRequestContext.rootParams is Record<string, unknown>. The values are always strings at runtime (they're route params), but the types don't match. Consider using Record<string, unknown> here for consistency, or add an explicit narrowing cast at the capture site in cache-runtime.ts.
| probe({ | ||
| id, | ||
| kind: cacheVariant, | ||
| encodedArguments: JSON.stringify(args), |
There was a problem hiding this comment.
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:
| encodedArguments: JSON.stringify(args), | |
| encodedArguments: (() => { try { return JSON.stringify(args); } catch { return "[]"; } })(), |
Alternatively, move the serialization into a helper.
| setInsideUseCacheProbe(true); | ||
| 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); | ||
| } |
There was a problem hiding this comment.
Good: the try/finally pattern correctly restores global state even on assertion failure. This addresses the prior review concern about test pollution.
One thing to note: vi.resetModules() on line 2958 means cache-runtime.js is re-evaluated with NODE_ENV="development", but the probe-globals.js imported on line 2947 is from the original module evaluation (before resetModules). Since the globals use Symbol.for on globalThis, this still works — but it's worth being aware that the test is mixing pre-reset and post-reset module instances. A comment noting this would help future readers.
|
Review submitted. Here's a summary of the findings: Requires changes (2):
Moderate (3):
|
- 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<string, unknown> vs Record<string, string | string[] | undefined> 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.
|
/bigbonk review |
There was a problem hiding this comment.
Review: use cache deadlock probe (round 6)
This has been through five review rounds and the architecture is in good shape. The core design — fresh ModuleRunner per probe with isolated EvaluatedModules, runner.import() for ALS modules, refcount-based recursion guard, timer cleanup in .finally(), HMR teardown+reinit — is correct. CI is fully green. Previous critical issues (dead scheduler file, native import(), round-robin reuse, permanent HMR disable, recursive probing, timer leaks) are all resolved.
There are still a few things worth addressing before merge. One is a real semantic issue, one is an unfinished cleanup from a prior round, and the rest are minor but would improve the code.
Requires changes
cookieHeaderwas removed from the runtime code but is still declared in theUseCacheProbeRequestSnapshottype — or rather, it was removed from the snapshot construction but the type definition doesn't include it either, so this is actually fine. Disregard. (Self-correction: I verified the type and snapshot are consistent.)
Moderate
See inline comments.
Minor / non-blocking
See inline comments.
| const { id, encodedArguments, request, timeoutMs } = msg; | ||
|
|
||
| // Internal timeout so the probe aborts before the outer render timeout. | ||
| const deadline = Date.now() + timeoutMs; |
There was a problem hiding this comment.
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:
| 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).
| // 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(); |
There was a problem hiding this comment.
If the suggestion to use performance.now() for the deadline is accepted, this line should also 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.
| // Swallow rejection when execution wins the race. | ||
| timeoutPromise.catch(() => {}); | ||
|
|
||
| const executionPromise = executeWithContext(fn, args, cacheVariant); |
There was a problem hiding this comment.
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.
| const requestSnapshot = { | ||
| headers: headers ? Array.from(headers.entries()) : [], | ||
| urlPathname: navCtx?.pathname ?? "/", | ||
| urlSearch: navCtx?.searchParams?.toString() ?? "", | ||
| rootParams: (requestCtx.rootParams ?? {}) as Record< |
There was a problem hiding this comment.
rootParams type assertion masks a real mismatch. getRequestContext().rootParams is Record<string, unknown> (from UnifiedRequestContext), but the snapshot type is Record<string, string | string[] | undefined>. The as cast silently drops the type safety.
This works today because route params are always strings, but the assertion hides the fact that the types don't agree. A safer approach would be to narrow at the source:
| const requestSnapshot = { | |
| headers: headers ? Array.from(headers.entries()) : [], | |
| urlPathname: navCtx?.pathname ?? "/", | |
| urlSearch: navCtx?.searchParams?.toString() ?? "", | |
| rootParams: (requestCtx.rootParams ?? {}) as Record< | |
| rootParams: Object.fromEntries( | |
| Object.entries(requestCtx.rootParams ?? {}).map(([k, v]) => [ | |
| k, | |
| typeof v === 'string' || Array.isArray(v) ? v : undefined, | |
| ]), | |
| ), |
Non-blocking — the cast is fine for now, but this is a latent type hole.
| // 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 = ""; |
There was a problem hiding this comment.
Dead code path. As noted in previous reviews, kind can never be "private" because private cache functions return at cache-runtime.ts:396 before reaching the isDev probe-scheduling block. The comment on line 95-96 acknowledges this but the logic still carries the dead branch.
Consider simplifying to just const variant = ""; and removing the comment about private caches. The comment makes it sound like the code is defensively handling something that could happen, but the code path is structurally unreachable — it's not a safety net, it's dead code.
Non-blocking.
| import("./use-cache-probe-globals.js"), | ||
| ]); | ||
|
|
||
| const timeoutError = new UseCacheTimeoutError(); |
There was a problem hiding this comment.
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 [ | ||
| { UseCacheTimeoutError, UseCacheDeadlockError }, | ||
| { getUseCacheProbe, isInsideUseCacheProbe }, | ||
| ] = await Promise.all([ | ||
| import("./use-cache-errors.js"), | ||
| import("./use-cache-probe-globals.js"), | ||
| ]); |
There was a problem hiding this comment.
Dynamic imports are fine for tree-shaking but add async overhead. After the first call, the module cache makes these near-free. But every registerCachedFunction invocation does await Promise.all([import(...), import(...)]) — two microtask yields minimum.
For a hot path in dev mode, consider hoisting to a lazy singleton so the await only happens on the first call:
let _probeModules: Awaited<ReturnType<typeof loadProbeModules>> | undefined;
async function loadProbeModules() {
const [errors, globals] = await Promise.all([
import("./use-cache-errors.js"),
import("./use-cache-probe-globals.js"),
]);
return { ...errors, ...globals };
}Then: const mods = _probeModules ??= await loadProbeModules();
Non-blocking.
| const deadline = Date.now() + timeoutMs; | ||
|
|
||
| let probeTimeoutTimer: ReturnType<typeof setTimeout> | undefined; | ||
| setInsideUseCacheProbe(true); |
There was a problem hiding this comment.
setInsideUseCacheProbe(true) is called before runner.import(). The import itself can fail (e.g., module not found, transform error). If it throws, the finally block calls setInsideUseCacheProbe(false), which decrements the refcount — correct.
But: the probe function was called because !isInsideUseCacheProbe() was true in cache-runtime.ts. If the import fails and the probe returns false, the caller in cache-runtime.ts does nothing (the .then callback checks if (completed)). The isInsideUseCacheProbe flag is correctly incremented/decremented in all paths — just confirming the flow is sound.
| resolveHung = resolve; | ||
| }); | ||
| const cached = registerCachedFunction(hungFn, "test:deadlock-race", ""); | ||
|
|
There was a problem hiding this comment.
Good test — this is the key behavioral test that was missing in earlier rounds. The fake-timer approach to test the 10s probe scheduling and deadlock error surfacing via Promise.race is exactly right. This covers the core detection path without needing real timeouts.
One concern: vi.advanceTimersByTimeAsync(10_000) advances fake timers, but the probe function installed via setUseCacheProbe(async () => true) is a microtask (resolved promise). After advanceTimersByTimeAsync fires the 10s setTimeout, the probe's .then() callback runs in the next microtick. The single await Promise.resolve() on line 3055 should be enough to flush that, but if this test ever becomes flaky, adding a second await Promise.resolve() would be the fix.
| export function setInsideUseCacheProbe(value: boolean): void { | ||
| const current = ((globalThis as Record<symbol, unknown>)[INSIDE_PROBE_SYMBOL] as number) || 0; | ||
| (globalThis as Record<symbol, unknown>)[INSIDE_PROBE_SYMBOL] = value | ||
| ? current + 1 | ||
| : Math.max(0, current - 1); | ||
| } |
There was a problem hiding this comment.
The refcount approach is correct for concurrent probes. Good fix from the earlier review rounds.
As noted in the v4 review, the API shape (setInsideUseCacheProbe(true/false)) is slightly misleading for refcount semantics — enterUseCacheProbe() / leaveUseCacheProbe() would be clearer. But renaming is not worth a churn-only change at this point.
|
Review submitted. Here's a summary of the findings: Overall assessment: The PR is in good shape after 5 prior review rounds. All previously identified critical issues (dead scheduler file, native Key findings in this round (all moderate/minor, no blockers):
|
- 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.
- 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
|
/bigbonk review |
There was a problem hiding this comment.
Review: use cache deadlock probe (round 7)
This PR has been through six review rounds and addressed all previously identified critical issues. The architecture is solid: fresh ModuleRunner per probe with isolated EvaluatedModules, runner.import() for ALS modules, refcount-based recursion guard, timer cleanup in .finally(), HMR teardown+reinit, lazy probe module loading, and console.warn for the inconclusive case. CI is green.
I have two actionable findings (one moderate, one minor) and a few non-blocking observations.
Moderate
-
_probeModuleslazy singleton survivesvi.resetModules()butloadProbeModulesre-evaluates the modules — Incache-runtime.ts, the_probeModulesvariable is at module scope and uses??=to avoid re-importing. But whenvi.resetModules()runs in tests, a newcache-runtime.jsmodule instance is created — with its own_probeModules = undefined. This is correct for test isolation, but it means the lazy singleton only caches within a single module instance. In production (single module instance), this is fine. In tests withvi.resetModules(), this is also fine because each test gets a fresh singleton. No action needed — flagging for documentation clarity only. -
Probe pool static import in
index.tspullsvinext/shims/use-cache-probe-globalsandvinext/shims/use-cache-errorseagerly at plugin load time — Line 19 ofindex.tsstatically importsuse-cache-probe-pool.js, which in turn statically importssetUseCacheProbe,setInsideUseCacheProbefromvinext/shims/use-cache-probe-globalsandUseCacheTimeoutErrorfromvinext/shims/use-cache-errors. These modules are loaded when the Vite plugin is registered, even if the dev server is never started (e.g.,vite build). The functions are only called insideconfigureServer(), so this is harmless at runtime, but it's unnecessary module loading during production builds. Consider making the import inindex.tsdynamic (insideconfigureServer) or lazy. Non-blocking — the cost is negligible for a plugin module.
Minor
See inline comments.
What looks good
- Fresh
ModuleRunnerper probe withrunner.close()infinally— correctly avoids stale module state. - Refcount-based
isInsideUseCacheProbe— handles concurrent probes correctly. envcaptured in local variable at closure creation time (line 43 ofuse-cache-probe-pool.ts) — immune to HMR teardown mid-probe.- Timer cleanup in
.finally()at both levels (probe-pool and cache-runtime) — no timer leaks. - Lazy singleton
_probeModules— avoids per-call dynamic import overhead after the first invocation. try/finallyin tests — properly restores global state on assertion failure.- Error classes set
this.name— stack traces show the correct class name. JSON.stringifywrapped in try/catch with"[]"fallback — handles circular references gracefully.- Inconclusive probe logs
console.warn— gives developers a signal before the 54s hard timeout. - HMR teardown+reinit in
invalidateAppRoutingModules— probes continue working after file changes.
Overall this is in good shape. The architecture matches the Next.js approach (vercel/next.js#93500, #93538) adapted for Vite's ModuleRunner isolation model. The inline comments are thorough and explain the non-obvious design decisions well.
|
|
||
| // Wrap it with registerCachedFunction so the probe runs through the | ||
| // same cache-runtime path (fresh ALS, no shared state). | ||
| const variant = ""; |
There was a problem hiding this comment.
Dead code path. cacheVariant === "private" returns at cache-runtime.ts:412-421 before reaching the isDev block at line 426, so the probe is never scheduled for private caches. variant here is always "".
This has been flagged in multiple prior rounds. Consider simplifying to just const variant = ""; with a one-line comment like // Private caches return before the probe block in cache-runtime.ts.
Non-blocking, but the current code reads like it handles something that structurally cannot happen.
| const { id, encodedArguments, request, timeoutMs } = msg; | ||
|
|
||
| // Internal timeout so the probe aborts before the outer render timeout. | ||
| const deadline = performance.now() + timeoutMs; |
There was a problem hiding this comment.
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.
| const USE_CACHE_TIMEOUT_MS = 54_000; | ||
| const fillDeadlineAt = performance.now() + USE_CACHE_TIMEOUT_MS; | ||
|
|
||
| const { |
There was a problem hiding this comment.
Minor: eager error construction on every dev-mode "use cache" invocation. UseCacheTimeoutError and UseCacheDeadlockError are loaded here (via the lazy singleton) but constructed later on lines 484 and 501. So the classes are loaded eagerly, but the instances are only constructed when needed — inside the setTimeout callbacks.
Wait, looking more carefully: the errors are constructed inside the setTimeout callbacks (line 484: reject(new UseCacheDeadlockError()), line 501: reject(new UseCacheTimeoutError())), not eagerly. This is already the lazy construction pattern. Good — no wasted stack trace capture on the fast path.
Disregard the prior round's concern about eager construction; that was fixed.
| headers: headers ? Array.from(headers.entries()) : [], | ||
| urlPathname: navCtx?.pathname ?? "/", | ||
| urlSearch: navCtx?.searchParams?.toString() ?? "", | ||
| rootParams: Object.fromEntries( |
There was a problem hiding this comment.
Subtle: rootParams narrowing is correct but verbose. The Object.fromEntries(Object.entries(...).map(...)) on lines 456-461 narrows Record<string, unknown> to Record<string, string | string[] | undefined> by filtering out non-string, non-array values. This is the right approach (addresses the type mismatch flagged in prior rounds) — it's explicit narrowing rather than an unsafe cast.
The as cast on line 461 is now a documentation cast rather than a lie, since the .map() already guarantees the value types. Acceptable.
| // Swallow rejection when execution wins the race. | ||
| timeoutPromise.catch(() => {}); | ||
|
|
||
| const executionPromise = executeWithContext(fn, args, cacheVariant); |
There was a problem hiding this comment.
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.
| }); | ||
|
|
||
| // Advance to the 10 s probe timer. | ||
| await vi.advanceTimersByTimeAsync(10_000); |
There was a problem hiding this comment.
Good test coverage for the core detection path. The fake-timer approach to test the 10s probe + deadlock error surfacing via Promise.race is exactly right. One thing to watch: vi.advanceTimersByTimeAsync(10_000) fires the 10s setTimeout, then the probe's async () => true resolves in a microtask, then .then((completed) => { if (completed) reject(...) }) fires in another microtask. The single await Promise.resolve() on line 3107 should flush that chain, but if this test ever becomes flaky, adding a second await Promise.resolve() would be the fix.
| // 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(":"); |
There was a problem hiding this comment.
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.
| 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"; |
There was a problem hiding this comment.
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.
|
Review submitted. Here's a summary: Overall assessment: The PR is in good shape after six prior review rounds. All critical issues from earlier rounds (dead scheduler file, native Findings (all non-blocking):
No blockers remaining. |
… constant 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.
|
/bigbonk review |
There was a problem hiding this comment.
Review: use cache deadlock probe (round 8)
This PR has been through seven review rounds and the architecture is solid. The design — fresh ModuleRunner per probe with isolated EvaluatedModules, runner.import() for ALS modules, refcount-based recursion guard, timer cleanup in .finally(), HMR teardown+reinit, lazy probe module loading — is correct and well-implemented.
I have one moderate issue, two minor issues, and some non-blocking observations.
Moderate
invalidateAppRoutingModulesis now async but its callers silently swallow errors. Theserver.watcher.on("add"/"unlink")handlers callinvalidateAppRoutingModules().catch(() => {}). Thecatch(() => {})silently swallows all errors — not just probe-pool errors, but also failures ininvalidateAppRouteCache(),invalidateRscEntryModule(), andinvalidateRootParamsModule()which were previously synchronous and would throw visibly. If any of those three functions throw, the developer gets no feedback and the dev server silently operates with a stale route cache. At minimum, the catch should log the error.
Minor
-
kindfield passed to probe is dead code. Thekind: cacheVariantatcache-runtime.ts:471can never be"private"because private caches return at line 412-422 before reaching theisDevblock. In the probe pool,variantis hardcoded to""(line 98). Thekindfield inUseCacheProbemessage type exists but serves no purpose — it's always"". Consider removing it from the message type and the probe function, or at minimum adding a comment that this is always""today. -
Probe pool initialization is fire-and-forget with silent error swallowing. At
index.ts:2174,getProbePoolModule().then(...).catch(() => {})means if the dynamic import orinitUseCacheProbePoolfails, the developer gets no error and deadlock detection is silently disabled for the session. Aconsole.warnin the catch would make this diagnosable.
Non-blocking observations
- The
envlocal capture atuse-cache-probe-pool.ts:43correctly immunizes against HMR teardown mid-probe. Good pattern. - Error classes correctly set
this.name. TheisUseCacheTimeoutError/isUseCacheDeadlockErrortype guards use duck-typeddigestchecking, which is the right approach for cross-realm compatibility. - The lazy singleton
_probeModulespattern incache-runtime.tseliminates per-call async overhead after the first invocation — correct optimization. - The
try/finallypattern in tests properly restores global state on assertion failure. - Timer cleanup in
.finally()at both levels (probe-pool and cache-runtime) prevents timer leaks. - The
JSON.stringify(args)try/catch with"[]"fallback handles circular references gracefully.
Overall the code is in good shape. The three items above are the only things I'd want addressed before merge.
| } | ||
| if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) { | ||
| invalidateAppRoutingModules(); | ||
| invalidateAppRoutingModules().catch(() => {}); |
There was a problem hiding this comment.
invalidateAppRoutingModules is now async, but .catch(() => {}) silently swallows all errors — including failures in the three synchronous invalidation calls (invalidateAppRouteCache, invalidateRscEntryModule, invalidateRootParamsModule) that were previously uncaught-and-visible. If any of those throw, the dev server silently operates with a stale route cache and the developer gets no feedback.
| invalidateAppRoutingModules().catch(() => {}); | |
| invalidateAppRoutingModules().catch((err) => { | |
| console.warn("[vinext] Failed to invalidate app routing modules:", err); | |
| }); |
Same for the "unlink" handler on line 2103.
| } | ||
| if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) { | ||
| invalidateAppRoutingModules(); | ||
| invalidateAppRoutingModules().catch(() => {}); |
There was a problem hiding this comment.
Same concern as the "add" handler — silently swallowing all errors.
| invalidateAppRoutingModules().catch(() => {}); | |
| invalidateAppRoutingModules().catch((err) => { | |
| console.warn("[vinext] Failed to invalidate app routing modules:", err); | |
| }); |
| if (rscEnv) { | ||
| getProbePoolModule() | ||
| .then(({ initUseCacheProbePool }) => initUseCacheProbePool(rscEnv)) | ||
| .catch(() => {}); |
There was a problem hiding this comment.
If the probe pool fails to initialize (import error, environment issue, etc.), deadlock detection is silently disabled for the entire dev session with no diagnostic output.
| .catch(() => {}); | |
| getProbePoolModule() | |
| .then(({ initUseCacheProbePool }) => initUseCacheProbePool(rscEnv)) | |
| .catch((err) => { | |
| console.warn("[vinext] Failed to initialize use-cache probe pool:", err); | |
| }); |
|
|
||
| export type UseCacheProbe = (msg: { | ||
| id: string; | ||
| kind: string; |
There was a problem hiding this comment.
Nit: kind in this message type is structurally always "" — private caches return before the probe scheduling block in cache-runtime.ts (line 412-422), and the probe pool hardcodes variant = "" (line 98). Consider either removing kind from the type or adding a doc comment noting it's always empty string today. This avoids giving future readers the impression that the probe needs to handle different cache variants.
|
|
||
| probe({ | ||
| id, | ||
| kind: cacheVariant, |
There was a problem hiding this comment.
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.
| const deadline = performance.now() + timeoutMs; | ||
|
|
||
| let probeTimeoutTimer: ReturnType<typeof setTimeout> | undefined; | ||
| setInsideUseCacheProbe(true); |
There was a problem hiding this comment.
Minor: setInsideUseCacheProbe(true) is called before the runner.import() calls on lines 62 and 87, which can fail (module not found, transform errors, etc.). If they throw, setInsideUseCacheProbe(false) in finally correctly decrements the refcount — so this is safe. Just confirming the flow is sound for future readers.
|
Review submitted. Here's a summary of the findings: 1 moderate issue:
2 minor issues:
Non-blocking observations (all positive):
|
Summary
Implements a dev-only probe that detects when a
'use cache'fill is stuck on module-scope shared state (e.g. a top-levelMapused to dedupe fetches), matching Next.js behavior from vercel/next.js#93500 and #93538.How it works
UseCacheTimeoutErrorandUseCacheDeadlockErrorwithdigestfields for type-safe detection.ModuleRunnerinstances with isolatedEvaluatedModulesgraphs, so top-level module state is recreated from scratch.process.env.NODE_ENV === "development"and tree-shakes out of production builds.Test coverage
use-cache-deadlock(never-resolving module-scope promise) anduse-cache-recovery(slow-but-working I/O).Files changed
packages/vinext/src/shims/use-cache-errors.ts(new)packages/vinext/src/shims/use-cache-probe-globals.ts(new)packages/vinext/src/server/use-cache-probe-scheduler.ts(new)packages/vinext/src/server/use-cache-probe-pool.ts(new)packages/vinext/src/server/dev-module-runner.tspackages/vinext/src/shims/cache-runtime.tspackages/vinext/src/index.tstests/shims.test.tstests/fixtures/app-basic/app/use-cache-deadlock/page.tsx(new)tests/fixtures/app-basic/app/use-cache-recovery/page.tsx(new)Closes #1126