Repository navigation
Expand file tree
/
Copy pathnode-compile-cache.mjs
More file actions
216 lines (207 loc) 路 7.83 KB
/
Copy pathnode-compile-cache.mjs
File metadata and controls
216 lines (207 loc) 路 7.83 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
import { createHash } from "node:crypto";
import { readFileSync, statSync } from "node:fs";
import fs from "node:fs/promises";
import * as module from "node:module";
import os from "node:os";
import path from "node:path";
import { isMainThread, Worker, workerData } from "node:worker_threads";
const MAX_BYTES = 512 * 1024 * 1024;
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const MAINTENANCE_INTERVAL_MS = 60 * 60 * 1000;
const BUILD_MARKER_RE = /^(?:[a-f0-9]{16}|\d+-\d+|no-package-json|build-[A-Za-z0-9._-]+)$/;
const sanitize = (value) => {
const segment = value.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
return segment && segment !== "." && segment !== ".." ? segment : "unknown";
};
function isCurrentCompileCache(directory, env) {
const current = module.getCompileCacheDir?.();
const desired = path.resolve(directory);
return (
current &&
(path.resolve(current) === desired ||
(env.NODE_COMPILE_CACHE &&
path.resolve(env.NODE_COMPILE_CACHE) === desired &&
path.dirname(path.resolve(current)) === desired))
);
}
export function resolveOpenClawCompileCacheRespawnEnv({ directory, env = process.env }) {
// Keep a caller-owned cache when Node permissions can forbid process replacement.
if (
process.permission ||
env.NODE_DISABLE_COMPILE_CACHE !== undefined ||
env.OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED === "1" ||
!directory ||
!module.getCompileCacheDir?.()
) {
return undefined;
}
if (isCurrentCompileCache(directory, env)) {
return undefined;
}
return {
...env,
NODE_COMPILE_CACHE: directory,
OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED: "1",
};
}
export function enableOpenClawCompileCache({ directory, env = process.env }) {
if (!directory || env.NODE_DISABLE_COMPILE_CACHE !== undefined) {
return;
}
const owner = (globalThis[Symbol.for("openclaw.nodeCompileCacheBase")] ??= {});
try {
const result = module.enableCompileCache(directory);
if (
result.status === module.constants.compileCacheStatus.ENABLED ||
(result.status === module.constants.compileCacheStatus.ALREADY_ENABLED &&
isCurrentCompileCache(directory, env))
) {
owner.baseDirectory ??= path.resolve(directory);
void maintainOpenClawCompileCache(directory);
return;
}
if (result.status === module.constants.compileCacheStatus.ALREADY_ENABLED) {
return;
}
} catch {
// Disposable bytecode must never prevent startup.
}
if (!owner.unavailableReported) {
owner.unavailableReported = true;
process.stderr.write("[openclaw] Compile cache unavailable; continuing without it.\n");
}
}
export function resolveOpenClawCompileCacheDirectory({ installRoot, env = process.env }) {
if (env.NODE_DISABLE_COMPILE_CACHE !== undefined) {
return undefined;
}
const packagePath = path.join(installRoot, "package.json");
let version = "unknown";
let marker = "no-package-json";
try {
const parsed = JSON.parse(readFileSync(packagePath, "utf8"));
version = typeof parsed.version === "string" ? sanitize(parsed.version) : version;
const stat = statSync(packagePath);
marker = `${Math.trunc(stat.mtimeMs)}-${stat.size}`;
} catch {
// Keep cache activation best-effort for incomplete installations.
}
try {
const build = JSON.parse(
readFileSync(path.join(installRoot, "dist", "build-info.json"), "utf8"),
);
if (typeof build.buildId === "string" && build.buildId.trim()) {
marker = createHash("sha256").update(build.buildId).digest("hex").slice(0, 16);
}
} catch {
// Older packages use the installation metadata above.
}
let base = path.resolve(env.NODE_COMPILE_CACHE || path.join(os.tmpdir(), "node-compile-cache"));
// Workers inherit the qualified base. Repeated CLI descendants must not nest
// another OpenClaw namespace; also collapse namespaces left by older launchers.
while (
path.basename(path.dirname(path.dirname(base))) === "openclaw" &&
BUILD_MARKER_RE.test(path.basename(base))
) {
base = path.dirname(path.dirname(path.dirname(base)));
}
return resolveSafeNodeCompileCacheDirectory(path.join(base, "openclaw", version, marker));
}
export function resolveSafeNodeCompileCacheDirectory(directory) {
// Node can hang at 240/245 characters (and 283 for other path structures):
// https://github.com/nodejs/node/issues/66438. Leave room for Node's cache leaf.
if (process.platform === "win32" && path.resolve(directory).length > 200) {
process.stderr.write(
"[openclaw] Compile cache disabled: Windows cache path exceeds 200 characters.\n",
);
return undefined;
}
return directory;
}
async function maintainOpenClawCompileCache(directory) {
// Node permission grants and later revocations do not extend to workers.
if (process.permission) {
return undefined;
}
const owner = (globalThis[Symbol.for("openclaw.nodeCompileCacheBase")] ??= {});
const pending = (owner.maintenance ??= new Map());
if (pending.has(directory)) {
return pending.get(directory);
}
const task = new Promise((resolve) => {
const worker = new Worker(new URL(import.meta.url), {
workerData: { openclawCompileCacheDirectory: directory },
// Maintenance must not replay CLI entry preloads or application loaders.
execArgv: [],
env: Object.fromEntries(
Object.entries(process.env).filter(([name]) => !/^(NODE_OPTIONS|BUN_OPTIONS)$/i.test(name)),
),
});
worker.on("error", () => {});
worker.once("exit", () => resolve());
// Callers can await completed cleanup, but unawaited cache work must not
// extend a command's lifetime. A Promise alone does not keep Node alive.
worker.unref();
})
.catch(() => {
// Disposable bytecode must never prevent startup or command completion.
})
.finally(() => pending.delete(directory));
pending.set(directory, task);
return task;
}
async function maintain(directory) {
const root = path.dirname(path.dirname(directory));
const version = path.basename(path.dirname(directory));
if (
path.basename(root) !== "openclaw" ||
sanitize(version) !== version ||
!BUILD_MARKER_RE.test(path.basename(directory))
) {
return;
}
const now = Date.now();
const previous = await fs.lstat(root).catch(() => undefined);
for (const target of [root, path.dirname(directory), directory]) {
const stat = target === root ? previous : await fs.lstat(target).catch(() => undefined);
if (stat && !stat.isDirectory()) {
return;
}
}
await fs.mkdir(directory, { recursive: true });
if (previous && now - previous.mtimeMs < MAINTENANCE_INTERVAL_MS) {
return;
}
// Claim the interval before walking: later CLIs should not repeat a large
// scan while it is running. Interrupted maintenance can retry next hour.
await fs.utimes(root, new Date(now), new Date(now));
const files = [];
let bytes = 0;
async function visit(dir) {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
const file = path.join(dir, entry.name);
if (entry.isDirectory()) {
await visit(file);
} else if (entry.isFile()) {
const stat = await fs.lstat(file);
bytes += stat.size;
files.push({ file, size: stat.size, mtimeMs: stat.mtimeMs });
}
}
}
// Keep recent releases together so preparing a candidate cannot evict
// the serving release (or vice versa). Bound the entire owned namespace.
await visit(root);
files.sort((left, right) => left.mtimeMs - right.mtimeMs);
for (const file of files) {
if (bytes > MAX_BYTES || now - file.mtimeMs > MAX_AGE_MS) {
await fs.unlink(file.file);
bytes -= file.size;
}
}
}
if (!isMainThread && typeof workerData?.openclawCompileCacheDirectory === "string") {
void maintain(workerData.openclawCompileCacheDirectory).catch(() => {
// Disposable bytecode cleanup is best-effort inside the worker too.
});
}