diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 5edfecbe5..a4ddb7a95 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -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 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 358f0ca00..44b2302fa 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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` 必须另做并发启动基准,不得为节省磁盘直接改变生产产物路径。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index bfe45f8e9..eb6a5b204 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -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 进程消费队列。 diff --git a/package.json b/package.json index b24b5f28c..696143ad5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/rust-build-cache.mjs b/scripts/rust-build-cache.mjs new file mode 100644 index 000000000..1df71c2e6 --- /dev/null +++ b/scripts/rust-build-cache.mjs @@ -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; + }); +} diff --git a/scripts/rust-build-cache.test.mjs b/scripts/rust-build-cache.test.mjs new file mode 100644 index 000000000..8ba9b84d2 --- /dev/null +++ b/scripts/rust-build-cache.test.mjs @@ -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'], + ); +}); diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 4684a8528..6ef1cca89 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -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,平衡编译时间和性能