Skip to content
Merged
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
181 changes: 181 additions & 0 deletions src/rendering/orchestrator/module-loader/module-persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,185 @@ describe("module-loader/module-persistence", () => {
await Deno.remove(tmpDir, { recursive: true }).catch(() => undefined);
}
});

it("recreates the output directory when it disappears after being cached", async () => {
const projectDir = await Deno.makeTempDir({ prefix: "vf-module-persist-project-" });
const tmpDir = await Deno.makeTempDir({ prefix: "vf-module-persist-out-" });
const localAdapter = await getLocalAdapter();
const filePath = join(projectDir, "lib/uses-crypto.ts");
const moduleCache = new Map<string, string>();

try {
await Deno.mkdir(dirname(filePath), { recursive: true });

const first = await persistTransformedModule({
filePath,
projectDir,
tmpDir,
transformedCode: "export const a = 1;",
localAdapter,
moduleCache,
cacheKey: "first",
});
assertEquals(await Deno.readTextFile(first), "export const a = 1;");

// Something outside the loader wipes the cache dir (manual `rm -rf .cache`,
// a cache sweep, a container restart). The mkdir memo still claims it exists.
await Deno.remove(join(tmpDir, "lib"), { recursive: true });

const second = await persistTransformedModule({
filePath,
projectDir,
tmpDir,
transformedCode: "export const a = 2;",
localAdapter,
moduleCache,
cacheKey: "second",
});
assertEquals(await Deno.readTextFile(second), "export const a = 2;");
} finally {
await Deno.remove(projectDir, { recursive: true }).catch(() => undefined);
await Deno.remove(tmpDir, { recursive: true }).catch(() => undefined);
}
});

it("does not cache a failed mkdir as a created directory", async () => {
const projectDir = await Deno.makeTempDir({ prefix: "vf-module-persist-project-" });
const tmpDir = await Deno.makeTempDir({ prefix: "vf-module-persist-out-" });
const localAdapter = await getLocalAdapter();
const filePath = join(projectDir, "lib/transient.ts");
const moduleCache = new Map<string, string>();

// A transient mkdir failure (EMFILE under concurrent compilation) must not
// poison the memo — otherwise every later write to that directory ENOENTs.
let failNextMkdir = true;
const stubFs = Object.create(localAdapter.fs) as typeof localAdapter.fs;
stubFs.mkdir = (path: string, options?: { recursive?: boolean }) => {
if (failNextMkdir) {
failNextMkdir = false;
return Promise.reject(new Error("EMFILE: too many open files, mkdir"));
}
return localAdapter.fs.mkdir(path, options);
};
const stubAdapter = Object.create(localAdapter) as typeof localAdapter;
Object.defineProperty(stubAdapter, "fs", { value: stubFs });

try {
await Deno.mkdir(dirname(filePath), { recursive: true });

await persistTransformedModule({
filePath,
projectDir,
tmpDir,
transformedCode: "export const b = 1;",
localAdapter: stubAdapter,
moduleCache,
cacheKey: "transient",
}).catch(() => undefined);

const result = await persistTransformedModule({
filePath,
projectDir,
tmpDir,
transformedCode: "export const b = 2;",
localAdapter: stubAdapter,
moduleCache,
cacheKey: "transient-retry",
});
assertEquals(await Deno.readTextFile(result), "export const b = 2;");
} finally {
await Deno.remove(projectDir, { recursive: true }).catch(() => undefined);
await Deno.remove(tmpDir, { recursive: true }).catch(() => undefined);
}
});

it("retries the mkdir on a later write when an earlier mkdir failed", async () => {
const projectDir = await Deno.makeTempDir({ prefix: "vf-module-persist-project-" });
const tmpDir = await Deno.makeTempDir({ prefix: "vf-module-persist-out-" });
const localAdapter = await getLocalAdapter();
const filePath = join(projectDir, "lib/transient.ts");
const moduleCache = new Map<string, string>();

// mkdir always rejects, and the writes are made to land anyway by creating
// the output directory out of band. This isolates the memo from the write
// retry: the question is only whether a failed mkdir is remembered as done.
let mkdirCalls = 0;
const stubFs = Object.create(localAdapter.fs) as typeof localAdapter.fs;
stubFs.mkdir = () => {
mkdirCalls++;
return Promise.reject(new Error("EMFILE: too many open files, mkdir"));
};
const stubAdapter = Object.create(localAdapter) as typeof localAdapter;
Object.defineProperty(stubAdapter, "fs", { value: stubFs });

try {
await Deno.mkdir(dirname(filePath), { recursive: true });
await Deno.mkdir(join(tmpDir, "lib"), { recursive: true });

const persist = (transformedCode: string, cacheKey: string) =>
persistTransformedModule({
filePath,
projectDir,
tmpDir,
transformedCode,
localAdapter: stubAdapter,
moduleCache,
cacheKey,
});

await persist("export const b = 1;", "one").catch(() => undefined);
const before = mkdirCalls;
await persist("export const b = 2;", "two").catch(() => undefined);

// A failed mkdir must not be remembered as a created directory: the second
// persist has to attempt the mkdir again rather than trust a poisoned memo.
assertEquals(mkdirCalls > before, true);
} finally {
await Deno.remove(projectDir, { recursive: true }).catch(() => undefined);
await Deno.remove(tmpDir, { recursive: true }).catch(() => undefined);
}
});

