Skip to content

Commit 8e38012

Browse files
committed
perf(test): coordinate source require cache misses
Signed-off-by: Ho Lim <subhoya@gmail.com>
1 parent 10091a3 commit 8e38012

4 files changed

Lines changed: 474 additions & 85 deletions

File tree

test/helpers/onboard-script-mocks.cjs

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,19 @@ const path = require("node:path");
1010
const ts = require("typescript");
1111

1212
const sourceLoader = path.join(__dirname, "register-source-require.ts");
13-
const { outputText } = ts.transpileModule(fs.readFileSync(sourceLoader, "utf8"), {
14-
compilerOptions: {
15-
esModuleInterop: true,
16-
module: ts.ModuleKind.CommonJS,
17-
target: ts.ScriptTarget.ES2022,
18-
},
19-
fileName: sourceLoader,
20-
});
21-
const sourceLoaderModule = new Module(sourceLoader, module);
22-
sourceLoaderModule.filename = sourceLoader;
23-
sourceLoaderModule.paths = module.paths;
24-
sourceLoaderModule._compile(outputText, sourceLoader);
13+
14+
Module._extensions[".ts"] = (targetModule, filename) => {
15+
const { outputText } = ts.transpileModule(fs.readFileSync(filename, "utf8"), {
16+
compilerOptions: {
17+
esModuleInterop: true,
18+
module: ts.ModuleKind.CommonJS,
19+
target: ts.ScriptTarget.ES2022,
20+
},
21+
fileName: filename,
22+
});
23+
targetModule._compile(outputText, filename);
24+
};
25+
require(sourceLoader);
2526

