治理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
@@ -64,3 +64,17 @@ libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] }
[profile.dev]
opt-level = 0
debug = 1
codegen-units = 256
lto = "off"
incremental = true
[profile.test]
opt-level = 0
debug = 1
codegen-units = 256
lto = "off"
incremental = false
@@ -4817,3 +4817,10 @@
- 原因:MCP 子进程继承隔离 HOME/AppData 和 Codex 进程约束;把真实浏览器或 GUI 登录态硬塞给子进程既不稳定,也扩大凭据边界。
- 处理:STDIO MCP 只做 schema 与协议适配;浏览器和付费美术通过随机 loopback 工具桥回到持有项目、登录态和正常桌面环境的客户端主进程。桥只绑定当前项目、限制请求大小和审核工具名,返回脱敏文本与有界 PNG。
- 验证:必须从真实 Codex thread 发起 MCP 调用并观察 desktop/mobile `readyState=complete` 与两张截图;直接运行 MCP 二进制成功不能替代该链路。
## 独立 Cargo workspace 的测试增量缓存会吞噬数百 GiB(2026-08-22)
- 现象:`server-rs/target` 与 AGC `src-tauri/target` 合计超过 `231 GiB`;其中两个 `debug/incremental` 约 `158.5 GiB`,server-rs 累积 `1298` 个增量会话目录,AGC 累积 `100` 个。
- 原因:两个 Cargo workspace 拥有独立 target 和锁文件,AGC 又以 path dependency 复用若干 server-rs crate;更主要的是全量 / 分组 `cargo test` 继承增量编译,每组 crate / feature / profile hash 都可以留下新会话,Cargo 不会按仓库期望自动收缩这些历史目录。
- 处理:保留两个 workspace 的产品 / 发布边界;两边 `[profile.test]` 关闭 incremental 并固定 `debug=1`,AGC dev profile 与 server-rs 对齐调试信息级别。日常用 `npm run audit:rust-build-cache` 只读核对;需要回收时先停止 Cargo / rustc,再显式运行 `npm run clean:rust-incremental -- --apply`,只删两个固定增量目录。
- 验证:清理前后各跑一次只读审计并核对磁盘可用空间;分别运行 server-rs 与 AGC 定向 `cargo test`,确认 test profile 不再生成持久 `debug/incremental` 堆积。共享 `CARGO_TARGET_DIR` 必须另做并发启动基准,不得为节省磁盘直接改变生产产物路径。
@@ -64,6 +64,31 @@ Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查
Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 会用空的 `RUSTC_WRAPPER` / `CARGO_BUILD_RUSTC_WRAPPER` 覆盖 `server-rs/.cargo/config.toml` 里的 `sccache`,从而直连真实 `rustc`。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`;Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志是否出现该错误,再确认脚本注入的 wrapper 为空。
### 本地 Rust 构建缓存与磁盘上限
`server-rs/Cargo.toml` 和 `apps/ai-game-creator-shell/src-tauri/Cargo.toml` 是两个独立 Cargo workspace;AGC 会以 path dependency 复用 `agent-runtime-core`、`platform-llm`、`platform-agent` 和 `shared-contracts`,但两边默认仍分别写入 `server-rs/target` 与 `apps/ai-game-creator-shell/src-tauri/target`。这个代码和锁文件边界继续保留,不为节省磁盘直接合并 workspace;生产构建脚本和 Tauri 发布还依赖当前 manifest / lock / target 身份。
一次性 `cargo test` 不应长期保留增量缓存。两个 workspace 的 `[profile.test]` 固定 `incremental = false`、`debug = 1`;AGC `[profile.dev]` 与 `server-rs` 对齐 `debug = 1`、`codegen-units = 256` 和非 LTO 的开发口径,保留交互式 `cargo run` 的增量编译。这样全量测试不再按每组 feature / crate hash 累积大量 `debug/incremental` 目录,同时不把每次开发启动都退化为冷编译。
仓库统一使用以下本地工具:
```bash
# 默认只读:统计两套 target 和 incremental 体积,超过默认阈值时警告
npm run audit:rust-build-cache
# 仍然只读:列出本次会清理的两个固定目录
npm run clean:rust-incremental
# 显式写入:仅删除两个 debug/incremental,不删 deps、release、源码或锁文件
npm run clean:rust-incremental -- --apply
```
审计结果为跨平台可复现的文件逻辑字节数,默认警告阈值为 `120 GiB`;Windows 稀疏文件、压缩或分配单元可使该数值与磁盘物理占用不完全一致。清理后同时使用操作系统剩余空间复核真实回收量,不把逻辑字节差值冒充物理空间。
清理工具只接受仓库内两个编译目标的固定 `debug/incremental` 路径,默认 dry-run;路径越界、根目录或目标是符号链接 / junction、仓库标记缺失,或存在活跃 `cargo` / `rustc` 进程时均失败关闭。清理后的首次测试或开发构建会变慢,属于可再生缓存预期行为。
共享 `CARGO_TARGET_DIR` 不作为默认第一阶段方案:`npm run agc` 会并发启动 server-rs 和 Tauri Cargo,两个 workspace 指向同一 target 会引入构建锁串行化。需要共享时必须先用冷 / 热启动基准证明依赖复用收益大于并发锁等待,并保持生产 `CARGO_TARGET_DIR` 显式覆盖和现有发布产物路径不变。
Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg,`npm run dev` / `npm run dev:api-server` 会自动把该目录加入本次 `api-server` 子进程 `Path`,并注入 `CHARACTER_ANIMATION_FFMPEG_PATH` / `CHARACTER_ANIMATION_FFPROBE_PATH` 的绝对路径。这样即使外层终端或长期运行的 dev 进程是在安装 FFmpeg 之前启动,角色动画抽帧也不会继续因为 `ffmpeg: program not found` 失败;若手动配置了上述环境变量或 `GENARRATIVE_CHARACTER_ANIMATION_*` 前缀变量,显式配置优先。
开发态 `npm run dev` 与 `npm run dev:api-server` 都会注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号。完整 `npm run dev` 会强制父 API 使用 `GENARRATIVE_PROCESS_ROLE=all`,忽略外层显式角色,确保本地 `api-server` 同时监听 HTTP 并消费外部生成队列;只有单模块 `npm run dev:api-server` 会保留显式 `GENARRATIVE_PROCESS_ROLE`,未设置时默认为 `all`。`all` 不内嵌 BgFilter worker;启动器总是先启动并验活独立 `GENARRATIVE_PROCESS_ROLE=bgfilter-worker` 进程,再启动父 API,并向两者注入同一个内部 base URL / Token。Linux 本地默认 `all` 角色启动前,dev 脚本会停止当前仓库、同一个 SpacetimeDB server / database 下遗留的 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程,避免旧 worker 二进制继续抢同一条队列并在业务写回时制造 procedure 超时;显式拆分 `api` / `external-generation-worker` 做生产式验证时不会触发这项清理。生产环境仍按 `api-server` 配置默认关闭密码自动注册,并由独立 worker 进程消费队列。
+3
View File
@@ -44,6 +44,9 @@
"build:raw": "node scripts/vite-cli.mjs build",
"preview": "node scripts/vite-cli.mjs preview",
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"audit:rust-build-cache": "node scripts/rust-build-cache.mjs",
"clean:rust-incremental": "node scripts/rust-build-cache.mjs --clean-incremental",
"test:rust-build-cache": "node --test scripts/rust-build-cache.test.mjs",
"check:encoding": "node scripts/check-encoding.mjs",
"check:git-hooks": "node --test scripts/git-hooks.test.mjs",
"check:repository-ci": "bash scripts/check-repository-ci.sh",
+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'],
);
});
+7
View File
@@ -154,6 +154,13 @@ codegen-units = 256 # 多单元并行 CodeGen
lto = "off" # dev 别开 LTO
incremental = true
[profile.test]
opt-level = 0
debug = 1
codegen-units = 256
lto = "off"
incremental = false
[profile.release]
opt-level = 3 # 最大优化等级
lto = "thin" # 启用 Thin LTO,平衡编译时间和性能