it("rethrows a non-race write error without retrying the write", async () => {
const projectDir = await Deno.makeTempDir({ prefix: "vf-module-persist-project-" });
const tmpDir = await Deno.makeTempDir({ prefix: "vf-module-persist-out-" });
const localAdapter = await getLocalAdapter();
const filePath = join(projectDir, "lib/denied.ts");
const moduleCache = new Map<string, string>();

// A permission error is not a vanished-directory race, so recreating the
// directory and writing again would just fail twice on an already-degraded
// filesystem. It must surface immediately.
let writeCalls = 0;
const stubFs = Object.create(localAdapter.fs) as typeof localAdapter.fs;
stubFs.writeFile = () => {
writeCalls++;
return Promise.reject(new Error("EACCES: permission denied, open"));
};
const stubAdapter = Object.create(localAdapter) as typeof localAdapter;
Object.defineProperty(stubAdapter, "fs", { value: stubFs });

try {
await Deno.mkdir(dirname(filePath), { recursive: true });

let rejected = false;
await persistTransformedModule({
filePath,
projectDir,
tmpDir,
transformedCode: "export const c = 1;",
localAdapter: stubAdapter,
moduleCache,
cacheKey: "denied",
}).catch(() => {
rejected = true;
});

assertEquals(rejected, true);
assertEquals(writeCalls, 1);
} finally {
await Deno.remove(projectDir, { recursive: true }).catch(() => undefined);
await Deno.remove(tmpDir, { recursive: true }).catch(() => undefined);
}
});
});
58 changes: 44 additions & 14 deletions src/rendering/orchestrator/module-loader/module-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts";
import { join } from "#veryfront/compat/path/index.ts";
import { rendererLogger } from "#veryfront/utils";
import { isCacheWriteRaceError } from "#veryfront/utils/cache-file-ops.ts";
import { hashCodeHex } from "#veryfront/utils/hash-utils.ts";
import {
getModulePathCache,
Expand Down Expand Up @@ -36,17 +37,26 @@ function pruneCreatedDirs(): void {
}
}

async function ensureDir(adapter: RuntimeAdapter, dir: string): Promise<void> {
if (createdDirs.has(dir)) return;
async function ensureDir(
adapter: RuntimeAdapter,
dir: string,
force = false,
): Promise<void> {
if (!force && createdDirs.has(dir)) return;

try {
await adapter.fs.mkdir(dir, { recursive: true });
} catch (_) {
/* expected: directory might already exist */
} finally {
createdDirs.add(dir);
pruneCreatedDirs();
} catch (error) {
// `recursive: true` is a no-op on an existing directory, so a rejection here
// means the directory may genuinely be absent (EMFILE, EACCES, a racing
// sweep). Drop the memo so the next attempt retries instead of assuming the
// directory is present forever after.
createdDirs.delete(dir);
throw error;
}

createdDirs.add(dir);
pruneCreatedDirs();
}

export interface PersistTransformedModuleInput {
Expand Down Expand Up @@ -75,17 +85,37 @@ export async function persistTransformedModule(
const tempFilePath = join(input.tmpDir, jsPath);

const tempDir = tempFilePath.substring(0, tempFilePath.lastIndexOf("/"));
await ensureDir(input.localAdapter, tempDir);
await ensureDir(input.localAdapter, tempDir).catch(() => {
// Fall through to the write, which retries the mkdir on failure.
});

try {
await input.localAdapter.fs.writeFile(tempFilePath, input.transformedCode);
} catch (error) {
logger.error("Failed to write module:", {
filePath: input.filePath,
tempFilePath,
error: error instanceof Error ? error.message : String(error),
});
throw error;
// The cache directory can vanish between mkdir and write — a manual
// `rm -rf .cache`, a cache sweep, or a mkdir that never actually landed.
// Force the directory back into existence and retry once before failing.
if (!isCacheWriteRaceError(error)) {
logger.error("Failed to write module:", {
filePath: input.filePath,
tempFilePath,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}

try {
await ensureDir(input.localAdapter, tempDir, true);
await input.localAdapter.fs.writeFile(tempFilePath, input.transformedCode);
logger.debug("Recreated module cache directory after failed write", { tempDir });
} catch (retryError) {
logger.error("Failed to write module:", {
filePath: input.filePath,
tempFilePath,
error: retryError instanceof Error ? retryError.message : String(retryError),
});
throw retryError;
}
}

if (input.contentSourceId) {
Expand Down