diff --git a/scripts/cargo-target-gc.mjs b/scripts/cargo-target-gc.mjs index c96866aca..8494cf597 100644 --- a/scripts/cargo-target-gc.mjs +++ b/scripts/cargo-target-gc.mjs @@ -4,17 +4,20 @@ * * Safe policy: * - incremental: keep latest crate root (+ latest session) - * - .fingerprint: never mtime-prune (Cargo needs multiple concurrent units) - * - deps: delete only hashes with no remaining fingerprint directory + * - .fingerprint: keep the latest generation per Cargo unit identity, plus a + * short grace window for recently-built feature variants + * - deps / build: delete hashes with no remaining fingerprint directory * * Env: * BITFUN_TARGET_GC=0 disable * BITFUN_TARGET_GC_DRY_RUN=1 report only + * BITFUN_TARGET_GC_MIN_AGE_HOURS=24 */ import { execFileSync } from 'node:child_process'; import { existsSync, lstatSync, + readFileSync, readdirSync, rmSync, statSync, @@ -27,6 +30,7 @@ const DEFAULT_ROOT = join(__dirname, '..'); const FINGERPRINT_HASH_RE = /^(.+)-([0-9a-f]{16})$/; const DEPS_HASH_RE = /^.+?-([0-9a-f]{16})(?:[.-]|$)/; const SESSION_DIR_RE = /^s-/; +const DEFAULT_FINGERPRINT_MIN_AGE_MS = 24 * 60 * 60 * 1_000; export function splitIncrementalCrateDir(name) { const idx = name.lastIndexOf('-'); @@ -63,6 +67,17 @@ function safeStatMtimeMs(path) { } } +function fingerprintActivityMtimeMs(fingerprintPath) { + // Cargo refreshes invoked.timestamp even when a fingerprint is reused, while + // the directory mtime can remain unchanged for days. The stamp is therefore + // the authoritative activity signal; the directory time covers incomplete + // fingerprints that do not have a stamp yet. + return Math.max( + safeStatMtimeMs(join(fingerprintPath, 'invoked.timestamp')), + safeStatMtimeMs(fingerprintPath) + ); +} + function listDirs(dir) { if (!existsSync(dir)) { return []; @@ -149,24 +164,95 @@ export function planIncrementalPrune(incrementalDir, { keepSessions = 1 } = {}) return toDelete; } +function readFingerprintUnitIdentity(fingerprintPath, stem) { + const jsonNames = listFiles(fingerprintPath) + .filter((name) => name.endsWith('.json')) + .sort(); + if (jsonNames.length === 0) { + return null; + } + + const units = []; + for (const name of jsonNames) { + try { + const metadata = JSON.parse(readFileSync(join(fingerprintPath, name), 'utf8')); + // These fields identify the Cargo unit itself. Intentionally exclude + // features, dependency hashes, rustflags and config: changes to those + // fields create a new generation of the same unit, which is precisely + // the history this GC needs to bound. + units.push([ + name, + metadata.target ?? null, + metadata.profile ?? null, + metadata.path ?? null, + metadata.compile_kind ?? null, + ]); + } catch { + // An unreadable fingerprint may be in the middle of being written. + // Defer to the caller's age-based incomplete-entry policy. + return null; + } + } + + return JSON.stringify([stem, units]); +} + /** - * Collect fingerprint metadata hashes that still exist on disk. + * Keep the latest generation of each distinct Cargo unit. * - * Important: do NOT delete fingerprint directories by "keep newest N per stem". - * Cargo routinely keeps multiple concurrent units for one package (lib, - * build-script, feature variants). mtime is not a valid liveness signal, and - * deleting a still-referenced fingerprint + its deps forces a cold rebuild of - * that crate (and often a large share of the graph) on the next desktop:dev. + * A package stem alone is unsafe because Cargo can concurrently own lib, + * test-lib, bin and build-script units. Cargo's fingerprint JSON supplies a + * stable unit identity; feature/dependency changes are treated as generations + * of that unit. Recently-created generations stay inside a grace window so a + * just-finished multi-command workflow remains warm. */ -export function planFingerprintPrune(fingerprintDir) { +export function planFingerprintPrune( + fingerprintDir, + { now = Date.now(), minAgeMs = DEFAULT_FINGERPRINT_MIN_AGE_MS } = {} +) { + const toDelete = []; const keptHashes = new Set(); + const groups = new Map(); + for (const name of listDirs(fingerprintDir)) { const split = splitFingerprintDir(name); - if (split) { - keptHashes.add(split.hash); + if (!split) { + continue; + } + + const path = join(fingerprintDir, name); + const mtimeMs = fingerprintActivityMtimeMs(path); + const unitIdentity = readFingerprintUnitIdentity(path, split.stem); + if (!unitIdentity) { + // Incomplete fingerprints are safe to remove once old; unreadable recent + // entries may still be in flight and remain protected by the grace period. + if (now - mtimeMs >= minAgeMs) { + toDelete.push(path); + } else { + keptHashes.add(split.hash); + } + continue; } + + const entries = groups.get(unitIdentity) || []; + entries.push({ path, hash: split.hash, mtimeMs }); + groups.set(unitIdentity, entries); } - return { toDelete: [], keptHashes }; + + for (const entries of groups.values()) { + entries.sort((a, b) => b.mtimeMs - a.mtimeMs); + entries.forEach((entry, index) => { + const isLatest = index === 0; + const isRecent = now - entry.mtimeMs < minAgeMs; + if (isLatest || isRecent) { + keptHashes.add(entry.hash); + } else { + toDelete.push(entry.path); + } + }); + } + + return { toDelete, keptHashes }; } export function planDepsOrphanPrune(depsDir, keptHashes) { @@ -190,6 +276,17 @@ export function planDepsOrphanPrune(depsDir, keptHashes) { return toDelete; } +export function planBuildOrphanPrune(buildDir, keptHashes) { + const toDelete = []; + for (const name of listDirs(buildDir)) { + const split = splitFingerprintDir(name); + if (split && !keptHashes.has(split.hash)) { + toDelete.push(join(buildDir, name)); + } + } + return toDelete; +} + export function resolveProfileDir(targetDir, { profile = 'debug', triple = null } = {}) { if (triple) { return join(targetDir, triple, profile); @@ -242,20 +339,59 @@ export function isCompilerBusy({ exec = execFileSync, platform = process.platfor return false; } -export function collectGcPlan(profileDir) { +export function isTargetProfileBusy({ + profileDir, + exec = execFileSync, + platform = process.platform, +} = {}) { + if (platform !== 'win32' && profileDir) { + const lockPaths = [ + join(profileDir, '.cargo-lock'), + join(profileDir, '.cargo-build-lock'), + join(profileDir, '.cargo-artifact-lock'), + ].filter((path) => existsSync(path)); + + if (lockPaths.length > 0) { + try { + const output = exec('lsof', ['-t', ...lockPaths], { encoding: 'utf8' }); + return Boolean(String(output).trim()); + } catch (error) { + // lsof exits 1 when none of the named files are open. That is a scoped, + // authoritative "not busy" result even if another worktree is compiling. + if (error?.status === 1) { + return false; + } + // lsof is optional; fall through to the conservative global fallback. + } + } + } + + return isCompilerBusy({ exec, platform }); +} + +export function collectGcPlan( + profileDir, + { now = Date.now(), fingerprintMinAgeMs = DEFAULT_FINGERPRINT_MIN_AGE_MS } = {} +) { const incrementalDir = join(profileDir, 'incremental'); const fingerprintDir = join(profileDir, '.fingerprint'); const depsDir = join(profileDir, 'deps'); + const buildDir = join(profileDir, 'build'); const incremental = planIncrementalPrune(incrementalDir); - const fingerprintPlan = planFingerprintPrune(fingerprintDir); + const fingerprintPlan = planFingerprintPrune(fingerprintDir, { + now, + minAgeMs: fingerprintMinAgeMs, + }); const deps = planDepsOrphanPrune(depsDir, fingerprintPlan.keptHashes); + const build = planBuildOrphanPrune(buildDir, fingerprintPlan.keptHashes); return { incremental, fingerprint: fingerprintPlan.toDelete, deps, - all: [...incremental, ...fingerprintPlan.toDelete, ...deps], + build, + all: [...incremental, ...fingerprintPlan.toDelete, ...deps, ...build], }; } @@ -267,31 +403,33 @@ export function runCargoTargetGc(options = {}) { : join(rootDir, 'target'), profile = 'debug', triple = null, - dryRun = ['1', 'true', 'yes'].includes( - String(process.env.BITFUN_TARGET_GC_DRY_RUN || options.dryRun || '').toLowerCase() - ), - enabled = !['0', 'false', 'no'].includes( - String(process.env.BITFUN_TARGET_GC ?? '1').toLowerCase() - ), skipIfBusy = true, logger = console, } = options; + const dryRun = + options.dryRun ?? + ['1', 'true', 'yes'].includes( + String(process.env.BITFUN_TARGET_GC_DRY_RUN ?? '').toLowerCase() + ); + const enabled = + options.enabled ?? + !['0', 'false', 'no'].includes( + String(process.env.BITFUN_TARGET_GC ?? '1').toLowerCase() + ); + const configuredMinAgeHours = Number( + options.fingerprintMinAgeHours ?? + process.env.BITFUN_TARGET_GC_MIN_AGE_HOURS ?? + DEFAULT_FINGERPRINT_MIN_AGE_MS / (60 * 60 * 1_000) + ); + const fingerprintMinAgeMs = + Number.isFinite(configuredMinAgeHours) && configuredMinAgeHours >= 0 + ? configuredMinAgeHours * 60 * 60 * 1_000 + : DEFAULT_FINGERPRINT_MIN_AGE_MS; if (!enabled) { return { skipped: true, reason: 'disabled', removed: [] }; } - if (skipIfBusy) { - const busyDeadline = Date.now() + 15_000; - while (isCompilerBusy()) { - if (Date.now() >= busyDeadline) { - logger.info?.('[target-gc] Skipping: cargo/rustc still running'); - return { skipped: true, reason: 'compiler-busy', removed: [] }; - } - sleepMs(500); - } - } - const profileDir = resolveProfileDir(targetDir, { profile, triple }); if (!existsSync(profileDir)) { return { skipped: true, reason: 'missing-profile-dir', removed: [], profileDir }; @@ -306,7 +444,18 @@ export function runCargoTargetGc(options = {}) { return { skipped: true, reason: 'stat-failed', removed: [], profileDir }; } - const plan = collectGcPlan(profileDir); + if (skipIfBusy) { + const busyDeadline = Date.now() + 15_000; + while (isTargetProfileBusy({ profileDir })) { + if (Date.now() >= busyDeadline) { + logger.info?.(`[target-gc] Skipping: Cargo still uses ${profileDir}`); + return { skipped: true, reason: 'compiler-busy', removed: [], profileDir }; + } + sleepMs(500); + } + } + + const plan = collectGcPlan(profileDir, { fingerprintMinAgeMs }); const removed = []; for (const path of plan.all) { try { @@ -327,6 +476,7 @@ export function runCargoTargetGc(options = {}) { incremental: plan.incremental.length, fingerprint: plan.fingerprint.length, deps: plan.deps.length, + build: plan.build.length, total: plan.all.length, }, }; @@ -334,7 +484,8 @@ export function runCargoTargetGc(options = {}) { if (summary.counts.total > 0) { logger.info?.( `[target-gc] ${dryRun ? 'Would remove' : 'Removed'} ${summary.counts.total} stale cache path(s) ` + - `(incremental=${summary.counts.incremental}, fingerprint=${summary.counts.fingerprint}, deps=${summary.counts.deps}) ` + + `(incremental=${summary.counts.incremental}, fingerprint=${summary.counts.fingerprint}, ` + + `deps=${summary.counts.deps}, build=${summary.counts.build}) ` + `under ${profileDir}` ); } else { @@ -345,7 +496,7 @@ export function runCargoTargetGc(options = {}) { } export function parseGcArgs(argv) { - const args = { profile: 'debug', triple: null, dryRun: false, help: false }; + const args = { profile: 'debug', triple: null, dryRun: undefined, help: false }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === '--help' || arg === '-h') { @@ -375,6 +526,7 @@ Prune stale Cargo incremental / fingerprint / deps caches for one profile. Environment: BITFUN_TARGET_GC=0 disable BITFUN_TARGET_GC_DRY_RUN=1 dry-run + BITFUN_TARGET_GC_MIN_AGE_HOURS=24 `); } diff --git a/scripts/cargo-target-gc.test.mjs b/scripts/cargo-target-gc.test.mjs index 5c981cec3..6529380c6 100644 --- a/scripts/cargo-target-gc.test.mjs +++ b/scripts/cargo-target-gc.test.mjs @@ -6,6 +6,8 @@ import test from 'node:test'; import { collectGcPlan, extractDepsArtifactHash, + isTargetProfileBusy, + parseGcArgs, profileFromTauriBuildArgs, runCargoTargetGc, selectStaleByMtime, @@ -36,6 +38,27 @@ function touchFile(path, mtimeMs) { utimesSync(path, date, date); } +function touchFingerprint( + profileDir, + dirName, + unitName, + metadata, + mtimeMs, + invokedMtimeMs = mtimeMs +) { + const fingerprintDir = join(profileDir, '.fingerprint', dirName); + mkdirSync(fingerprintDir, { recursive: true }); + const unitPath = join(fingerprintDir, `${unitName}.json`); + writeFileSync(unitPath, JSON.stringify(metadata)); + const invokedPath = join(fingerprintDir, 'invoked.timestamp'); + writeFileSync(invokedPath, ''); + const date = new Date(mtimeMs); + utimesSync(unitPath, date, date); + const invokedDate = new Date(invokedMtimeMs); + utimesSync(invokedPath, invokedDate, invokedDate); + utimesSync(fingerprintDir, date, date); +} + test('split helpers parse cargo cache names', () => { assert.deepEqual(splitIncrementalCrateDir('bitfun_core-3vwcc7dt79hqo'), { crate: 'bitfun_core', @@ -64,11 +87,12 @@ test('selectStaleByMtime keeps newest entries', () => { assert.deepEqual(new Set(stale), new Set(['a', 'c'])); }); -test('collectGcPlan prunes incremental only and never drops live fingerprints', () => { +test('collectGcPlan keeps distinct Cargo units while pruning stale generations', () => { const { root, cleanup } = fixtureRoot(); try { const profileDir = join(root, 'debug'); const now = Date.now(); + const dayMs = 24 * 60 * 60 * 1_000; touchDir(join(profileDir, 'incremental', 'bitfun_core-oldhash1'), now - 3_000); touchDir(join(profileDir, 'incremental', 'bitfun_core-newhash2'), now); @@ -81,23 +105,56 @@ test('collectGcPlan prunes incremental only and never drops live fingerprints', now ); - // Multiple fingerprint units for the same stem can all be live for Cargo - // (lib / build-script / feature variants). GC must not mtime-prune them. - touchDir(join(profileDir, '.fingerprint', 'bitfun-core-aaaaaaaaaaaaaaaa'), now - 3_000); - touchDir(join(profileDir, '.fingerprint', 'bitfun-core-bbbbbbbbbbbbbbbb'), now); - touchDir(join(profileDir, '.fingerprint', 'syn-1111111111111111'), now - 4_000); - touchDir(join(profileDir, '.fingerprint', 'syn-2222222222222222'), now - 2_000); - touchDir(join(profileDir, '.fingerprint', 'syn-3333333333333333'), now); + const libUnit = { target: 1, profile: 2, path: 3, compile_kind: 0 }; + touchFingerprint( + profileDir, + 'bitfun-core-aaaaaaaaaaaaaaaa', + 'lib-bitfun_core', + { ...libUnit, features: '["old"]' }, + now - 3 * dayMs + ); + touchFingerprint( + profileDir, + 'bitfun-core-bbbbbbbbbbbbbbbb', + 'lib-bitfun_core', + { ...libUnit, features: '["latest"]' }, + now + ); + // A fingerprint reused by Cargo stays warm through invoked.timestamp even + // when the directory itself is old. + touchFingerprint( + profileDir, + 'bitfun-core-cccccccccccccccc', + 'lib-bitfun_core', + { ...libUnit, features: '["recent"]' }, + now - 3 * dayMs, + now - 60 * 60 * 1_000 + ); + // Test units are a distinct Cargo unit and remain independently reusable. + touchFingerprint( + profileDir, + 'bitfun-core-dddddddddddddddd', + 'test-lib-bitfun_core', + { target: 1, profile: 4, path: 3, compile_kind: 0 }, + now - 4 * dayMs + ); + // An old incomplete fingerprint is abandoned output. + touchDir( + join(profileDir, '.fingerprint', 'bitfun-core-eeeeeeeeeeeeeeee'), + now - 5 * dayMs + ); - touchFile(join(profileDir, 'deps', 'libbitfun_core-aaaaaaaaaaaaaaaa.rlib'), now - 3_000); + touchFile(join(profileDir, 'deps', 'libbitfun_core-aaaaaaaaaaaaaaaa.rlib'), now); touchFile(join(profileDir, 'deps', 'libbitfun_core-bbbbbbbbbbbbbbbb.rlib'), now); - touchFile(join(profileDir, 'deps', 'libsyn-1111111111111111.rlib'), now - 4_000); - touchFile(join(profileDir, 'deps', 'libsyn-2222222222222222.rlib'), now - 2_000); - touchFile(join(profileDir, 'deps', 'libsyn-3333333333333333.rlib'), now); - // True orphan: no matching fingerprint directory remains. - touchFile(join(profileDir, 'deps', 'liborphan-ffffffffffffffff.rlib'), now - 5_000); + touchFile(join(profileDir, 'deps', 'libbitfun_core-cccccccccccccccc.rlib'), now); + touchFile(join(profileDir, 'deps', 'libbitfun_core-dddddddddddddddd.rlib'), now); + touchFile(join(profileDir, 'deps', 'libbitfun_core-eeeeeeeeeeeeeeee.rlib'), now); - const plan = collectGcPlan(profileDir); + touchDir(join(profileDir, 'build', 'bitfun-core-aaaaaaaaaaaaaaaa'), now); + touchDir(join(profileDir, 'build', 'bitfun-core-bbbbbbbbbbbbbbbb'), now); + touchDir(join(profileDir, 'build', 'bitfun-core-eeeeeeeeeeeeeeee'), now); + + const plan = collectGcPlan(profileDir, { now, fingerprintMinAgeMs: dayMs }); assert.ok(plan.incremental.some((path) => path.endsWith('bitfun_core-oldhash1'))); assert.ok( @@ -105,20 +162,25 @@ test('collectGcPlan prunes incremental only and never drops live fingerprints', path.includes(`${join('bitfun_core-newhash2', 's-old-session')}`) ) ); - assert.equal(plan.fingerprint.length, 0); - assert.ok( - !plan.deps.some((path) => path.endsWith('libbitfun_core-aaaaaaaaaaaaaaaa.rlib')) - ); - assert.ok(!plan.deps.some((path) => path.endsWith('libsyn-1111111111111111.rlib'))); - assert.ok(!plan.deps.some((path) => path.endsWith('libsyn-2222222222222222.rlib'))); - assert.ok(!plan.deps.some((path) => path.endsWith('libsyn-3333333333333333.rlib'))); - assert.ok(plan.deps.some((path) => path.endsWith('liborphan-ffffffffffffffff.rlib'))); + assert.ok(plan.fingerprint.some((path) => path.endsWith('bitfun-core-aaaaaaaaaaaaaaaa'))); + assert.ok(plan.fingerprint.some((path) => path.endsWith('bitfun-core-eeeeeeeeeeeeeeee'))); + assert.ok(!plan.fingerprint.some((path) => path.endsWith('bitfun-core-bbbbbbbbbbbbbbbb'))); + assert.ok(!plan.fingerprint.some((path) => path.endsWith('bitfun-core-cccccccccccccccc'))); + assert.ok(!plan.fingerprint.some((path) => path.endsWith('bitfun-core-dddddddddddddddd'))); + assert.ok(plan.deps.some((path) => path.endsWith('libbitfun_core-aaaaaaaaaaaaaaaa.rlib'))); + assert.ok(plan.deps.some((path) => path.endsWith('libbitfun_core-eeeeeeeeeeeeeeee.rlib'))); + assert.ok(!plan.deps.some((path) => path.endsWith('libbitfun_core-bbbbbbbbbbbbbbbb.rlib'))); + assert.ok(!plan.deps.some((path) => path.endsWith('libbitfun_core-cccccccccccccccc.rlib'))); + assert.ok(!plan.deps.some((path) => path.endsWith('libbitfun_core-dddddddddddddddd.rlib'))); + assert.ok(plan.build.some((path) => path.endsWith('bitfun-core-aaaaaaaaaaaaaaaa'))); + assert.ok(plan.build.some((path) => path.endsWith('bitfun-core-eeeeeeeeeeeeeeee'))); + assert.ok(!plan.build.some((path) => path.endsWith('bitfun-core-bbbbbbbbbbbbbbbb'))); } finally { cleanup(); } }); -test('runCargoTargetGc keeps fingerprint-backed deps so next cargo can reuse them', () => { +test('runCargoTargetGc prunes old generations and honors dry-run', () => { const { root, cleanup } = fixtureRoot(); try { const targetDir = join(root, 'target'); @@ -126,8 +188,21 @@ test('runCargoTargetGc keeps fingerprint-backed deps so next cargo can reuse the const now = Date.now(); touchDir(join(profileDir, 'incremental', 'bitfun_demo-old'), now - 1_000); touchDir(join(profileDir, 'incremental', 'bitfun_demo-new'), now); - touchDir(join(profileDir, '.fingerprint', 'bitfun-demo-aaaaaaaaaaaaaaaa'), now - 1_000); - touchDir(join(profileDir, '.fingerprint', 'bitfun-demo-bbbbbbbbbbbbbbbb'), now); + const unit = { target: 1, profile: 2, path: 3, compile_kind: 0 }; + touchFingerprint( + profileDir, + 'bitfun-demo-aaaaaaaaaaaaaaaa', + 'lib-bitfun_demo', + unit, + now - 1_000 + ); + touchFingerprint( + profileDir, + 'bitfun-demo-bbbbbbbbbbbbbbbb', + 'lib-bitfun_demo', + unit, + now + ); touchFile(join(profileDir, 'deps', 'libbitfun_demo-aaaaaaaaaaaaaaaa.rlib'), now - 1_000); touchFile(join(profileDir, 'deps', 'libbitfun_demo-bbbbbbbbbbbbbbbb.rlib'), now); touchFile(join(profileDir, 'deps', 'libghost-cccccccccccccccc.rlib'), now - 2_000); @@ -137,6 +212,7 @@ test('runCargoTargetGc keeps fingerprint-backed deps so next cargo can reuse the targetDir, profile: 'debug', dryRun: true, + fingerprintMinAgeHours: 0, skipIfBusy: false, logger: { info() {}, warn() {} }, }); @@ -149,6 +225,7 @@ test('runCargoTargetGc keeps fingerprint-backed deps so next cargo can reuse the targetDir, profile: 'debug', dryRun: false, + fingerprintMinAgeHours: 0, skipIfBusy: false, logger: { info() {}, warn() {} }, }); @@ -157,11 +234,11 @@ test('runCargoTargetGc keeps fingerprint-backed deps so next cargo can reuse the assert.equal(existsSync(join(profileDir, 'incremental', 'bitfun_demo-new')), true); assert.equal( existsSync(join(profileDir, '.fingerprint', 'bitfun-demo-aaaaaaaaaaaaaaaa')), - true + false ); assert.equal( existsSync(join(profileDir, 'deps', 'libbitfun_demo-aaaaaaaaaaaaaaaa.rlib')), - true + false ); assert.equal( existsSync(join(profileDir, 'deps', 'libbitfun_demo-bbbbbbbbbbbbbbbb.rlib')), @@ -173,6 +250,73 @@ test('runCargoTargetGc keeps fingerprint-backed deps so next cargo can reuse the } }); +test('target busy detection scopes Cargo locks to the selected profile', () => { + const { root, cleanup } = fixtureRoot(); + try { + const profileDir = join(root, 'target', 'debug'); + touchFile(join(profileDir, '.cargo-lock'), Date.now()); + + assert.equal( + isTargetProfileBusy({ + profileDir, + platform: 'darwin', + exec(command) { + assert.equal(command, 'lsof'); + return '123\n'; + }, + }), + true + ); + + assert.equal( + isTargetProfileBusy({ + profileDir, + platform: 'darwin', + exec(command) { + assert.equal(command, 'lsof'); + const error = new Error('no open files'); + error.status = 1; + throw error; + }, + }), + false + ); + } finally { + cleanup(); + } +}); + +test('dry-run environment remains effective when CLI omits the flag', () => { + const { root, cleanup } = fixtureRoot(); + const previous = process.env.BITFUN_TARGET_GC_DRY_RUN; + try { + const targetDir = join(root, 'target'); + const profileDir = join(targetDir, 'debug'); + touchDir(join(profileDir, 'incremental', 'bitfun_demo-old'), 1); + touchDir(join(profileDir, 'incremental', 'bitfun_demo-new'), 2); + process.env.BITFUN_TARGET_GC_DRY_RUN = '1'; + + const result = runCargoTargetGc({ + rootDir: root, + targetDir, + profile: 'debug', + skipIfBusy: false, + logger: { info() {}, warn() {} }, + }); + + assert.equal(parseGcArgs([]).dryRun, undefined); + assert.equal(result.dryRun, true); + assert.equal(existsSync(join(profileDir, 'incremental', 'bitfun_demo-old')), true); + } finally { + if (previous === undefined) { + delete process.env.BITFUN_TARGET_GC_DRY_RUN; + } else { + process.env.BITFUN_TARGET_GC_DRY_RUN = previous; + } + cleanup(); + } +}); + test('tauri build argv helpers resolve profile and target', () => { assert.equal(profileFromTauriBuildArgs(['--debug']), 'debug'); assert.equal(profileFromTauriBuildArgs(['--profile', 'release-fast']), 'release-fast'); diff --git a/src/apps/desktop/AGENTS-CN.md b/src/apps/desktop/AGENTS-CN.md index 476cca882..3cc388e6d 100644 --- a/src/apps/desktop/AGENTS-CN.md +++ b/src/apps/desktop/AGENTS-CN.md @@ -55,7 +55,7 @@ pnpm run desktop:preview:debug ## Target 缓存 GC -`desktop:dev`(退出时)、`desktop:preview:debug`(关闭时)以及 `desktop:build*` 会裁剪过期的 `target//incremental`(每个 crate 只留最新根)以及已无对应 `.fingerprint` 的孤儿 `deps`。**不会**按 mtime 删除 fingerprint(否则下次 `desktop:dev` 会冷编译)。手动执行:`pnpm run target:gc -- --profile debug`。禁用:`BITFUN_TARGET_GC=0`;演练:`BITFUN_TARGET_GC_DRY_RUN=1`。 +`desktop:dev`(退出时)、`desktop:preview:debug`(关闭时)以及 `desktop:build*` 会裁剪过期的 `target/` 缓存代际。`incremental` 每个 crate/session 保留最新项;GC 根据 Cargo fingerprint JSON 区分 lib、test、bin、build-script 等构建单元,每个单元保留最新代际,并保留 Cargo 管理的 `invoked.timestamp` 在最近 24 小时内刷新过的全部代际,随后删除失去 fingerprint 的 `deps` 文件和 `build` 目录。忙碌检测只检查所选 profile 的 Cargo 锁文件,因此其他 worktree 的编译不会再阻止清理。手动执行:`pnpm run target:gc -- --profile debug`。禁用:`BITFUN_TARGET_GC=0`;演练:`BITFUN_TARGET_GC_DRY_RUN=1`;可用 `BITFUN_TARGET_GC_MIN_AGE_HOURS` 调整安全窗口。 `release-fast` profile(`Cargo.toml`):继承 `release`,但关闭 LTO、`codegen-units` 提高到 16、启用增量编译。编译速度显著提升,代价是二进制体积增大和边际运行时性能下降。 diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index 8dd3eaded..c8411a10d 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -64,7 +64,7 @@ required. The default dev profile keeps line tables while reducing PDB size. ## Target cache GC -`desktop:dev` (on exit), `desktop:preview:debug` (on shutdown), and `desktop:build*` prune stale `target//incremental` roots (keep latest per crate) and true-orphan `deps` hashes with no matching `.fingerprint` directory. Fingerprints are never mtime-pruned (that forced cold rebuilds). Manual: `pnpm run target:gc -- --profile debug`. Disable with `BITFUN_TARGET_GC=0`; dry-run with `BITFUN_TARGET_GC_DRY_RUN=1`. +`desktop:dev` (on exit), `desktop:preview:debug` (on shutdown), and `desktop:build*` prune stale `target/` cache generations. Incremental roots keep the latest crate/session. Cargo fingerprint JSON identifies distinct lib, test, bin, and build-script units; GC keeps the latest generation of each unit plus every generation whose Cargo-managed `invoked.timestamp` was refreshed within the last 24 hours, then removes orphaned `deps` files and `build` directories. Busy detection is scoped to Cargo lock files in the selected profile, so an unrelated worktree build does not suppress GC. Manual: `pnpm run target:gc -- --profile debug`. Disable with `BITFUN_TARGET_GC=0`; dry-run with `BITFUN_TARGET_GC_DRY_RUN=1`; adjust the grace window with `BITFUN_TARGET_GC_MIN_AGE_HOURS`. `release-fast` profile (`Cargo.toml`): inherits `release` but disables LTO, increases `codegen-units` to 16, enables incremental compilation. Significantly faster at the cost of binary size and marginal runtime performance.