Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ export default {
// #726-CACHE-01/04 defines the disabled proof boundary before runtime
// observation recording or cache reuse is wired in later slices.
"src/server/cache-proof.ts",
// Worker entry loaded dynamically by use-cache-probe-pool.ts via
// getWorkerPath() + new Worker(). Not statically importable.
"src/server/use-cache-probe-worker.ts",
],
project: ["src/**/*.{ts,tsx}"],
},
Expand Down
12 changes: 12 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { handleApiRoute } from "./server/api-handler.js";
import { installSocketErrorBackstop } from "./server/socket-error-backstop.js";
import { shouldInvalidateAppRouteFile } from "./server/dev-route-files.js";
import { createDirectRunner } from "./server/dev-module-runner.js";
import { installUseCacheProbePool } from "./server/use-cache-probe-pool.js";
import { generateRscEntry } from "./entries/app-rsc-entry.js";
import { generateSsrEntry } from "./entries/app-ssr-entry.js";
import { generateBrowserEntry } from "./entries/app-browser-entry.js";
Expand Down Expand Up @@ -2072,6 +2073,17 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
}
});

// ── "use cache" deadlock probe (dev-only) ───────────────────────────
if (typeof process !== "undefined" && process.env.NODE_ENV === "development") {
installUseCacheProbePool({
onInvalidate: (cb) => {
server.watcher.on("add", cb);
server.watcher.on("change", cb);
server.watcher.on("unlink", cb);
},
});
}

// ── Dev request origin check ─────────────────────────────────────
// Registered directly (not in the returned function) so it runs
// BEFORE Vite's built-in middleware. This ensures all requests
Expand Down
60 changes: 60 additions & 0 deletions packages/vinext/src/server/use-cache-probe-globals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Cross-module handoff for the "use cache" hang-detection probe.
*
* A symbol on globalThis decouples the dev-server entry point (which installs
* the worker pool) from the read site inside cache-runtime.ts — avoiding a
* direct import of dev-only code from the cache module. In any process where the
* symbol is not set (production, edge, unit tests, the probe worker itself)
* getUseCacheProbe() returns undefined, which doubles as the recursion guard
* against a probe spawning another probe.
*/

const SYMBOL: unique symbol = Symbol.for("vinext.dev.useCacheProbe");

/**
* Serializable view of the outer request store forwarded to the probe worker.
* The worker rebuilds a real request context from this so cache bodies that
* read cookies(), headers(), or draftMode() behave the same as in a real fill.
*/
export type UseCacheProbeRequestSnapshot = {
headers: [string, string][];
cookieHeader: string | undefined;
urlPathname: string;
urlSearch: string;
rootParams: Record<string, string | string[]>;
isDraftMode: boolean;
isHmrRefresh: boolean;
};

/**
* Probe hook installed by the dev server. Resolves to true if the cache
* function ran to completion in isolation — the strong signal that shared
* outer-scope state is deadlocking the main fill. Resolves to false for any
* other outcome (probe timeout, decode failure, missing module, etc.).
*/
type UseCacheProbe = (args: {
/** Module path of the file containing the cached function. */
modulePath: string;
/** Export name / server reference id of the cached function. */
id: string;
/** Cache variant (e.g. "" for default, "remote", "private"). */
variant: string;
/** Serialized function arguments (string from stableStringify or RSC encodeReply). */
encodedArguments: string;
/** Forwarded request store snapshot so private caches resolve correctly. */
request: UseCacheProbeRequestSnapshot;
/** Internal timeout for the probe worker. */
timeoutMs: number;
}) => Promise<boolean>;

type ProbeHolder = {
[SYMBOL]?: UseCacheProbe;
};

export function setUseCacheProbe(fn: UseCacheProbe | undefined): void {
(globalThis as unknown as ProbeHolder)[SYMBOL] = fn;
}

