Files
Genarrative/scripts/rust-build-cache.mjs
Git Hooks Test 00b78c9701
Project CI / Repository checks (push) Successful in 3m40s
Project CI / Backend tests (push) Successful in 4m9s
Project CI / Frontend tests (push) Successful in 4m10s
Project CI / Native shell tests (push) Failing after 11m55s
治理Rust构建缓存膨胀
关闭测试 profile 的增量编译并对齐 AGC 开发调试配置

新增受控缓存审计和显式 incremental 清理命令

补齐安全测试、开发运维说明与团队踩坑记录
2026-08-22 11:52:14 +08:00

397 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { execFileSync } from 'node:child_process';
import { lstat, readdir, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const GIB = 1024 ** 3;
const DEFAULT_MAX_GIB = 120;
const REPOSITORY_MARKERS = [
'package.json',
'server-rs/Cargo.toml',
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
];
const MANAGED_CACHE_TARGETS = [
{
id: 'server-rs',
targetRelativePath: 'server-rs/target',
incrementalRelativePath: 'server-rs/target/debug/incremental',
},
{
id: 'agc',
targetRelativePath: 'apps/ai-game-creator-shell/src-tauri/target',
incrementalRelativePath:
'apps/ai-game-creator-shell/src-tauri/target/debug/incremental',
},
];
const ACTIVE_BUILD_PROCESS_NAMES = new Set([
'cargo',
'cargo.exe',
'rustc',
'rustc.exe',
]);
function pathStaysInside(root, candidate) {
const relative = path.relative(root, candidate);
return (
relative !== '' &&
relative !== '..' &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
}
async function pathExists(value) {
try {
await lstat(value);
return true;
} catch (error) {
if (error?.code === 'ENOENT') return false;
throw error;
}
}
async function assertRepositoryRoot(repoRoot) {
const resolvedRoot = path.resolve(repoRoot);
const rootInfo = await lstat(resolvedRoot);
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
throw new Error(`仓库根目录不是普通目录: ${resolvedRoot}`);
}
for (const marker of REPOSITORY_MARKERS) {
const markerPath = path.join(resolvedRoot, ...marker.split('/'));
const markerInfo = await lstat(markerPath).catch((error) => {
if (error?.code === 'ENOENT') {
throw new Error(`仓库标记缺失: ${marker}`);
}
throw error;
});
if (!markerInfo.isFile() || markerInfo.isSymbolicLink()) {
throw new Error(`仓库标记不是普通文件: ${marker}`);
}
}
return resolvedRoot;
}
async function assertManagedPath(repoRoot, relativePath) {
if (
typeof relativePath !== 'string' ||
relativePath.length === 0 ||
path.isAbsolute(relativePath) ||
relativePath.includes('\\') ||
relativePath
.split('/')
.some((part) => !part || part === '.' || part === '..')
) {
throw new Error(`受控缓存相对路径无效: ${relativePath}`);
}
const target = path.resolve(repoRoot, ...relativePath.split('/'));
if (!pathStaysInside(repoRoot, target)) {
throw new Error(`受控缓存路径越出仓库: ${relativePath}`);
}
let current = repoRoot;
for (const part of relativePath.split('/')) {
current = path.join(current, part);
if (!(await pathExists(current))) break;
const info = await lstat(current);
if (info.isSymbolicLink()) {
throw new Error(`受控缓存路径包含符号链接或 junction: ${relativePath}`);
}
}
if (await pathExists(target)) {
const info = await lstat(target);
if (!info.isDirectory() || info.isSymbolicLink()) {
throw new Error(`受控缓存目标不是普通目录: ${relativePath}`);
}
}
return target;
}
async function measureDirectory(directory) {
if (!(await pathExists(directory))) {
return { exists: false, bytes: 0, files: 0, directories: 0, links: 0 };
}
const pending = [directory];
let bytes = 0;
let files = 0;
let directories = 0;
let links = 0;
while (pending.length > 0) {
const current = pending.pop();
const entries = await readdir(current, { withFileTypes: true }).catch(
(error) => {
if (error?.code === 'ENOENT') return [];
throw error;
},
);
directories += 1;
for (const entry of entries) {
const entryPath = path.join(current, entry.name);
if (entry.isSymbolicLink()) {
links += 1;
} else if (entry.isDirectory()) {
pending.push(entryPath);
} else if (entry.isFile()) {
const fileInfo = await stat(entryPath).catch((error) => {
if (error?.code === 'ENOENT') return null;
throw error;
});
if (!fileInfo) continue;
bytes += fileInfo.size;
files += 1;
}
}
}
return { exists: true, bytes, files, directories, links };
}
async function resolveManagedCacheTargets(repoRoot) {
const root = await assertRepositoryRoot(repoRoot);
const targets = [];
for (const spec of MANAGED_CACHE_TARGETS) {
targets.push({
...spec,
targetPath: await assertManagedPath(root, spec.targetRelativePath),
incrementalPath: await assertManagedPath(
root,
spec.incrementalRelativePath,
),
});
}
return { repoRoot: root, targets };
}
async function auditRustBuildCache({
repoRoot,
maxBytes = DEFAULT_MAX_GIB * GIB,
} = {}) {
const resolved = await resolveManagedCacheTargets(repoRoot);
const targets = [];
for (const target of resolved.targets) {
targets.push({
id: target.id,
targetRelativePath: target.targetRelativePath,
incrementalRelativePath: target.incrementalRelativePath,
target: await measureDirectory(target.targetPath),
incremental: await measureDirectory(target.incrementalPath),
});
}
const totalBytes = targets.reduce((sum, item) => sum + item.target.bytes, 0);
const incrementalBytes = targets.reduce(
(sum, item) => sum + item.incremental.bytes,
0,
);
return {
schemaVersion: 'genarrative.rust-build-cache-audit.v1',
repoRoot: resolved.repoRoot,
maxBytes,
warning: totalBytes > maxBytes,
totalBytes,
incrementalBytes,
targets,
};
}
function parseWindowsTaskList(output) {
return output
.split(/\r?\n/u)
.map((line) => /^"((?:[^"]|"")*)"/u.exec(line)?.[1]?.replaceAll('""', '"'))
.filter(Boolean);
}
function readActiveProcessNames() {
if (process.platform === 'win32') {
const output = execFileSync('tasklist', ['/fo', 'csv', '/nh'], {
encoding: 'utf8',
windowsHide: true,
});
return parseWindowsTaskList(output);
}
const output = execFileSync('ps', ['-A', '-o', 'comm='], {
encoding: 'utf8',
});
return output
.split(/\r?\n/u)
.map((value) => value.trim())
.filter(Boolean);
}
function activeRustBuildProcesses(processNames) {
return [
...new Set(
processNames
.map((name) => name.toLowerCase())
.filter((name) => ACTIVE_BUILD_PROCESS_NAMES.has(name)),
),
].sort();
}
async function cleanIncrementalCaches({
repoRoot,
apply = false,
processNames,
} = {}) {
const auditBefore = await auditRustBuildCache({ repoRoot });
const resolved = await resolveManagedCacheTargets(repoRoot);
if (!apply) {
return {
schemaVersion: 'genarrative.rust-build-cache-clean.v1',
applied: false,
freedBytes: 0,
plannedBytes: auditBefore.incrementalBytes,
targets: resolved.targets.map((target) => target.incrementalRelativePath),
auditBefore,
};
}
const activeProcesses = activeRustBuildProcesses(
processNames ?? readActiveProcessNames(),
);
if (activeProcesses.length > 0) {
throw new Error(
`检测到活跃 Rust 构建进程,拒绝清理: ${activeProcesses.join('')}`,
);
}
for (const target of resolved.targets) {
await assertManagedPath(resolved.repoRoot, target.incrementalRelativePath);
if (!(await pathExists(target.incrementalPath))) continue;
await rm(target.incrementalPath, {
recursive: true,
force: false,
maxRetries: 3,
retryDelay: 250,
});
if (await pathExists(target.incrementalPath)) {
throw new Error(
`增量缓存删除后仍存在: ${target.incrementalRelativePath}`,
);
}
}
const auditAfter = await auditRustBuildCache({ repoRoot });
return {
schemaVersion: 'genarrative.rust-build-cache-clean.v1',
applied: true,
freedBytes: Math.max(0, auditBefore.totalBytes - auditAfter.totalBytes),
plannedBytes: auditBefore.incrementalBytes,
targets: resolved.targets.map((target) => target.incrementalRelativePath),
auditBefore,
auditAfter,
};
}
function parseArguments(argv) {
const options = {
cleanIncremental: false,
apply: false,
json: false,
maxGiB: DEFAULT_MAX_GIB,
};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--clean-incremental') {
options.cleanIncremental = true;
} else if (argument === '--apply') {
options.apply = true;
} else if (argument === '--json') {
options.json = true;
} else if (argument === '--max-gib') {
const value = Number(argv[index + 1]);
if (!Number.isFinite(value) || value <= 0) {
throw new Error('--max-gib 必须是正数');
}
options.maxGiB = value;
index += 1;
} else {
throw new Error(`未知参数: ${argument}`);
}
}
if (options.apply && !options.cleanIncremental) {
throw new Error('--apply 只能与 --clean-incremental 一起使用');
}
return options;
}
function formatGiB(bytes) {
return (bytes / GIB).toFixed(2);
}
function printAudit(audit) {
for (const target of audit.targets) {
console.log(
`[rust-cache] ${target.id}: target=${formatGiB(target.target.bytes)} GiB, incremental=${formatGiB(target.incremental.bytes)} GiB`,
);
}
console.log(
`[rust-cache] total=${formatGiB(audit.totalBytes)} GiB, incremental=${formatGiB(audit.incrementalBytes)} GiB, threshold=${formatGiB(audit.maxBytes)} GiB`,
);
if (audit.warning) {
console.warn('[rust-cache] WARNING: Rust 构建缓存已超过本地建议阈值');
}
}
function isDirectExecution() {
return (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
);
}
async function main() {
const options = parseArguments(process.argv.slice(2));
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
);
const result = options.cleanIncremental
? await cleanIncrementalCaches({
repoRoot,
apply: options.apply,
})
: await auditRustBuildCache({
repoRoot,
maxBytes: options.maxGiB * GIB,
});
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
if (options.cleanIncremental) {
printAudit(result.auditBefore);
if (!result.applied) {
console.log(
'[rust-cache] dry-run未删除任何文件追加 --apply 才会清理固定 incremental 目录。',
);
return;
}
printAudit(result.auditAfter);
console.log(`[rust-cache] freed=${formatGiB(result.freedBytes)} GiB`);
return;
}
printAudit(result);
}
export {
ACTIVE_BUILD_PROCESS_NAMES,
activeRustBuildProcesses,
assertManagedPath,
auditRustBuildCache,
cleanIncrementalCaches,
DEFAULT_MAX_GIB,
formatGiB,
MANAGED_CACHE_TARGETS,
parseArguments,
parseWindowsTaskList,
resolveManagedCacheTargets,
};
if (isDirectExecution()) {
main().catch((error) => {
console.error(
`[rust-cache] ${error instanceof Error ? error.message : String(error)}`,
);
process.exitCode = 1;
});
}