2627
function normalizeCommand(command) {
2728
return (Array.isArray(command) ? command.join(" ") : String(command)).replace(/'/g, "");

test/helpers/register-source-require.ts

Lines changed: 229 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,15 @@ import crypto from "node:crypto";
55
import fs from "node:fs";
66
import Module from "node:module";
77
import path from "node:path";
8+
import { performance } from "node:perf_hooks";
89
import ts from "typescript";
910

11+
import {
12+
loadSourceRequireCompilerOptions,
13+
sourceRequireCacheDir,
14+
sourceRequireCachePath,
15+
} from "./source-require-cache";
16+
1017
type CommonJsModule = NodeModule & {
1118
_compile(source: string, filename: string): void;
1219
};
@@ -23,45 +30,229 @@ const moduleRuntime = Module as unknown as {
2330
_resolveFilename: ResolveFilename;
2431
};
2532
const repoRoot = path.resolve(__dirname, "../..");
26-
const configPath = path.join(repoRoot, "tsconfig.src.json");
27-
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
33+
const compilerOptions = loadSourceRequireCompilerOptions(repoRoot);
34+
// Keep the cross-process transpilation cache in this checkout's dependency
35+
// tree. A shared, predictable directory under the OS temp root could be
36+
// replaced by another local user before a test process reads from it.
37+
const cacheDir = sourceRequireCacheDir(repoRoot);
38+
fs.mkdirSync(cacheDir, { recursive: true });
39+
const cacheWaitMs = envInt("NEMOCLAW_SOURCE_REQUIRE_CACHE_WAIT_MS", 5_000);
40+
const cachePollMs = Math.max(1, envInt("NEMOCLAW_SOURCE_REQUIRE_CACHE_POLL_MS", 25));
41+
const cacheLockStaleMs = envInt("NEMOCLAW_SOURCE_REQUIRE_CACHE_LOCK_STALE_MS", 30_000);
42+
const statsPath = process.env.NEMOCLAW_SOURCE_REQUIRE_STATS;
43+
44+
const stats = {
45+
cacheHits: 0,
46+
cacheMisses: 0,
47+
compileMs: 0,
48+
duplicateFallbacks: 0,
49+
files: 0,
50+
lockWaits: 0,
51+
readCacheMs: 0,
52+
staleLocks: 0,
53+
transforms: 0,
54+
transformMs: 0,
55+
waitMs: 0,
56+
};
57+
58+
function envInt(name: string, fallback: number): number {
59+
const raw = process.env[name];
60+
if (raw === undefined) return fallback;
61+
const parsed = Number.parseInt(raw, 10);
62+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
63+
}
64+
65+
function nowMs(): number {
66+
return performance.now();
67+
}
68+
69+
const sleepBuffer = new SharedArrayBuffer(4);
70+
const sleepArray = new Int32Array(sleepBuffer);
71+
72+
function sleepSync(ms: number): void {
73+
if (ms <= 0) return;
74+
Atomics.wait(sleepArray, 0, 0, ms);
75+
}
76+
77+
function readCachedOutput(cachePath: string): string | null {
78+
const start = nowMs();
79+
try {
80+
const output = fs.readFileSync(cachePath, "utf8");
81+
stats.readCacheMs += nowMs() - start;
82+
return output;
83+
} catch (error) {
84+
stats.readCacheMs += nowMs() - start;
85+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
86+
return null;
87+
}
88+
}
2889

29-
if (configFile.error) {
30-
throw new Error(ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n"));
90+
function writeAtomic(cachePath: string, outputText: string): void {
91+
const temporaryPath = `${cachePath}.${process.pid}.${crypto.randomUUID()}`;
92+
fs.writeFileSync(temporaryPath, outputText, { flag: "wx", mode: 0o600 });
93+
try {
94+
fs.renameSync(temporaryPath, cachePath);
95+
} catch (error) {
96+
fs.rmSync(temporaryPath, { force: true });
97+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
98+
}
99+
}
100+
101+
function parseLockOwner(contents: string): { pid?: number } {
102+
try {
103+
const parsed = JSON.parse(contents);
104+
return typeof parsed?.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
105+
? { pid: parsed.pid }
106+
: {};
107+
} catch {
108+
return {};
109+
}
110+
}
111+
112+
function processIsRunning(pid: number): boolean {
113+
try {
114+
process.kill(pid, 0);
115+
return true;
116+
} catch (error) {
117+
return (error as NodeJS.ErrnoException).code !== "ESRCH";
118+
}
119+
}
120+
121+
function lockOwnerContents(filename: string): string {
122+
return `${JSON.stringify({
123+
filename,
124+
pid: process.pid,
125+
startedAt: new Date().toISOString(),
126+
startedAtMs: Date.now(),
127+
})}\n`;
128+
}
129+
130+
function tryAcquireLock(lockPath: string, filename: string): boolean {
131+
try {
132+
fs.writeFileSync(lockPath, lockOwnerContents(filename), { flag: "wx", mode: 0o600 });
133+
return true;
134+
} catch (error) {
135+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
136+
return false;
137+
}
31138
}
32139

33-
const parsedConfig = ts.parseJsonConfigFileContent(
34-
configFile.config,
35-
ts.sys,
36-
repoRoot,
37-
{},
38-
configPath,
39-
);
40-
if (parsedConfig.errors.length > 0) {
41-
throw new Error(
42-
parsedConfig.errors
43-
.map((error) => ts.flattenDiagnosticMessageText(error.messageText, "\n"))
44-
.join("\n"),
140+
function reclaimStaleLock(lockPath: string, filename: string): boolean {
141+
if (cacheLockStaleMs <= 0) return false;
142+
let fd: number | undefined;
143+
try {
144+
fd = fs.openSync(lockPath, "r+");
145+
const stat = fs.fstatSync(fd);
146+
const contents = fs.readFileSync(fd, "utf8");
147+
const ageMs = Date.now() - stat.mtimeMs;
148+
if (ageMs < cacheLockStaleMs) return false;
149+
const { pid } = parseLockOwner(contents);
150+
if (pid !== undefined && processIsRunning(pid)) return false;
151+
152+
fs.ftruncateSync(fd, 0);
153+
fs.writeSync(fd, lockOwnerContents(filename), 0, "utf8");
154+
stats.staleLocks += 1;
155+
return true;
156+
} catch (error) {
157+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
158+
throw error;
159+
} finally {
160+
if (fd !== undefined) fs.closeSync(fd);
161+
}
162+
}
163+
164+
function transpileSource(source: string, filename: string): string {
165+
const start = nowMs();
166+
const result = ts.transpileModule(source, {
167+
compilerOptions,
168+
fileName: filename,
169+
reportDiagnostics: true,
170+
});
171+
stats.transformMs += nowMs() - start;
172+
stats.transforms += 1;
173+
const errors = result.diagnostics?.filter(
174+
(diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error,
45175
);
176+
if (errors && errors.length > 0) {
177+
throw new Error(
178+
errors
179+
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"))
180+
.join("\n"),
181+
);
182+
}
183+
return result.outputText;
46184
}
47185

48-
const compilerOptions: ts.CompilerOptions = {
49-
...parsedConfig.options,
50-
declaration: false,
51-
declarationMap: false,
52-
inlineSourceMap: true,
53-
inlineSources: true,
54-
noEmit: false,
55-
outDir: undefined,
56-
rootDir: undefined,
57-
sourceMap: false,
58-
};
59-
// Keep the cross-process transpilation cache in this checkout's dependency
60-
// tree. A shared, predictable directory under the OS temp root could be
61-
// replaced by another local user before a test process reads from it.
62-
const cacheDir = path.join(repoRoot, "node_modules", ".cache", "nemoclaw-source-require");
63-
const compilerFingerprint = JSON.stringify({ compilerOptions, typescript: ts.version });
64-
fs.mkdirSync(cacheDir, { recursive: true });
186+
function waitForCache(cachePath: string): string | null {
187+
if (cacheWaitMs <= 0) return null;
188+
const start = nowMs();
189+
const deadline = start + cacheWaitMs;
190+
stats.lockWaits += 1;
191+
192+
while (nowMs() < deadline) {
193+
const output = readCachedOutput(cachePath);
194+
if (output !== null) {
195+
stats.waitMs += nowMs() - start;
196+
return output;
197+
}
198+
sleepSync(Math.min(cachePollMs, Math.max(0, deadline - nowMs())));
199+
}
200+
stats.waitMs += nowMs() - start;
201+
return null;
202+
}
203+
204+
function compileWithCache(filename: string, source: string, cachePath: string): string {
205+
const cached = readCachedOutput(cachePath);
206+
if (cached !== null) {
207+
stats.cacheHits += 1;
208+
return cached;
209+
}
210+
stats.cacheMisses += 1;
211+
212+
const lockPath = `${cachePath}.lock`;
213+
let ownsLock = tryAcquireLock(lockPath, filename);
214+
if (!ownsLock) {
215+
const cachedAfterContention = readCachedOutput(cachePath);
216+
if (cachedAfterContention !== null) {
217+
stats.cacheHits += 1;
218+
return cachedAfterContention;
219+
}
220+
ownsLock = reclaimStaleLock(lockPath, filename);
221+
}
222+
223+
if (!ownsLock) {
224+
const waited = waitForCache(cachePath);
225+
if (waited !== null) {
226+
stats.cacheHits += 1;
227+
return waited;
228+
}
229+
stats.duplicateFallbacks += 1;
230+
return transpileSource(source, filename);
231+
}
232+
233+
try {
234+
const outputText = transpileSource(source, filename);
235+
writeAtomic(cachePath, outputText);
236+
return outputText;
237+
} finally {
238+
fs.rmSync(lockPath, { force: true });
239+
}
240+
}
241+
242+
if (statsPath) {
243+
process.once("exit", () => {
244+
if (stats.files === 0) return;
245+
const row = {
246+
...stats,
247+
cacheDir,
248+
label: process.env.NEMOCLAW_SOURCE_REQUIRE_STATS_LABEL ?? null,
249+
pid: process.pid,
250+
rssMb: Math.round((process.memoryUsage().rss / 1024 / 1024) * 10) / 10,
251+
};
252+
fs.mkdirSync(path.dirname(statsPath), { recursive: true });
253+
fs.appendFileSync(statsPath, `${JSON.stringify(row)}\n`, { mode: 0o600 });
254+
});
255+
}
65256

66257
const resolveFilename = moduleRuntime._resolveFilename;
67258
moduleRuntime._resolveFilename = function resolveSourceFilename(request, parent, isMain, options) {
@@ -82,46 +273,11 @@ moduleRuntime._resolveFilename = function resolveSourceFilename(request, parent,
82273
};
83274

84275
moduleRuntime._extensions[".ts"] = (module, filename) => {
276+
const compileStart = nowMs();
277+
stats.files += 1;
85278
const source = fs.readFileSync(filename, "utf8");
86-
const cacheKey = crypto
87-
.createHash("sha256")
88-
.update(filename)
89-
.update("\0")
90-
.update(source)
91-
.update("\0")
92-
.update(compilerFingerprint)
93-
.digest("hex");
94-
const cachePath = path.join(cacheDir, `${cacheKey}.cjs`);
95-
let outputText: string;
96-
97-
try {
98-
outputText = fs.readFileSync(cachePath, "utf8");
99-
} catch (error) {
100-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
101-
const result = ts.transpileModule(source, {
102-
compilerOptions,
103-
fileName: filename,
104-
reportDiagnostics: true,
105-
});
106-
const errors = result.diagnostics?.filter(
107-
(diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error,
108-
);
109-
if (errors && errors.length > 0) {
110-
throw new Error(
111-
errors
112-
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"))
113-
.join("\n"),
114-
);
115-
}
116-
outputText = result.outputText;
117-
const temporaryPath = `${cachePath}.${process.pid}.${crypto.randomUUID()}`;
118-
fs.writeFileSync(temporaryPath, outputText, { flag: "wx", mode: 0o600 });
119-
try {
120-
fs.renameSync(temporaryPath, cachePath);
121-
} catch (error) {
122-
fs.rmSync(temporaryPath, { force: true });
123-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
124-
}
125-
}
279+
const cachePath = sourceRequireCachePath({ compilerOptions, filename, repoRoot, source });
280+
const outputText = compileWithCache(filename, source, cachePath);
281+
stats.compileMs += nowMs() - compileStart;
126282
module._compile(outputText, filename);
127283
};

0 commit comments

Comments
 (0)