治理Rust构建缓存膨胀
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

关闭测试 profile 的增量编译并对齐 AGC 开发调试配置

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

补齐安全测试、开发运维说明与团队踩坑记录
This commit is contained in:
Git Hooks Test
2026-08-22 11:52:14 +08:00
parent d83551dd65
commit 00b78c9701
7 changed files with 705 additions and 0 deletions
+396
View File
@@ -0,0 +1,396 @@
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;
});
}
+253
View File
@@ -0,0 +1,253 @@
import assert from 'node:assert/strict';
import {
mkdir,
mkdtemp,
readFile,
rm,
symlink,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
activeRustBuildProcesses,
auditRustBuildCache,
cleanIncrementalCaches,
parseArguments,
parseWindowsTaskList,
} from './rust-build-cache.mjs';
async function createFixture(t) {
const root = await mkdtemp(path.join(os.tmpdir(), 'genarrative-rust-cache-'));
t.after(() => rm(root, { recursive: true, force: true }));
await mkdir(path.join(root, 'server-rs', 'target', 'debug', 'incremental'), {
recursive: true,
});
await mkdir(path.join(root, 'server-rs', 'target', 'debug', 'deps'), {
recursive: true,
});
await mkdir(
path.join(
root,
'apps',
'ai-game-creator-shell',
'src-tauri',
'target',
'debug',
'incremental',
),
{ recursive: true },
);
await mkdir(
path.join(
root,
'apps',
'ai-game-creator-shell',
'src-tauri',
'target',
'release',
),
{ recursive: true },
);
await writeFile(path.join(root, 'package.json'), '{}');
await writeFile(path.join(root, 'server-rs', 'Cargo.toml'), '[workspace]');
await writeFile(
path.join(root, 'apps', 'ai-game-creator-shell', 'src-tauri', 'Cargo.toml'),
'[package]\nname="fixture"\nversion="0.0.0"',
);
await writeFile(
path.join(
root,
'server-rs',
'target',
'debug',
'incremental',
'server.bin',
),
Buffer.alloc(17),
);
await writeFile(
path.join(root, 'server-rs', 'target', 'debug', 'deps', 'keep.rlib'),
Buffer.alloc(5),
);
await writeFile(
path.join(
root,
'apps',
'ai-game-creator-shell',
'src-tauri',
'target',
'debug',
'incremental',
'agc.bin',
),
Buffer.alloc(23),
);
await writeFile(
path.join(
root,
'apps',
'ai-game-creator-shell',
'src-tauri',
'target',
'release',
'keep.exe',
),
Buffer.alloc(7),
);
return root;
}
test('audit is read-only and clean requires explicit apply', async (t) => {
const root = await createFixture(t);
const audit = await auditRustBuildCache({ repoRoot: root, maxBytes: 1 });
assert.equal(audit.incrementalBytes, 40);
assert.equal(audit.warning, true);
const dryRun = await cleanIncrementalCaches({ repoRoot: root });
assert.equal(dryRun.applied, false);
assert.equal(dryRun.plannedBytes, 40);
assert.equal(
(
await readFile(
path.join(
root,
'server-rs',
'target',
'debug',
'incremental',
'server.bin',
),
)
).length,
17,
);
});
test('apply removes only fixed incremental directories and is idempotent', async (t) => {
const root = await createFixture(t);
const result = await cleanIncrementalCaches({
repoRoot: root,
apply: true,
processNames: [],
});
assert.equal(result.applied, true);
assert.equal(result.auditAfter.incrementalBytes, 0);
assert.equal(
(
await readFile(
path.join(root, 'server-rs', 'target', 'debug', 'deps', 'keep.rlib'),
)
).length,
5,
);
assert.equal(
(
await readFile(
path.join(
root,
'apps',
'ai-game-creator-shell',
'src-tauri',
'target',
'release',
'keep.exe',
),
)
).length,
7,
);
const replay = await cleanIncrementalCaches({
repoRoot: root,
apply: true,
processNames: [],
});
assert.equal(replay.freedBytes, 0);
});
test('apply fails closed while cargo or rustc is active', async (t) => {
const root = await createFixture(t);
await assert.rejects(
cleanIncrementalCaches({
repoRoot: root,
apply: true,
processNames: ['cargo.exe', 'node.exe'],
}),
/活跃 Rust 构建进程/u,
);
assert.equal(
(
await readFile(
path.join(
root,
'server-rs',
'target',
'debug',
'incremental',
'server.bin',
),
)
).length,
17,
);
});
test('managed paths reject a linked target directory', async (t) => {
const root = await createFixture(t);
const incremental = path.join(
root,
'apps',
'ai-game-creator-shell',
'src-tauri',
'target',
'debug',
'incremental',
);
const outside = await mkdtemp(
path.join(os.tmpdir(), 'genarrative-rust-cache-outside-'),
);
t.after(() => rm(outside, { recursive: true, force: true }));
await rm(incremental, { recursive: true });
try {
await symlink(
outside,
incremental,
process.platform === 'win32' ? 'junction' : 'dir',
);
} catch (error) {
if (error?.code === 'EPERM') {
t.skip('current Windows token cannot create a junction fixture');
return;
}
throw error;
}
await assert.rejects(
auditRustBuildCache({ repoRoot: root }),
/符号链接或 junction/u,
);
});
test('argument and process parsing keep the destructive boundary explicit', () => {
assert.deepEqual(parseArguments([]), {
cleanIncremental: false,
apply: false,
json: false,
maxGiB: 120,
});
assert.equal(parseArguments(['--clean-incremental', '--apply']).apply, true);
assert.throws(() => parseArguments(['--apply']), /只能与/u);
assert.throws(() => parseArguments(['--root', 'C:\\']), /未知参数/u);
assert.deepEqual(
activeRustBuildProcesses(['Cargo.EXE', 'node.exe', 'rustc.exe']),
['cargo.exe', 'rustc.exe'],
);
assert.deepEqual(
parseWindowsTaskList(
'"cargo.exe","1","Console","1","10 K"\r\n"node.exe","2"',
),
['cargo.exe', 'node.exe'],
);
});