export function getUseCacheProbe(): UseCacheProbe | undefined {
return (globalThis as unknown as ProbeHolder)[SYMBOL];
}
158 changes: 158 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,158 @@
/**
* Dev-only "use cache" probe worker pool.
*
* Uses Node.js worker_threads to re-execute a cached function in isolation.
* The worker bypasses the ESM cache by appending ?v=probe-<timestamp> to the
* module URL, giving it a fresh module scope.
*
* The pool is torn down on HMR / file invalidation so the next probe starts
* with empty module caches.
*/

import { fileURLToPath } from "node:url";
import path from "node:path";
import fs from "node:fs";
import { setUseCacheProbe, type UseCacheProbeRequestSnapshot } from "./use-cache-probe-globals.js";

let pool: WorkerPool | undefined;

type UseCacheProbePoolOptions = {
/** Called whenever the dev server invalidates caches (HMR, route recompile). */
onInvalidate?: (callback: () => void) => void;
};

type ProbeMessage = {
modulePath: string;
functionId: string;
variant: string;
encodedArguments: string;
request: UseCacheProbeRequestSnapshot;
timeoutMs: number;
};

type WorkerPool = {
runProbe(msg: ProbeMessage): Promise<boolean>;
end(): Promise<void>;
};

function getWorkerPath(): string {
const poolDir = fileURLToPath(new URL(".", import.meta.url));
// When running from source the file is .ts; when built it's .js.
const tsPath = path.join(poolDir, "use-cache-probe-worker.ts");
if (fs.existsSync(tsPath)) return tsPath;
return path.join(poolDir, "use-cache-probe-worker.js");
}

function createWorkerPool(): WorkerPool {
// Lazy-import worker_threads so this module can be loaded in contexts
// where Node.js built-ins are unavailable (e.g. browser builds, though
// this whole path is dev-only and never reached in production).
const { Worker } = require("node:worker_threads") as typeof import("node:worker_threads");

const workerPath = getWorkerPath();

// Inherit parent's execArgv so tsx loader (or any other loader) propagates
// to the worker. This lets the worker import .ts files when the parent runs
// under tsx (the common vinext dev workflow).
const execArgv = [...process.execArgv];

const worker = new Worker(workerPath, {
execArgv,
stderr: true,
stdout: true,
});

let nextId = 0;
const pending = new Map<
number,
{ resolve: (v: boolean) => void; reject: (e: unknown) => void }
>();

worker.on(
"message",
(msg: { id: number; completed: boolean } | { id: number; error: string }) => {
const handler = pending.get(msg.id);
if (!handler) return;
pending.delete(msg.id);
if ("error" in msg) {
handler.reject(new Error(msg.error));
} else {
handler.resolve(msg.completed);
}
},
);

worker.on("error", (err: Error) => {
for (const [, handler] of pending) {
handler.reject(err);
}
pending.clear();
});

worker.on("exit", (code: number) => {
if (code !== 0) {
for (const [, handler] of pending) {
handler.reject(new Error(`Probe worker exited with code ${code}`));
}
pending.clear();
}
});

return {
runProbe(msg: ProbeMessage): Promise<boolean> {
const id = ++nextId;
return new Promise<boolean>((resolve, reject) => {
pending.set(id, { resolve, reject });
worker.postMessage({ id, ...msg });
});
},
async end(): Promise<void> {
await worker.terminate().catch(() => {});
},
};
}

function getPool(): WorkerPool {
if (!pool) {
pool = createWorkerPool();
}
return pool;
}

async function tearDownPool(): Promise<void> {
const current = pool;
if (!current) return;
pool = undefined;
await current.end().catch(() => {});
}

export function installUseCacheProbePool(options?: UseCacheProbePoolOptions): void {
if (options?.onInvalidate) {
options.onInvalidate(() => {
void tearDownPool();
});
}

setUseCacheProbe(async (args) => {
let activePool: WorkerPool;
try {
activePool = getPool();
} catch {
return false;
}

try {
return await activePool.runProbe({
modulePath: args.modulePath,
functionId: args.id,
variant: args.variant,
encodedArguments: args.encodedArguments,
request: args.request,
timeoutMs: args.timeoutMs,
});
} catch {
await tearDownPool();
return false;
}
});
}
Loading
Loading