diff --git a/PoolProblem/PoolProblem/AppService.swift b/PoolProblem/PoolProblem/AppService.swift index 9fac6f5..4b5ed1a 100644 --- a/PoolProblem/PoolProblem/AppService.swift +++ b/PoolProblem/PoolProblem/AppService.swift @@ -65,6 +65,13 @@ final class AppService { private let paths: StoragePaths private let snapshotStore: SnapshotStore private let logStore: CleanLogStore + private let ownerCommandRecordStore: OwnerCommandRecordStore + private let knownPnpmTargetsStore: OwnerCommandKnownTargetsStore + private let pnpmRunner: OwnerCommandRunner + private(set) var currentPnpmStoreTarget: OwnerCommandTarget? + private(set) var lastPnpmProbeFailure: OwnerCommandFailure? + private var knownPnpmStorePaths: [String] + private var pnpmHistoryOverflowed: Bool private let growthLedgerStore: GrowthLedgerStore private let recipeSuggestionStore: RecipeSuggestionStore private let cleanupCoordinator: CleanupCoordinator @@ -92,11 +99,40 @@ final class AppService { Int64(config.minimumCleanItemMB * 1_000_000) } - init(state: AppState, paths: StoragePaths = StoragePaths(), automationEnabled: Bool = true) { + init( + state: AppState, paths: StoragePaths = StoragePaths(), + automationEnabled: Bool = true, pnpmRunner: OwnerCommandRunner? = nil + ) { self.state = state self.paths = paths self.snapshotStore = SnapshotStore(paths: paths) self.logStore = CleanLogStore(paths: paths) + self.ownerCommandRecordStore = OwnerCommandRecordStore(paths: paths) + let knownStore = OwnerCommandKnownTargetsStore(paths: paths) + self.knownPnpmTargetsStore = knownStore + let retainedSnapshots: [Snapshot] + var historyLoadFailed = false + do { retainedSnapshots = try SnapshotStore(paths: paths).snapshots() } + catch { retainedSnapshots = []; historyLoadFailed = true } + let knownState: OwnerCommandKnownTargetsStore.State + do { knownState = try knownStore.migrate(snapshots: retainedSnapshots) } + catch { + let saved = (try? knownStore.state()) ?? .init() + let migrated = retainedSnapshots.flatMap { snapshot in + snapshot.items.filter { + $0.recipeID == OwnerCommandRecipe.pnpmStorePrune.id + }.map(\.path) + } + knownState = .init( + paths: Array((saved.paths + migrated).suffix(OwnerCommandKnownTargetsStore.maximumPaths)), + overflowed: true + ) + } + self.knownPnpmStorePaths = knownState.paths + self.pnpmHistoryOverflowed = knownState.overflowed || historyLoadFailed + self.pnpmRunner = pnpmRunner ?? OwnerCommandRunner( + recipe: .pnpmStorePrune, home: paths.homeDirectory + ) self.growthLedgerStore = GrowthLedgerStore(paths: paths) self.recipeSuggestionStore = RecipeSuggestionStore(paths: paths) self.automationEnabled = automationEnabled @@ -218,6 +254,7 @@ final class AppService { } guard let (volume, items, snapshots) = await work.value else { return } state.availableBytes = volume.availableBytes + state.lastOwnerCommandRecord = try? ownerCommandRecordStore.entries().last state.totalBytes = volume.totalBytes state.items = items state.lastScanAt = snapshots.last?.volume.timestamp @@ -258,14 +295,41 @@ final class AppService { let paths = self.paths let cloneRatios = loadConfig().cloneRatios let ageRules = ageDaysByRecipe() - let recipes = activeRecipes() + let pnpmRunner = self.pnpmRunner + let pnpmProbe = await Task.detached(priority: .utility) { + pnpmRunner.probe() + }.value + let pnpmTarget = try? pnpmProbe.get() + if let pnpmTarget { + if let history = try? knownPnpmTargetsStore.record([pnpmTarget.path]) { + knownPnpmStorePaths = history.paths + // A startup read/migration failure may have lost an older + // target even if this later write succeeds. Keep the guard + // closed for this session. + pnpmHistoryOverflowed = pnpmHistoryOverflowed || history.overflowed + } else { + if !knownPnpmStorePaths.contains(pnpmTarget.path) { + knownPnpmStorePaths.append(pnpmTarget.path) + } + pnpmHistoryOverflowed = true + } + } + let pnpmFailure: OwnerCommandFailure? + if case .failure(let failure) = pnpmProbe, failure != .unavailable { + pnpmFailure = failure + } else { + pnpmFailure = nil + } + let recipes = recipes(pnpmTarget: pnpmTarget) + let protectedPnpmPaths = knownPnpmStorePaths let work = Task.detached(priority: .background) { () -> (ScanResult, Snapshot?, [Snapshot])? in guard let result = try? DiskReservoirCore.Scanner( cloneRatios: cloneRatios, - ageDaysByRecipe: ageRules + ageDaysByRecipe: ageRules, + protectedOwnerPaths: protectedPnpmPaths ).scan( recipes: recipes, - homeDirectory: NSHomeDirectory() + homeDirectory: paths.homeDirectory ) else { return nil } let snapshot = Snapshot(volume: result.volume, items: result.items) let store = SnapshotStore(paths: paths) @@ -274,11 +338,17 @@ final class AppService { let all = (try? store.snapshots()) ?? [] return (result, previous, all) } - guard let (result, previous, all) = await work.value else { return } + guard let (result, previous, all) = await work.value else { + currentPnpmStoreTarget = nil + lastPnpmProbeFailure = pnpmFailure + return + } guard startedAtRevision == scanRevision else { pendingScanRequested = true return } + currentPnpmStoreTarget = pnpmTarget + lastPnpmProbeFailure = pnpmFailure state.availableBytes = result.volume.availableBytes state.totalBytes = result.volume.totalBytes state.items = result.items @@ -522,13 +592,17 @@ final class AppService { let ageRules = ageDaysByRecipe() let groupsByRecipe = recipeGroups(recipes) let defaultAgesByRecipe = recipeDefaultAges(recipes) + let homeDirectory = paths.homeDirectory + let knownPnpmStorePaths = self.knownPnpmStorePaths + let pnpmHistoryOverflowed = self.pnpmHistoryOverflowed let work = Task.detached(priority: .userInitiated) { () -> (ScanResult, CleanOutcome?)? in guard let result = try? DiskReservoirCore.Scanner( cloneRatios: cloneRatios, - ageDaysByRecipe: ageRules + ageDaysByRecipe: ageRules, + protectedOwnerPaths: knownPnpmStorePaths ).scan( recipes: recipes, - homeDirectory: NSHomeDirectory() + homeDirectory: homeDirectory ) else { return nil } if dryRun { let evaluator = RuleEvaluator( @@ -574,7 +648,10 @@ final class AppService { ), deleter: TrashBatchDeleter(batchName: Self.cleanupBatchName()), inspector: PGrepProcessInspector(), - logStore: logStore + logStore: logStore, + homeDirectory: homeDirectory, + knownOwnerStorePaths: knownPnpmStorePaths, + ownerHistoryOverflowed: pnpmHistoryOverflowed ).run( scan: result, config: config, @@ -636,6 +713,7 @@ final class AppService { // safeWhileRunning 与 userConfirm 放行,displayOnly(用户数据)除外; // requiresQuit 须先退出相关进程(如 Simulator)才能清理。 guard let currentRecipe = activeRecipes().first(where: { $0.id == item.recipeID }), + item.recipeID != OwnerCommandRecipe.pnpmStorePrune.id, item.cleanability.allowsManualCleanup, currentRecipe.cleanability.allowsManualCleanup, // 废纸篓是特殊过渡区:只通过废纸篓详情页管理,不走通用清理 @@ -646,6 +724,19 @@ final class AppService { !currentRecipe.cleanByChildOnly else { return .failed(.unavailable) } + let currentPaths = item.paths.isEmpty ? [item.path] : item.paths + let scanProbe: Result = currentPnpmStoreTarget + .map(Result.success) ?? .failure(lastPnpmProbeFailure ?? .unavailable) + let knownPaths = knownPnpmStorePaths + if currentPaths.contains(where: { + !OwnerManagedPathGuard.mayDelete( + path: $0, recipeID: item.recipeID, probe: scanProbe, + homeDirectory: paths.homeDirectory, knownStorePaths: knownPaths, + historyOverflowed: pnpmHistoryOverflowed + ) + }) { + return .failed(.unavailable) + } if item.recipeID == TemporaryBuildArtifacts.recipeID { let inspector = PGrepProcessInspector() if let running = TemporaryBuildArtifacts.guardProcessNames.first(where: inspector.isRunning) { @@ -664,13 +755,43 @@ final class AppService { state.cleanedItemIDs = [] state.deletingItemID = item.id let logStore = self.logStore + let scanHome = paths.homeDirectory + let pnpmRunner = self.pnpmRunner + let knownStorePaths = knownPnpmStorePaths + let pnpmHistoryOverflowed = self.pnpmHistoryOverflowed let deleter = TrashBatchDeleter(batchName: Self.cleanupBatchName()) let work = Task.detached(priority: .userInitiated) { () -> ManualCleanExecution in let targetPaths = item.paths.isEmpty ? [item.path] : item.paths + let needsOwnerProbe = item.category == .packageManager || targetPaths.contains { path in + OwnerManagedPathGuard.isRecognizablePnpmLocation( + path, homeDirectory: scanHome + ) || knownStorePaths.contains { + OwnerManagedPathGuard.overlaps(path, storePath: $0) + } + } + let ownerStore: Result = needsOwnerProbe + ? pnpmRunner.probe() : .failure(.unavailable) let batchID = UUID() var entries: [CleanLogEntry] = [] var firstFailure: ManualCleanFailure? for target in targetPaths { + guard OwnerManagedPathGuard.mayDelete( + path: target, recipeID: item.recipeID, probe: ownerStore, + homeDirectory: scanHome, knownStorePaths: knownStorePaths, + historyOverflowed: pnpmHistoryOverflowed + ) else { + firstFailure = firstFailure ?? .unavailable + continue + } + if item.recipeID == PackageManagerRecipes.familyID, + !PackageManagerRecipes.isApprovedDefaultCachePath(target, homeDirectory: scanHome) { + firstFailure = firstFailure ?? .unavailable + continue + } + guard !PackageManagerRecipes.isLegacyPnpmPath(target, homeDirectory: scanHome) else { + firstFailure = firstFailure ?? .unavailable + continue + } if item.recipeID == TemporaryBuildArtifacts.recipeID, !TemporaryBuildArtifacts.isEligibleForCleanup(path: target) { firstFailure = firstFailure ?? .recentlyModified @@ -846,13 +967,24 @@ final class AppService { recipe: recipe, config: loadConfig() ) let growth = (try? growthLedgerStore.entries()) ?? [] - return await Task.detached(priority: .utility) { + let knownPaths = knownPnpmStorePaths + let scanProbe: Result = currentPnpmStoreTarget + .map(Result.success) ?? .failure(lastPnpmProbeFailure ?? .unavailable) + let homeDirectory = paths.homeDirectory + let children = await Task.detached(priority: .utility) { ChildDirectoryExplorer().list( parentPath: item.path, growthEntries: growth, protectedChildNames: protected ) }.value + return children.filter { + OwnerManagedPathGuard.mayDelete( + path: $0.path, recipeID: item.recipeID, probe: scanProbe, + homeDirectory: homeDirectory, knownStorePaths: knownPaths, + historyOverflowed: pnpmHistoryOverflowed + ) + } } /// 逐子目录清理:执行前用当前配方与文件树重新校验,不信任详情页中的旧路径。 @@ -875,6 +1007,16 @@ final class AppService { minimumIdleSeconds: recipe.id == "deriveddata" ? DerivedDataChildPolicy.minimumIdleSeconds : 0 ) else { return false } + let pnpmRunner = self.pnpmRunner + let ownerStore = await Task.detached(priority: .userInitiated) { + pnpmRunner.probe() + }.value + let knownPaths = self.knownPnpmStorePaths + guard OwnerManagedPathGuard.mayDelete( + path: child.path, recipeID: item.recipeID, probe: ownerStore, + homeDirectory: self.paths.homeDirectory, knownStorePaths: knownPaths, + historyOverflowed: self.pnpmHistoryOverflowed + ) else { return false } let deleter = TrashBatchDeleter(batchName: Self.cleanupBatchName()) guard let deletion = try? deleter.deleteReturningResult( url: URL(fileURLWithPath: child.path), @@ -1158,6 +1300,9 @@ final class AppService { let idleHours = idleHoursByRecipe(recipes: recipes) let groupsByRecipe = recipeGroups(recipes) let defaultAgesByRecipe = recipeDefaultAges(recipes) + let homeDirectory = paths.homeDirectory + let knownPnpmStorePaths = self.knownPnpmStorePaths + let pnpmHistoryOverflowed = self.pnpmHistoryOverflowed let work = Task.detached(priority: .utility) { () -> CleanOutcome? in let cleaner = Cleaner( evaluator: RuleEvaluator( @@ -1169,7 +1314,10 @@ final class AppService { ), deleter: TrashBatchDeleter(batchName: Self.cleanupBatchName()), inspector: PGrepProcessInspector(), - logStore: logStore + logStore: logStore, + homeDirectory: homeDirectory, + knownOwnerStorePaths: knownPnpmStorePaths, + ownerHistoryOverflowed: pnpmHistoryOverflowed ) return try? cleaner.run( scan: scan, @@ -1478,24 +1626,89 @@ final class AppService { guard let recipe = activeRecipes().first(where: { $0.id == item.recipeID }) else { return false } + let itemPaths = item.paths.isEmpty ? [item.path] : item.paths + let scanProbe: Result = currentPnpmStoreTarget + .map(Result.success) ?? .failure(lastPnpmProbeFailure ?? .unavailable) + let knownPaths = knownPnpmStorePaths + if itemPaths.contains(where: { + !OwnerManagedPathGuard.mayDelete( + path: $0, recipeID: item.recipeID, probe: scanProbe, + homeDirectory: paths.homeDirectory, knownStorePaths: knownPaths, + historyOverflowed: pnpmHistoryOverflowed + ) + }) { + return false + } return item.cleanability.allowsManualCleanup && recipe.cleanability.allowsManualCleanup && !item.cleanByChildOnly && !recipe.cleanByChildOnly } + func probePnpmStore() async -> Result { + let pnpmRunner = self.pnpmRunner + return await Task.detached(priority: .userInitiated) { + pnpmRunner.probe() + }.value + } + + func prunePnpmStore( + confirmed target: OwnerCommandTarget, + onJournalResult: @escaping @MainActor (OwnerCommandJournalResult) -> Void + ) async -> OwnerCommandJournalResult { + await cleanupCoordinator.run { [self] in + let recordStore = self.ownerCommandRecordStore + let pnpmRunner = self.pnpmRunner + let result = await Task.detached(priority: .userInitiated) { + OwnerCommandJournal(store: recordStore).execute( + recipeID: OwnerCommandRecipe.pnpmStorePrune.id, targetPath: target.path + ) { + pnpmRunner.perform(confirmed: target) + } + }.value + let execution: Result + switch result { + case .startNotSaved: + onJournalResult(result) + return result + case .completed(let commandResult, let record): + self.state.lastOwnerCommandRecord = record + execution = commandResult + case .completionNotSaved(let commandResult, let attempt): + self.state.lastOwnerCommandRecord = attempt + execution = commandResult + } + // Report persistence failure before capacity refresh or a potentially long scan. + onJournalResult(result) + if case .success = execution { + self.scanRevision &+= 1 + await self.refreshVolumeCapacity() + await self.scanNow(autoClean: false, clearCleanSummary: false) + self.refreshCleanLogEntries() + } else { + await self.refreshVolumeCapacity() + } + return result + } + } + /// 当前生效的配方:系统内置 + 用户确认的项目目录配方。 func activeRecipes() -> [Recipe] { + recipes(pnpmTarget: currentPnpmStoreTarget) + } + + private func recipes(pnpmTarget: OwnerCommandTarget?) -> [Recipe] { let config = loadConfig() return RecipeRegistry.builtIn() + + (pnpmTarget.map { [OwnerCommandRecipe.pnpmStorePrune.scanRecipe(target: $0)] } ?? []) + [PackageManagerRecipes.make( extraRoots: [], - homeDirectory: NSHomeDirectory() + homeDirectory: paths.homeDirectory )] + (config.packageManagerCacheRoots.isEmpty ? [] : [PackageManagerRecipes.makeCustom(extraRoots: config.packageManagerCacheRoots)]) - + ProjectRecipes.make(devRoots: config.devRoots, homeDirectory: NSHomeDirectory()) + + ProjectRecipes.make(devRoots: config.devRoots, homeDirectory: paths.homeDirectory) } /// 各配方用户配置的年龄阈值(天),未配置的配方回落 recipe.defaultAgeDays。 diff --git a/PoolProblem/PoolProblem/Localizable.xcstrings b/PoolProblem/PoolProblem/Localizable.xcstrings index dbf971c..fa012f4 100644 --- a/PoolProblem/PoolProblem/Localizable.xcstrings +++ b/PoolProblem/PoolProblem/Localizable.xcstrings @@ -5387,6 +5387,414 @@ } } } + }, + "pnpm.title" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Prune pnpm store…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清理 pnpm store…" + } + } + } + }, + "pnpm.description" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manual official command. Removes unreferenced packages; future installs may download them again. Store size is not guaranteed freed space." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "手动执行官方命令,移除未被引用的包;后续安装可能重新下载。store 占用不等于保证释放空间。" + } + } + } + }, + "pnpm.confirm_title" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Permanently prune pnpm store?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "永久清理 pnpm store?" + } + } + } + }, + "pnpm.confirm_message" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Run pnpm store prune on:\n%@\n\nUnreferenced packages are permanently removed without going to Trash. Future installs may download them again. The store size is not guaranteed freed space." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将在以下目录执行 pnpm store prune:\n%@\n\n未被引用的包会永久移除,不经过废纸篓。后续安装可能重新下载;store 占用不等于保证释放空间。" + } + } + } + }, + "pnpm.run" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Permanently prune" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "永久清理" + } + } + } + }, + "pnpm.result" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Command succeeded. Measured available-space change: %@. Other disk activity may affect this reading." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "命令成功。实测可用空间变化:%@。同期磁盘活动可能影响读数。" + } + } + } + }, + "pnpm.result_unknown" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Command succeeded. Available-space change could not be measured." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "命令成功,但无法测得可用空间变化。" + } + } + } + }, + "pnpm.last_record" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Last pnpm command: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上次 pnpm 命令:%@" + } + } + } + }, + "pnpm.record_incomplete" : { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Last pnpm attempt: outcome unknown; check the store before retrying" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "上次 pnpm 清理尝试:结果未知;重试前请检查 store" } } + } + }, + "pnpm.error_journal_start" : { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Could not save the pnpm attempt. The prune command was not run." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "无法保存 pnpm 清理尝试记录;未运行清理命令。" } } + } + }, + "pnpm.error_journal_completion_success" : { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The command succeeded, but its completion history could not be saved. Check the store before retrying." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "命令已成功,但无法保存完成记录。重试前请检查 store。" } } + } + }, + "pnpm.error_journal_completion_failure" : { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Its completion history could not be saved. The command may have changed the store; check it before retrying." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "无法保存命令完成记录。store 可能已变化;重试前请检查。" } } + } + }, + "pnpm.error_unavailable" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "pnpm is unavailable; nothing was removed." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "找不到 pnpm;未执行清理。" + } + } + } + }, + "pnpm.error_invalid" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "pnpm returned an invalid store path; nothing was removed." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "pnpm 返回无效 store 路径;未执行清理。" + } + } + } + }, + "pnpm.error_changed" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The store path changed after confirmation; nothing was removed." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认后 store 路径发生变化;未执行清理。" + } + } + } + }, + "pnpm.error_probe" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Store path probe exited with code %d; nothing was removed." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "store 路径探测退出码 %d;未执行清理。" + } + } + } + }, + "pnpm.error_action" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "pnpm prune exited with code %d; check pnpm before retrying." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "pnpm prune 退出码 %d;请检查 pnpm 后重试。" + } + } + } + }, + "pnpm.error_timeout" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "pnpm timed out. Check the store before retrying; the command may have partially run." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "pnpm 超时。命令可能已部分执行,请检查 store 后再重试。" + } + } + } + }, + "pnpm.error_launch" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The pnpm process could not be verified. The store may have changed; check it before retrying." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法确认 pnpm 进程的执行结果。store 可能已变化;重试前请检查。" + } + } + } + }, + "pnpm.command" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Official command" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "官方命令" + } + } + } + }, + "pnpm.error_ambiguous" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Several pnpm installations use different stores. The command is blocked; align their store configuration before retrying." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检测到多个 pnpm 对应不同的 store,暂不执行。请先统一它们的 store 配置再重试。" + } + } + } + }, + "pnpm.probe_not_listed" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "pnpm store was not added to the recipes: %@. Scan again after fixing pnpm." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "pnpm store 暂未加入配方:%@。修复 pnpm 后可重新扫描。" + } + } + } + }, + "pnpm.size_unavailable" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No current measurement; scan again before using this recipe." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂无当前测量结果;请重新扫描后再使用此配方。" + } + } + } + }, + "pnpm.size_uncertain" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Current store occupancy is included in Non-cleanable. The amount pnpm can free is unknown until its official prune command finishes." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这里显示的 store 占用已计入「不可清理」;官方命令实际能释放多少,执行前无法确定。" + } + } + } + }, + "watch.generic_reason" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This directory contains user or tool-managed data. Pool Problem measures its occupancy but does not delete the directory. Review it in the owning app before making changes." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此目录包含用户数据或由其他工具管理的数据。应用只测量占用,不直接删除整个目录;请先在所属应用中检查。" + } + } + } + }, + "watch.included_in_non_cleanable" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Observed occupancy is already included in Non-cleanable; these totals must not be added together." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这些占用已计入「不可清理」,请勿与其相加。" + } + } + } + }, + "watch.simulator_devices_reason" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Simulator devices contain installed apps and their data. Pool Problem only observes them; manage individual devices in Xcode." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "模拟器设备包含已安装的 App 和数据。这里仅观察占用;请在 Xcode 中逐个管理设备。" + } + } + } + }, + "watch.xcode_archives_reason" : { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Xcode archives contain apps you created. Pool Problem only observes them; review individual archives in Xcode Organizer." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Xcode 归档包含你制作的 App。这里仅观察占用;请在 Xcode 的 Organizer 中逐个检查和管理。" + } + } + } } }, "version" : "1.0" diff --git a/PoolProblem/PoolProblem/Models/AppState.swift b/PoolProblem/PoolProblem/Models/AppState.swift index cd7567a..77f173f 100644 --- a/PoolProblem/PoolProblem/Models/AppState.swift +++ b/PoolProblem/PoolProblem/Models/AppState.swift @@ -47,6 +47,7 @@ final class AppState: ObservableObject { /// 正在删除的条目剩余比例:1 → 0,用于列表里大小逐渐缩小到消失的动画。 @Published var deletingProgress: Double = 1 @Published var lastCleanSummary: String? + @Published var lastOwnerCommandRecord: OwnerCommandRecord? @Published var detailItem: ScanItem? @Published var keptItemIDs: Set = [] @Published var availableHistory: [Int64] = [] diff --git a/PoolProblem/PoolProblem/Views/GrowthInsightsView.swift b/PoolProblem/PoolProblem/Views/GrowthInsightsView.swift index df00ad7..b786932 100644 --- a/PoolProblem/PoolProblem/Views/GrowthInsightsView.swift +++ b/PoolProblem/PoolProblem/Views/GrowthInsightsView.swift @@ -211,9 +211,10 @@ struct GrowthInsightsView: View { /// Observed assets stay outside every cleanup entry point. The figure is /// allocated disk space, while reclaimable space remains zero. private var watchSection: some View { - let watchItems = state.items + let watchItems = service.visibleItems(state.items) .filter { $0.cleanability == .watchOnly + && $0.recipeID != OwnerCommandRecipe.pnpmStorePrune.id && $0.paths.contains { GrowthPathStatus.probe($0) != .missing } } .sorted { $0.allocatedBytes > $1.allocatedBytes } diff --git a/PoolProblem/PoolProblem/Views/MenuBarView.swift b/PoolProblem/PoolProblem/Views/MenuBarView.swift index 2a7d55f..a0a843d 100644 --- a/PoolProblem/PoolProblem/Views/MenuBarView.swift +++ b/PoolProblem/PoolProblem/Views/MenuBarView.swift @@ -16,6 +16,10 @@ struct MenuBarView: View { @State private var showCleanHistory = false @State private var cleanFailureNotice: String? @State private var quitProcessRunning = false + @State private var pnpmTarget: OwnerCommandTarget? + @State private var pnpmBusy = false + @State private var pnpmNotice: String? + @State private var showPnpmConfirm = false private var estimatedRecipeIDs: Set { Set(RecipeRegistry.builtIn().filter(\.cloneProne).map(\.id)) @@ -117,6 +121,8 @@ struct MenuBarView: View { Group { if item.recipeID == "trash" { TrashDetailView(state: state, service: service) + } else if item.recipeID == OwnerCommandRecipe.pnpmStorePrune.id { + pnpmDetailOverlay(item) } else if service.isChildOnly(item) { CacheChildrenView(state: state, service: service, item: item) } else { @@ -176,6 +182,39 @@ struct MenuBarView: View { } message: { outcome in Text(Localized.string("clean.confirm_message", outcome.entries.count, Format.bytes(outcome.freedBytes))) } + .alert(Localized.string("pnpm.confirm_title"), isPresented: $showPnpmConfirm) { + Button(Localized.string("common.cancel"), role: .cancel) { pnpmTarget = nil } + Button(Localized.string("pnpm.run"), role: .destructive) { + guard let target = pnpmTarget else { return } + pnpmBusy = true + Task { + _ = await service.prunePnpmStore(confirmed: target) { result in + pnpmNotice = pnpmJournalText(result) + } + pnpmBusy = false + pnpmTarget = nil + } + } + } message: { + Text(Localized.string("pnpm.confirm_message", pnpmTarget?.path ?? "")) + } + } + + private func pnpmJournalText(_ result: OwnerCommandJournalResult) -> String { + switch result { + case .startNotSaved: + return Localized.string("pnpm.error_journal_start") + case .completed(let execution, _): + return pnpmExecutionText(execution) + case .completionNotSaved(let execution, _): + let warning: String + if case .success = execution { + warning = Localized.string("pnpm.error_journal_completion_success") + } else { + warning = Localized.string("pnpm.error_journal_completion_failure") + } + return pnpmExecutionText(execution) + " " + warning + } } private var rightPanel: some View { @@ -199,6 +238,14 @@ struct MenuBarView: View { legend + if let failure = service.lastPnpmProbeFailure { + Label(Localized.string("pnpm.probe_not_listed", pnpmFailureText(failure)), + systemImage: "exclamationmark.triangle") + .font(.caption2) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + Divider() if let summary = state.lastCleanSummary { @@ -219,6 +266,151 @@ struct MenuBarView: View { } } + private func beginPnpmPrune(for item: ScanItem) { + guard service.currentPnpmStoreTarget?.path == item.path, + service.visibleItems(state.items).contains(where: { $0.id == item.id }) else { + pnpmNotice = Localized.string("pnpm.error_changed") + return + } + pnpmBusy = true + pnpmNotice = nil + Task { + let result = await service.probePnpmStore() + switch result { + case .success(let target) where target.path == item.path: + pnpmTarget = target + showPnpmConfirm = true + case .success: + pnpmNotice = Localized.string("pnpm.error_changed") + await service.scanNow(autoClean: false) + case .failure(let failure): + pnpmNotice = pnpmFailureText(failure) + } + pnpmBusy = false + } + } + + private func pnpmExecutionText(_ result: Result) -> String { + switch result { + case .success(let outcome): + return outcome.capacityDeltaBytes.map { + Localized.string("pnpm.result", Format.signedBytes($0)) + } ?? Localized.string("pnpm.result_unknown") + case .failure(let failure): return pnpmFailureText(failure) + } + } + + private func pnpmFailureText(_ failure: OwnerCommandFailure) -> String { + switch failure { + case .unavailable: return Localized.string("pnpm.error_unavailable") + case .invalidTarget: return Localized.string("pnpm.error_invalid") + case .ambiguousTarget: return Localized.string("pnpm.error_ambiguous") + case .targetChanged: return Localized.string("pnpm.error_changed") + case .probeFailed(let code): return Localized.string("pnpm.error_probe", Int(code)) + case .actionFailed(let code): return Localized.string("pnpm.error_action", Int(code)) + case .timedOut: return Localized.string("pnpm.error_timeout") + case .launchFailed: return Localized.string("pnpm.error_launch") + } + } + + private func pnpmDetailOverlay(_ item: ScanItem) -> some View { + let currentItem = state.items.first { + $0.id == item.id && service.currentPnpmStoreTarget?.path == $0.path + } + let isEligible = service.visibleItems(state.items).contains { $0.id == item.id } + return VStack(alignment: .leading, spacing: 12) { + HStack { + Text(Localized.recipeName(item.recipeID, fallback: item.name)) + .font(.headline) + Spacer() + Button { + withAnimation(overlaySpring) { state.detailItem = nil } + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.bordered) + .controlSize(.small) + .focusEffectDisabled() + .cursorPointingHand() + } + + Text(Localized.string("pnpm.description")) + .font(.caption) + Text(Localized.string("pnpm.size_uncertain")) + .font(.caption2) + .foregroundStyle(.secondary) + + LabeledContent( + Localized.string("detail.current_size"), + value: currentItem.map { Format.bytes($0.allocatedBytes) } + ?? Localized.string("pnpm.size_unavailable") + ) + .font(.caption) + LabeledContent(Localized.string("pnpm.command"), value: "pnpm store prune") + .font(.caption) + + Divider() + Text(Localized.string("detail.path")) + .font(.caption2) + .foregroundStyle(.secondary) + Button { + revealInFinder(item.path) + } label: { + Text(item.path) + .font(.caption) + .foregroundStyle(.blue) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .focusEffectDisabled() + .cursorPointingHand() + + if let pnpmNotice { + Text(pnpmNotice) + .font(.caption2) + .foregroundStyle(.secondary) + } else if let record = state.lastOwnerCommandRecord, + record.recipeID == OwnerCommandRecipe.pnpmStorePrune.id { + Text(record.outcome == "started" + ? Localized.string("pnpm.record_incomplete") + : Localized.string("pnpm.last_record", record.outcome)) + .font(.caption2) + .foregroundStyle(.secondary) + } + + HStack { + Button(Localized.string("pnpm.run")) { + beginPnpmPrune(for: item) + } + .buttonStyle(.borderedProminent) + .tint(.orange) + .controlSize(.large) + .disabled(pnpmBusy || state.isCleaning || state.isScanning || !isEligible) + .focusEffectDisabled() + .cursorPointingHand() + Spacer() + Button(Localized.string("common.close")) { + withAnimation(overlaySpring) { state.detailItem = nil } + } + .buttonStyle(.bordered) + .focusEffectDisabled() + .cursorPointingHand() + } + } + .padding(16) + .frame(width: 380) + .background( + Color(nsColor: .windowBackgroundColor).opacity(reduceTransparency ? 1.0 : 0.97), + in: RoundedRectangle(cornerRadius: 12) + ) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator)) + .padding(40) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.15)) + } + private var autoCleanPlanList: some View { VStack(alignment: .leading, spacing: 7) { ForEach(state.autoCleanPlans) { plan in @@ -384,6 +576,13 @@ struct MenuBarView: View { excludedItemIDs: state.cleanedItemIDs ) let displayedLayers = poolLayers.layers.filter { displayedIDs.contains($0.itemID) } + // pnpm is measured occupancy with an owner-provided cleanup command. Its + // full store size is not an estimate of what `pnpm store prune` will free. + let ownerCommandItems = displayedItems.filter { + $0.recipeID == OwnerCommandRecipe.pnpmStorePrune.id + && service.currentPnpmStoreTarget?.path == $0.path + && FileManager.default.fileExists(atPath: $0.path) + } // 手动清理项:应用无法删除,只能提示用户到对应应用/Finder 清理 let manualItems = displayedItems .filter { @@ -397,13 +596,16 @@ struct MenuBarView: View { } .sorted { $0.reclaimableBytes > $1.reclaimableBytes } let observedItems = displayedItems - .filter { - !ScanDisplayPolicy.effectiveCleanability($0, recipes: recipes).allowsManualCleanup - && $0.recipeID != "trash" + .filter { item in + !ScanDisplayPolicy.effectiveCleanability(item, recipes: recipes).allowsManualCleanup + && item.recipeID != "trash" + && item.recipeID != OwnerCommandRecipe.pnpmStorePrune.id + && recipes.contains(where: { $0.id == item.recipeID }) + && FileManager.default.fileExists(atPath: item.path) } .sorted { $0.allocatedBytes > $1.allocatedBytes } return VStack(alignment: .leading, spacing: 5) { - Text(Localized.string("section.cleanable_count", displayedLayers.count)) + Text(Localized.string("section.cleanable_count", displayedLayers.count + ownerCommandItems.count)) .font(.caption) .foregroundStyle(.secondary) ForEach(displayedLayers.filter { layer in @@ -464,6 +666,35 @@ struct MenuBarView: View { .cursorPointingHand() } + ForEach(ownerCommandItems) { item in + Button { + withAnimation(overlaySpring) { state.detailItem = item } + } label: { + HStack(spacing: 5) { + Rectangle() + .fill(PoolLayers.manualColor) + .frame(width: 9, height: 9) + Text(Localized.recipeName(item.recipeID, fallback: item.name)) + .lineLimit(1) + .font(.caption) + Spacer() + Text(Format.bytes(item.allocatedBytes)) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + Text("?") + .font(.caption2) + .foregroundStyle(.orange) + .help(Localized.string("pnpm.size_uncertain")) + .frame(width: 10) + } + .frame(height: 22) + } + .buttonStyle(.plain) + .focusEffectDisabled() + .cursorPointingHand() + } + Divider() VStack(alignment: .leading, spacing: 5) { HStack(spacing: 5) { @@ -548,19 +779,21 @@ struct MenuBarView: View { } } - if poolLayers.nonCleanableBytes > 0 { + if !observedItems.isEmpty { Button { - withAnimation(overlaySpring) { showNonCleanableInfo = true } + withAnimation { observedExpanded.toggle() } } label: { HStack(spacing: 5) { Rectangle() .fill(PoolLayers.nonCleanableColor) .frame(width: 9, height: 9) - Text(Localized.string("section.non_cleanable")) + Text(Localized.string("insights.tab_watched")) .font(.caption) + Image(systemName: observedExpanded ? "chevron.up" : "chevron.down") + .font(.caption2) .foregroundStyle(.secondary) Spacer() - Text(Format.bytes(poolLayers.nonCleanableBytes)) + Text(Format.bytes(observedItems.reduce(Int64(0)) { $0 + $1.allocatedBytes })) .font(.caption) .foregroundStyle(.secondary) } @@ -568,51 +801,56 @@ struct MenuBarView: View { .buttonStyle(.plain) .focusEffectDisabled() .cursorPointingHand() - if !observedItems.isEmpty { - Button { - withAnimation { observedExpanded.toggle() } - } label: { - HStack(spacing: 5) { - Text(Localized.string("insights.tab_watched")) - .font(.caption) - Image(systemName: observedExpanded ? "chevron.up" : "chevron.down") - .font(.caption2) - .foregroundStyle(.secondary) - Spacer() - Text(Format.bytes(observedItems.reduce(Int64(0)) { $0 + $1.allocatedBytes })) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .buttonStyle(.plain) - .focusEffectDisabled() - .cursorPointingHand() - if observedExpanded { - VStack(alignment: .leading, spacing: 3) { - ForEach(observedItems) { item in - Button { - withAnimation(overlaySpring) { state.detailItem = item } - } label: { - HStack(spacing: 5) { - Text(Localized.recipeName(item.recipeID, fallback: item.name)) - .lineLimit(1) - .font(.caption) - Spacer() - Text(Format.bytes(item.allocatedBytes)) - .font(.caption) - .monospacedDigit() - .foregroundStyle(.secondary) - } - .frame(height: 22) + if observedExpanded { + VStack(alignment: .leading, spacing: 3) { + Text(Localized.string("watch.included_in_non_cleanable")) + .font(.caption2) + .foregroundStyle(.secondary) + ForEach(observedItems) { item in + Button { + withAnimation(overlaySpring) { state.detailItem = item } + } label: { + HStack(spacing: 5) { + Text(Localized.recipeName(item.recipeID, fallback: item.name)) + .lineLimit(1) + .font(.caption) + Spacer() + Text(Format.bytes(item.allocatedBytes)) + .font(.caption) + .monospacedDigit() + .foregroundStyle(.secondary) } - .buttonStyle(.plain) - .focusEffectDisabled() - .cursorPointingHand() + .frame(height: 22) } + .buttonStyle(.plain) + .focusEffectDisabled() + .cursorPointingHand() } - .padding(.leading, 14) + } + .padding(.leading, 14) + } + } + + if poolLayers.nonCleanableBytes > 0 { + Button { + withAnimation(overlaySpring) { showNonCleanableInfo = true } + } label: { + HStack(spacing: 5) { + Rectangle() + .fill(PoolLayers.nonCleanableColor) + .frame(width: 9, height: 9) + Text(Localized.string("section.non_cleanable")) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Text(Format.bytes(poolLayers.nonCleanableBytes)) + .font(.caption) + .foregroundStyle(.secondary) } } + .buttonStyle(.plain) + .focusEffectDisabled() + .cursorPointingHand() } } .padding(.top, 2) @@ -743,6 +981,17 @@ struct MenuBarView: View { state.showCleanConfirm = !outcome.entries.isEmpty } + private func observedReason(for item: ScanItem) -> String { + switch item.recipeID { + case "xcode-archives": + return Localized.string("watch.xcode_archives_reason") + case "core-simulator-devices": + return Localized.string("watch.simulator_devices_reason") + default: + return Localized.string("watch.generic_reason") + } + } + /// 配方详情说明浮层;只观察项也通过它展示路径和当前占用。 private func detailOverlay(_ item: ScanItem) -> some View { let isKept = state.keptItemIDs.contains(item.id) @@ -771,7 +1020,9 @@ struct MenuBarView: View { : Localized.string("insights.tab_watched")) .font(.caption2) .foregroundStyle(.secondary) - Text(Localized.suggestionText(rationale.suggestion)) + Text(cleanability.allowsManualCleanup + ? Localized.suggestionText(rationale.suggestion) + : observedReason(for: item)) .font(.caption) if appCleanable { Text(Localized.string("detail.why_cleanable")) @@ -798,11 +1049,13 @@ struct MenuBarView: View { Text(Localized.confirmationText(confirmation)) .font(.caption) } - Text(Localized.string("detail.last_used")) - .font(.caption2) - .foregroundStyle(.secondary) - Text(lastUsedText(rationale.lastUsed)) - .font(.caption) + if cleanability.allowsManualCleanup { + Text(Localized.string("detail.last_used")) + .font(.caption2) + .foregroundStyle(.secondary) + Text(lastUsedText(rationale.lastUsed)) + .font(.caption) + } } if estimatedRecipeIDs.contains(item.recipeID) { @@ -897,22 +1150,24 @@ struct MenuBarView: View { .foregroundStyle(.secondary) } } - if isKept { - Button(Localized.string("detail.unkeep")) { - service.unkeepItem(item.id) - } - .buttonStyle(.bordered) - .tint(.orange) - .focusEffectDisabled() - .cursorPointingHand() - } else { - Button(Localized.string("detail.keep")) { - service.keepItem(item) + if cleanability.allowsManualCleanup { + if isKept { + Button(Localized.string("detail.unkeep")) { + service.unkeepItem(item.id) + } + .buttonStyle(.bordered) + .tint(.orange) + .focusEffectDisabled() + .cursorPointingHand() + } else { + Button(Localized.string("detail.keep")) { + service.keepItem(item) + } + .buttonStyle(.borderedProminent) + .tint(.green) + .focusEffectDisabled() + .cursorPointingHand() } - .buttonStyle(.borderedProminent) - .tint(.green) - .focusEffectDisabled() - .cursorPointingHand() } Spacer() Button(Localized.string("common.close")) { diff --git a/PoolProblem/PoolProblemTests/AppServiceTests.swift b/PoolProblem/PoolProblemTests/AppServiceTests.swift index dffa7de..fb30ae1 100644 --- a/PoolProblem/PoolProblemTests/AppServiceTests.swift +++ b/PoolProblem/PoolProblemTests/AppServiceTests.swift @@ -3,6 +3,40 @@ import Foundation @testable import PoolProblem import DiskReservoirCore +@MainActor +@Test func pnpmStoreCacheChildIsNeitherListedForCleaningNorMoved() async throws { + let fixture = FileManager.default.temporaryDirectory + .appendingPathComponent("pp-pnpm-child-e2e-\(UUID())", isDirectory: true) + defer { try? FileManager.default.removeItem(at: fixture) } + let home = fixture.appendingPathComponent("home") + let caches = home.appendingPathComponent("Library/Caches") + let store = caches.appendingPathComponent("pnpm") + let data = fixture.appendingPathComponent("data") + try FileManager.default.createDirectory(at: store, withIntermediateDirectories: true) + try Data(repeating: 1, count: 11_000_000).write(to: store.appendingPathComponent("package")) + let fakePnpm = fixture.appendingPathComponent("bin/pnpm") + try FileManager.default.createDirectory(at: fakePnpm.deletingLastPathComponent(), withIntermediateDirectories: true) + try ("#!/bin/sh\necho '\(store.path)'\n").write(to: fakePnpm, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fakePnpm.path) + let paths = StoragePaths(baseURL: data, homeDirectory: home.path) + let state = AppState() + let service = AppService( + state: state, paths: paths, automationEnabled: false, + pnpmRunner: OwnerCommandRunner( + recipe: .pnpmStorePrune, executable: fakePnpm.path, + environment: ["PATH": fakePnpm.deletingLastPathComponent().path], home: home.path + ) + ) + await service.scanNow() + let cacheItem = try #require(state.items.first { $0.recipeID == "library-caches" }) + let rawChild = try #require(ChildDirectoryExplorer().list( + parentPath: caches.path, growthEntries: [], protectedChildNames: [] + ).first { $0.path == store.path }) + #expect(!(await service.cacheChildren(for: cacheItem)).contains { $0.path == store.path }) + #expect(!(await service.cleanCacheChild(rawChild, in: cacheItem))) + #expect(FileManager.default.fileExists(atPath: store.appendingPathComponent("package").path)) +} + @MainActor @Test func appServiceScanWritesSnapshot() async throws { let dir = FileManager.default.temporaryDirectory diff --git a/Sources/DiskReservoirCore/Cleaner/Cleaner.swift b/Sources/DiskReservoirCore/Cleaner/Cleaner.swift index b321ba8..c1288ea 100644 --- a/Sources/DiskReservoirCore/Cleaner/Cleaner.swift +++ b/Sources/DiskReservoirCore/Cleaner/Cleaner.swift @@ -27,25 +27,39 @@ public struct Cleaner: Sendable { private let deleter: FileDeleting private let inspector: ProcessInspecting private let logStore: CleanLogStore + private let homeDirectory: String private let availableBytesReader: @Sendable (URL) -> Int64 private let now: @Sendable () -> Date + private let ownerStoreProbe: @Sendable () -> Result + private let knownOwnerStorePaths: [String] + private let ownerHistoryOverflowed: Bool public init( evaluator: RuleEvaluator, deleter: FileDeleting, inspector: ProcessInspecting, logStore: CleanLogStore, + homeDirectory: String = NSHomeDirectory(), availableBytesReader: @escaping @Sendable (URL) -> Int64 = { VolumeReader.read(fileURL: $0).availableBytes }, - now: @escaping @Sendable () -> Date = { Date() } + now: @escaping @Sendable () -> Date = { Date() }, + ownerStoreProbe: (@Sendable () -> Result)? = nil, + knownOwnerStorePaths: [String] = [], + ownerHistoryOverflowed: Bool = false ) { self.evaluator = evaluator self.deleter = deleter self.inspector = inspector self.logStore = logStore + self.homeDirectory = homeDirectory self.availableBytesReader = availableBytesReader self.now = now + self.ownerStoreProbe = ownerStoreProbe ?? { + OwnerCommandRunner(recipe: .pnpmStorePrune, home: homeDirectory).probe() + } + self.knownOwnerStorePaths = knownOwnerStorePaths + self.ownerHistoryOverflowed = ownerHistoryOverflowed } /// 清理底线兜底:任何删除决定都必须经过可清理性校验。 @@ -82,6 +96,7 @@ public struct Cleaner: Sendable { } let availableBefore = availableBytesReader(scan.volumeURL) let candidates = scan.items + .filter { $0.recipeID != OwnerCommandRecipe.pnpmStorePrune.id } .filter { !RuleEvaluator.isPathProtected(item: $0, whitelistPaths: config.whitelistPaths) } // 应用无法删除的手动项(Xcode/Finder)不进入自动/强制清理候选 .filter { !CleanupRationale.make(for: $0).isManual } @@ -126,6 +141,21 @@ public struct Cleaner: Sendable { } return left.id < right.id } + let knownStorePaths = knownOwnerStorePaths + scan.items + .filter { $0.recipeID == OwnerCommandRecipe.pnpmStorePrune.id } + .map(\.path) + let needsOwnerProbe = candidates.contains { item in + item.category == .packageManager + || (item.paths.isEmpty ? [item.path] : item.paths).contains { path in + OwnerManagedPathGuard.isRecognizablePnpmLocation( + path, homeDirectory: homeDirectory + ) || knownStorePaths.contains { + OwnerManagedPathGuard.overlaps(path, storePath: $0) + } + } + } + let ownerStore: Result = needsOwnerProbe + ? ownerStoreProbe() : .failure(.unavailable) var entries: [CleanLogEntry] = [] var freedTotal: Int64 = 0 var below = true @@ -167,6 +197,18 @@ public struct Cleaner: Sendable { let targetPaths = item.paths.isEmpty ? [item.path] : item.paths var itemFreed: Int64 = 0 for target in targetPaths { + guard OwnerManagedPathGuard.mayDelete( + path: target, recipeID: item.recipeID, probe: ownerStore, + homeDirectory: homeDirectory, knownStorePaths: knownStorePaths, + historyOverflowed: ownerHistoryOverflowed + ) else { continue } + if item.recipeID == PackageManagerRecipes.familyID, + !PackageManagerRecipes.isApprovedDefaultCachePath(target, homeDirectory: homeDirectory) { + continue + } + guard !PackageManagerRecipes.isLegacyPnpmPath(target, homeDirectory: homeDirectory) else { + continue + } // 单项失败(如 TCC 权限)不影响后续项:尽力而为,继续清理其他目标 guard let deletion = try? deleter.deleteReturningResult( url: URL(fileURLWithPath: target), diff --git a/Sources/DiskReservoirCore/Recipes/OwnerCommandKnownTargetsStore.swift b/Sources/DiskReservoirCore/Recipes/OwnerCommandKnownTargetsStore.swift new file mode 100644 index 0000000..f84d4fa --- /dev/null +++ b/Sources/DiskReservoirCore/Recipes/OwnerCommandKnownTargetsStore.swift @@ -0,0 +1,82 @@ +import Foundation + +/// Durable safety history for owner-managed store paths. Snapshot retention +/// must not erase a previously observed target while its files still exist. +public struct OwnerCommandKnownTargetsStore: Sendable { + public struct State: Codable, Sendable { + public var paths: [String] + /// Once capacity was exceeded, deleted history is unknown. Callers + /// must fail closed for broad raw-path cleanup. + public var overflowed: Bool + + public init(paths: [String] = [], overflowed: Bool = false) { + self.paths = paths + self.overflowed = overflowed + } + } + + public static let maximumPaths = 1_024 + private static let lock = NSLock() + private let pathsConfig: StoragePaths + private let store: JSONStoring + + public init(paths: StoragePaths, store: JSONStoring = JSONStore()) { + self.pathsConfig = paths + self.store = store + } + + public func state() throws -> State { + Self.lock.lock(); defer { Self.lock.unlock() } + return try read() + } + + public func paths() throws -> [String] { try state().paths } + + @discardableResult + public func record(_ newPaths: [String]) throws -> State { + Self.lock.lock(); defer { Self.lock.unlock() } + var history = try read() + for raw in newPaths { + guard let canonical = canonicalStorePath(raw) else { continue } + history.paths.removeAll { $0 == canonical } + history.paths.append(canonical) + } + if history.paths.count > Self.maximumPaths { + history.overflowed = true + history.paths = Array(history.paths.suffix(Self.maximumPaths)) + } + try store.save(history, to: fileURL) + return history + } + + /// One-time or repeatable migration from the retained snapshots. `record` + /// de-duplicates, so replaying startup history cannot grow the file. + @discardableResult + public func migrate(snapshots: [Snapshot]) throws -> State { + try record(snapshots.flatMap { snapshot in + snapshot.items.filter { + $0.recipeID == OwnerCommandRecipe.pnpmStorePrune.id + }.map(\.path) + }) + } + + private var fileURL: URL { + pathsConfig.baseURL.appendingPathComponent("pnpm-known-store-paths.json") + } + + private func read() throws -> State { + try store.load(State.self, from: fileURL) ?? State() + } + + private func canonicalStorePath(_ raw: String) -> String? { + guard raw.hasPrefix("/"), !raw.contains("\0"), !raw.contains("\r"), + !raw.contains("\n"), raw.utf8.count <= 4_096 else { return nil } + let canonical = URL(fileURLWithPath: raw) + .resolvingSymlinksInPath().standardizedFileURL.path + let home = URL(fileURLWithPath: pathsConfig.homeDirectory) + .resolvingSymlinksInPath().standardizedFileURL.path + guard canonical != "/", canonical != home, + URL(fileURLWithPath: canonical).pathComponents.count > 2 else { return nil } + return canonical + } +} diff --git a/Sources/DiskReservoirCore/Recipes/OwnerCommandRecipe.swift b/Sources/DiskReservoirCore/Recipes/OwnerCommandRecipe.swift new file mode 100644 index 0000000..37c1e80 --- /dev/null +++ b/Sources/DiskReservoirCore/Recipes/OwnerCommandRecipe.swift @@ -0,0 +1,354 @@ +import Foundation +import Darwin + +/// An owner-command recipe. Its scan entry is observational; only `perform` may mutate it. +public struct OwnerCommandRecipe: Sendable { + public let id: String + public let executableName: String + public let probeArguments: [String] + public let actionArguments: [String] + + public static let pnpmStorePrune = OwnerCommandRecipe( + id: "pnpm-store-prune", executableName: "pnpm", + probeArguments: ["store", "path"], actionArguments: ["store", "prune"] + ) + + public func scanRecipe(target: OwnerCommandTarget) -> Recipe { + Recipe( + id: id, name: "pnpm store", category: .packageManager, + group: .packageManager, safety: .userConfirm, + disposition: .none, cleanability: .watchOnly, + defaultAgeDays: 0, minimumSizeMB: 10, processName: nil, + resolvePaths: { _ in [target.path] } + ) + } +} + +public struct OwnerCommandTarget: Sendable, Equatable { + public let executable: String + public let path: String +} + +public enum OwnerCommandFailure: Error, Sendable, Equatable { + case unavailable + case invalidTarget + case ambiguousTarget + case targetChanged + case probeFailed(Int32) + case actionFailed(Int32) + case timedOut + case launchFailed +} + +public struct OwnerCommandOutcome: Sendable { + public let target: OwnerCommandTarget + public let capacityDeltaBytes: Int64? + public let output: String +} + +public struct OwnerCommandRunner: Sendable { + public let recipe: OwnerCommandRecipe + private let explicitExecutable: String? + private let environment: [String: String] + private let home: String + private let timeout: TimeInterval + + public init( + recipe: OwnerCommandRecipe, + executable: String? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment, + home: String = NSHomeDirectory(), + timeout: TimeInterval = 30 + ) { + self.recipe = recipe + self.explicitExecutable = executable + self.environment = environment + self.home = home + self.timeout = timeout + } + + public func probe() -> Result { + let candidates = locateExecutables() + guard !candidates.isEmpty else { return .failure(.unavailable) } + let deadline = ProcessInfo.processInfo.systemUptime + timeout + var selected: OwnerCommandTarget? + var firstFailure: OwnerCommandFailure? + for executable in candidates { + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { return .failure(.timedOut) } + switch probe(executable: executable, timeLimit: min(8, remaining)) { + case .success(let target): + if let selected, selected.path != target.path { return .failure(.ambiguousTarget) } + if selected == nil { selected = target } + case .failure(let failure): + // A timed-out candidate may point to a different store; do not + // treat a separate successful candidate as unambiguous. + if failure == .timedOut { return .failure(.timedOut) } + if firstFailure == nil { firstFailure = failure } + } + } + return selected.map(Result.success) ?? .failure(firstFailure ?? .unavailable) + } + + private func probe(executable: String, timeLimit: TimeInterval) -> Result { + switch run(executable: executable, arguments: recipe.probeArguments, isProbe: true, timeLimit: timeLimit) { + case .failure(let failure): return .failure(failure) + case .success(let result): + guard result.status == 0 else { return .failure(.probeFailed(result.status)) } + let lines = result.output.trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .newlines) + guard lines.count == 1, let path = lines.first, + let canonical = validDirectory(path) else { return .failure(.invalidTarget) } + return .success(OwnerCommandTarget(executable: executable, path: canonical)) + } + } + + public func perform(confirmed: OwnerCommandTarget) -> Result { + // Re-run the exact executable and compare the canonical directory just before mutation. + guard FileManager.default.isExecutableFile(atPath: confirmed.executable) else { + return .failure(.unavailable) + } + switch run(executable: confirmed.executable, arguments: recipe.probeArguments, isProbe: true) { + case .failure(.timedOut): return .failure(.timedOut) + case .failure: return .failure(.launchFailed) + case .success(let check): + guard check.status == 0 else { return .failure(.probeFailed(check.status)) } + let lines = check.output.trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .newlines) + guard lines.count == 1, let path = lines.first, + let canonical = validDirectory(path) else { return .failure(.invalidTarget) } + guard canonical == confirmed.path else { return .failure(.targetChanged) } + } + let url = URL(fileURLWithPath: confirmed.path, isDirectory: true) + let before = VolumeReader.read(fileURL: url) + switch run(executable: confirmed.executable, arguments: recipe.actionArguments) { + case .failure(.timedOut): return .failure(.timedOut) + case .failure: return .failure(.launchFailed) + case .success(let action): + guard action.status == 0 else { return .failure(.actionFailed(action.status)) } + let after = VolumeReader.read(fileURL: url) + let delta: Int64? = before.totalBytes > 0 && after.totalBytes == before.totalBytes + ? after.availableBytes - before.availableBytes : nil + return .success(OwnerCommandOutcome(target: confirmed, capacityDeltaBytes: delta, output: action.output)) + } + } + + private func validDirectory(_ raw: String) -> String? { + guard raw.hasPrefix("/"), !raw.contains("\0"), !raw.contains("\r"), + !raw.contains("\n"), !raw.contains("/../") else { return nil } + let canonical = URL(fileURLWithPath: raw).resolvingSymlinksInPath().standardizedFileURL.path + let homePath = URL(fileURLWithPath: home).resolvingSymlinksInPath().standardizedFileURL.path + guard canonical != "/", canonical != homePath, + URL(fileURLWithPath: canonical).pathComponents.count > 2 else { return nil } + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: canonical, isDirectory: &isDirectory), + isDirectory.boolValue else { return nil } + return canonical + } + + private func locateExecutables() -> [String] { + var directories = (environment["PATH"] ?? "").split(separator: ":").map(String.init) + directories += ["/opt/homebrew/bin", "/usr/local/bin", home + "/.local/share/pnpm", home + "/Library/pnpm"] + let nvm = home + "/.nvm/versions/node" + if let versions = try? FileManager.default.contentsOfDirectory(atPath: nvm) { + directories += versions.sorted().reversed().map { nvm + "/" + $0 + "/bin" } + } + let candidates = explicitExecutable.map { [$0] } + ?? directories.map { $0 + "/" + recipe.executableName } + var seen = Set() + return candidates.filter { + $0.hasPrefix("/") && seen.insert($0).inserted + && FileManager.default.isExecutableFile(atPath: $0) + } + } + + private func run(executable: String, arguments: [String], isProbe: Bool = false, timeLimit: TimeInterval? = nil) -> Result<(status: Int32, output: String), OwnerCommandFailure> { + guard executable.hasPrefix("/"), !executable.contains("\0"), + !arguments.contains(where: { $0.contains("\0") }) else { return .failure(.launchFailed) } + var env = environment + env["HOME"] = home + if isProbe { env["COREPACK_ENABLE_NETWORK"] = "0" } + env["PATH"] = URL(fileURLWithPath: executable).deletingLastPathComponent().path + ":" + (env["PATH"] ?? "/usr/bin:/bin") + guard !env.contains(where: { $0.key.contains("=") || $0.key.contains("\0") || $0.value.contains("\0") }) else { + return .failure(.launchFailed) + } + var descriptors: [Int32] = [0, 0] + guard Darwin.pipe(&descriptors) == 0 else { return .failure(.launchFailed) } + let readFD = descriptors[0], writeFD = descriptors[1] + defer { close(readFD) } + var writerOpen = true + defer { if writerOpen { close(writeFD) } } + guard fcntl(readFD, F_SETFL, O_NONBLOCK) == 0 else { return .failure(.launchFailed) } + var attributes: posix_spawnattr_t? + guard posix_spawnattr_init(&attributes) == 0 else { return .failure(.launchFailed) } + defer { posix_spawnattr_destroy(&attributes) } + var actions: posix_spawn_file_actions_t? + guard posix_spawn_file_actions_init(&actions) == 0 else { return .failure(.launchFailed) } + defer { posix_spawn_file_actions_destroy(&actions) } + // PGID 0 means the child's own PID. Darwin establishes it before exec: + // a failed attribute or file action prevents the owner command from running. + guard posix_spawnattr_setpgroup(&attributes, 0) == 0, + posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_CLOEXEC_DEFAULT)) == 0, + posix_spawn_file_actions_addchdir_np(&actions, home) == 0, + posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0) == 0, + posix_spawn_file_actions_adddup2(&actions, writeFD, STDOUT_FILENO) == 0, + posix_spawn_file_actions_addopen(&actions, STDERR_FILENO, "/dev/null", O_WRONLY, 0) == 0, + posix_spawn_file_actions_addclose(&actions, readFD) == 0, + posix_spawn_file_actions_addclose(&actions, writeFD) == 0 else { return .failure(.launchFailed) } + let argv = ([executable] + arguments).map { strdup($0) } + let envp = env.map { strdup($0.key + "=" + $0.value) } + defer { for value in argv + envp { free(value) } } + guard argv.allSatisfy({ $0 != nil }), envp.allSatisfy({ $0 != nil }) else { return .failure(.launchFailed) } + var pid: pid_t = 0 + let spawnStatus = (argv + [nil]).withUnsafeBufferPointer { args in + (envp + [nil]).withUnsafeBufferPointer { vars in + posix_spawn(&pid, executable, &actions, &attributes, args.baseAddress!, vars.baseAddress!) + } + } + guard spawnStatus == 0 else { return .failure(.launchFailed) } + close(writeFD) + writerOpen = false + let output = OutputBuffer() + var bytes = [UInt8](repeating: 0, count: 8192) + func drain() { + // Bound work per poll even if a producer never stops writing. + for _ in 0..<32 { + let count = Darwin.read(readFD, &bytes, bytes.count) + if count <= 0 { break } + output.append(Data(bytes.prefix(count))) + } + } + let started = ProcessInfo.processInfo.systemUptime + var expired = false + var observationFailed = false + while true { + drain() + var info = siginfo_t() + // Leave the leader waitable until after group termination, so its PID + // cannot be reused and accidentally target an unrelated process group. + let result = waitid(P_PID, id_t(pid), &info, WEXITED | WNOHANG | WNOWAIT) + if result != 0 { + if errno == EINTR { continue } + observationFailed = true + break + } + if info.si_pid == pid { break } + if ProcessInfo.processInfo.systemUptime - started >= (timeLimit ?? timeout) { + expired = true + break + } + Thread.sleep(forTimeInterval: 0.01) + } + // Also clean descendants when a wrapper exits before them. SIGKILL cannot + // be ignored; do not rely on the wrapper forwarding termination signals. + let killed = kill(-pid, SIGKILL) + let killError = errno + var status: Int32 = 0 + var waited: pid_t + repeat { waited = waitpid(pid, &status, 0) } while waited == -1 && errno == EINTR + drain() + // Darwin can return EPERM for a group containing only the zombie + // leader. Accept that only after reaping and confirming the group is gone. + let groupGone = kill(-pid, 0) == -1 && errno == ESRCH + guard (killed == 0 || killError == ESRCH || groupGone), waited == pid, !observationFailed else { + return .failure(.launchFailed) + } + if expired { return .failure(.timedOut) } + let exitStatus = (status & 0x7f) == 0 ? (status >> 8) & 0xff : 128 + (status & 0x7f) + return .success((exitStatus, output.string)) + } +} + +private final class OutputBuffer: @unchecked Sendable { + private let lock = NSLock() + private var data = Data() + func append(_ chunk: Data) { + lock.lock() + if data.count < 65_536 { data.append(chunk.prefix(65_536 - data.count)) } + lock.unlock() + } + var string: String { + lock.lock(); defer { lock.unlock() } + return String(decoding: data, as: UTF8.self) + } +} + +public struct OwnerCommandRecord: Codable, Sendable, Identifiable { + public let id: UUID + public let timestamp: Date + public let recipeID: String + public let targetPath: String + public let outcome: String + public let capacityDeltaBytes: Int64? + /// Links a completion to its persisted attempt. Nil in older records and attempts. + public let attemptID: UUID? + + public init(recipeID: String, targetPath: String, outcome: String, capacityDeltaBytes: Int64?, attemptID: UUID? = nil) { + self.id = UUID() + self.timestamp = Date() + self.recipeID = recipeID + self.targetPath = targetPath + self.outcome = outcome + self.capacityDeltaBytes = capacityDeltaBytes + self.attemptID = attemptID + } +} + +public enum OwnerCommandJournalResult: Sendable { + case startNotSaved + case completionNotSaved(Result, OwnerCommandRecord) + case completed(Result, OwnerCommandRecord) +} + +/// Runs a destructive owner command only after its attempt is persisted. +/// Returns as soon as completion persistence is attempted, before UI refresh work. +public struct OwnerCommandJournal: Sendable { + private let store: OwnerCommandRecordStore + + public init(store: OwnerCommandRecordStore) { self.store = store } + + public func execute( + recipeID: String, targetPath: String, + action: () -> Result + ) -> OwnerCommandJournalResult { + let attempt = OwnerCommandRecord(recipeID: recipeID, targetPath: targetPath, + outcome: "started", capacityDeltaBytes: nil) + do { try store.append(attempt) } + catch { return .startNotSaved } + + let result = action() + let outcome: String + let delta: Int64? + switch result { + case .success(let success): outcome = "success"; delta = success.capacityDeltaBytes + case .failure(let failure): outcome = String(describing: failure); delta = nil + } + let completion = OwnerCommandRecord(recipeID: recipeID, targetPath: targetPath, + outcome: outcome, capacityDeltaBytes: delta, + attemptID: attempt.id) + do { try store.append(completion) } + catch { return .completionNotSaved(result, attempt) } + return .completed(result, completion) + } +} + +public struct OwnerCommandRecordStore: Sendable { + private let paths: StoragePaths + private let store: JSONStoring + + public init(paths: StoragePaths, store: JSONStoring = JSONStore()) { + self.paths = paths + self.store = store + } + + public func entries() throws -> [OwnerCommandRecord] { + try store.load([OwnerCommandRecord].self, from: paths.baseURL.appendingPathComponent("owner-command-log.json")) ?? [] + } + + public func append(_ entry: OwnerCommandRecord) throws { + let url = paths.baseURL.appendingPathComponent("owner-command-log.json") + var all = try entries() + all.append(entry) + try store.save(all, to: url) + } +} diff --git a/Sources/DiskReservoirCore/Recipes/OwnerManagedPathGuard.swift b/Sources/DiskReservoirCore/Recipes/OwnerManagedPathGuard.swift new file mode 100644 index 0000000..30d57d3 --- /dev/null +++ b/Sources/DiskReservoirCore/Recipes/OwnerManagedPathGuard.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Protects owner-managed stores from deletion through a different recipe, +/// including old aggregate snapshots and symlink aliases of an ancestor. +public enum OwnerManagedPathGuard { + public static func overlaps(_ path: String, storePath: String) -> Bool { + let candidate = canonical(path) + let store = canonical(storePath) + return candidate == store + || candidate.hasPrefix(store + "/") + || store.hasPrefix(candidate + "/") + } + + public static func mayDelete( + path: String, + recipeID: String, + probe: Result, + homeDirectory: String = NSHomeDirectory(), + knownStorePaths: [String] = [], + historyOverflowed: Bool = false + ) -> Bool { + guard recipeID != OwnerCommandRecipe.pnpmStorePrune.id else { return false } + // At least one older target was evicted. Its path is unknown, so no + // raw path can be proven disjoint from the complete owner history. + if historyOverflowed { return false } + if knownStorePaths.contains(where: { overlaps(path, storePath: $0) }) { + return false + } + if isRecognizablePnpmLocation(path, homeDirectory: homeDirectory) { + return false + } + switch probe { + case .success(let target): + return !overlaps(path, storePath: target.path) + case .failure(.unavailable): + // No executable is detectable. Preserve unrelated cleanup; an + // arbitrary orphan store without a saved target is unknowable. + return true + case .failure: + // User-added package-manager roots may be a store or its parent; + // without a trustworthy probe, their raw deletion is unsafe. + if recipeID == PackageManagerRecipes.customID { return false } + // A present but broken executable cannot establish where its + // store sits; broad package/cache roots fail closed. + if recipeID == PackageManagerRecipes.familyID || recipeID == "library-caches" { + return false + } + return true + } + } + + public static func isRecognizablePnpmLocation( + _ path: String, homeDirectory: String + ) -> Bool { + let roots = [ + "Library/pnpm", "Library/Caches/pnpm", + ".local/share/pnpm", ".cache/pnpm", ".pnpm-store", + ].map { homeDirectory + "/" + $0 } + return roots.contains { overlaps(path, storePath: $0) } + } + + private static func canonical(_ path: String) -> String { + URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL.path + } +} diff --git a/Sources/DiskReservoirCore/Recipes/PackageManagerRecipes.swift b/Sources/DiskReservoirCore/Recipes/PackageManagerRecipes.swift index e5d9dc6..5fbd2d8 100644 --- a/Sources/DiskReservoirCore/Recipes/PackageManagerRecipes.swift +++ b/Sources/DiskReservoirCore/Recipes/PackageManagerRecipes.swift @@ -1,23 +1,54 @@ import Foundation -/// 包管理器缓存配方族:npm / pnpm / uv / CocoaPods / Homebrew 等 +/// 包管理器缓存配方族:npm / uv / CocoaPods / Homebrew 等。 +/// pnpm store 由独立的手动官方命令配方管理,绝不按路径删除。 /// “可再生的全局缓存”归为一个配方,多路径聚合为一个清理条目。 /// 与项目内 node_modules(进回收站、需用户确认、活跃窗口保护)性质不同: /// 全局包管理器缓存可安全永久删除。 /// 用户可在增长洞察中把新发现的缓存目录加入该配方作用域(extraRoots)。 public enum PackageManagerRecipes { public static let familyID = "package-manager-caches" + public static let customID = "package-manager-custom" + + /// Reject old snapshot targets too; removing the path from current scan + /// recipes alone does not invalidate persisted aggregate scan items. + public static func isLegacyPnpmPath(_ path: String, homeDirectory: String) -> Bool { + let input = URL(fileURLWithPath: path).standardizedFileURL.path + let candidate = URL(fileURLWithPath: path) + .resolvingSymlinksInPath().standardizedFileURL.path + // A saved aggregate may refer to an earlier HOME. Check path segments + // as well as the current home's canonical root; the latter also catches + // aliases into a symlinked pnpm directory. + if [input, candidate].contains(where: { path in + let lower = path.lowercased() + return lower.hasSuffix("/library/pnpm") || lower.contains("/library/pnpm/") + }) { + return true + } + let root = URL(fileURLWithPath: homeDirectory + "/Library/pnpm") + .resolvingSymlinksInPath().standardizedFileURL.path + return candidate == root || candidate.hasPrefix(root + "/") + } public static func defaultPaths(homeDirectory: String) -> [String] { [ homeDirectory + "/.npm", - homeDirectory + "/Library/pnpm", homeDirectory + "/.cache/uv", homeDirectory + "/Library/Caches/CocoaPods", homeDirectory + "/Library/Caches/Homebrew", ] } + /// Persisted aggregate paths are untrusted. The built-in family can only + /// delete exact current default roots, even if an old scan included a + /// parent directory or a formerly configured extra root. + public static func isApprovedDefaultCachePath(_ path: String, homeDirectory: String) -> Bool { + let target = URL(fileURLWithPath: path).standardizedFileURL.path + return defaultPaths(homeDirectory: homeDirectory).contains { + URL(fileURLWithPath: $0).standardizedFileURL.path == target + } + } + public static func make( extraRoots: [String], homeDirectory: String @@ -47,7 +78,7 @@ public enum PackageManagerRecipes { /// unattended deletion boundary. public static func makeCustom(extraRoots: [String]) -> Recipe { Recipe( - id: "package-manager-custom", + id: customID, name: "用户添加的包管理器缓存", category: .packageManager, group: .packageManager, diff --git a/Sources/DiskReservoirCore/Scanner/ScanDisplayPolicy.swift b/Sources/DiskReservoirCore/Scanner/ScanDisplayPolicy.swift index c3f59c4..a8c7426 100644 --- a/Sources/DiskReservoirCore/Scanner/ScanDisplayPolicy.swift +++ b/Sources/DiskReservoirCore/Scanner/ScanDisplayPolicy.swift @@ -16,6 +16,12 @@ public enum ScanDisplayPolicy { public static func visibleItems(_ items: [ScanItem], recipes: [Recipe]) -> [ScanItem] { let minimumByID = Dictionary(uniqueKeysWithValues: recipes.map { ($0.id, $0.minimumSizeMB) }) return items.filter { item in + if item.recipeID == OwnerCommandRecipe.pnpmStorePrune.id { + guard let current = recipes.first(where: { $0.id == item.recipeID }), + current.cleanability == .watchOnly, + current.disposition == .none, + current.resolvePaths(StoragePaths()).contains(item.path) else { return false } + } if item.recipeID == "trash" || item.recipeID == "own-trash-batches" { return true } let minimumMB = minimumByID[item.recipeID] ?? 0 return Double(item.allocatedBytes) >= max(0, minimumMB) * 1_000_000 diff --git a/Sources/DiskReservoirCore/Scanner/Scanner.swift b/Sources/DiskReservoirCore/Scanner/Scanner.swift index db7e70a..c5bebec 100644 --- a/Sources/DiskReservoirCore/Scanner/Scanner.swift +++ b/Sources/DiskReservoirCore/Scanner/Scanner.swift @@ -21,17 +21,20 @@ public struct Scanner: Sendable { /// 每个配方覆盖的年龄阈值(天),来自 Config.rules 的用户设置; /// 未配置时回落到 recipe.defaultAgeDays。 private let ageDaysByRecipe: [String: Int] + private let protectedOwnerPaths: [String] public init( now: @escaping @Sendable () -> Date = { Date() }, includeHidden: Bool = true, cloneRatios: [String: Double] = [:], - ageDaysByRecipe: [String: Int] = [:] + ageDaysByRecipe: [String: Int] = [:], + protectedOwnerPaths: [String] = [] ) { self.now = now self.includeHidden = includeHidden self.cloneRatios = cloneRatios self.ageDaysByRecipe = ageDaysByRecipe + self.protectedOwnerPaths = protectedOwnerPaths } public func scan(recipes: [Recipe], homeDirectory: String) throws -> ScanResult { @@ -39,6 +42,9 @@ public struct Scanner: Sendable { // 其他配方已解析的路径:测量本配方时跳过这些子树,避免同一目录被 // 两个配方分别统计(如 ~/Library/Caches 与其内部 Homebrew/CocoaPods)。 let allResolved = recipes.map { $0.resolvePaths(paths) } + let ownerPaths = recipes + .filter { $0.id == OwnerCommandRecipe.pnpmStorePrune.id } + .flatMap { $0.resolvePaths(paths) } var items: [ScanItem] = [] var records: [FileRecord] = [] for recipe in recipes { @@ -49,15 +55,31 @@ public struct Scanner: Sendable { || !path.hasPrefix("/Library/Developer/CoreSimulator/") } let ownPaths = Set(resolved) - let excludedPaths = Set(allResolved.flatMap { $0 }).subtracting(ownPaths) + var excludedPaths = Set(allResolved.flatMap { $0 }).subtracting(ownPaths) + // The owner-managed pnpm store wins even when another recipe + // resolves the exact same root and set subtraction would erase it. + if recipe.id != OwnerCommandRecipe.pnpmStorePrune.id { + excludedPaths.formUnion(ownerPaths) + excludedPaths.formUnion(protectedOwnerPaths) + } if recipe.aggregatesPaths { - if let item = aggregateItem(recipe: recipe, paths: resolved, homeDirectory: homeDirectory) { + if let item = aggregateItem( + recipe: recipe, paths: resolved, homeDirectory: homeDirectory, + excludedPaths: excludedPaths + ) { items.append(item) } continue } for path in resolved { guard FileManager.default.fileExists(atPath: path) else { continue } + if recipe.id != OwnerCommandRecipe.pnpmStorePrune.id { + let canonical = URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL.path + if (ownerPaths + protectedOwnerPaths).contains(where: { + let owner = URL(fileURLWithPath: $0).resolvingSymlinksInPath().standardizedFileURL.path + return canonical == owner || canonical.hasPrefix(owner + "/") + }) { continue } + } if recipe.usageProbe == .simulatorRuntimeLastBooted { items.append(contentsOf: runtimeItems( recipe: recipe, @@ -126,9 +148,23 @@ public struct Scanner: Sendable { excludedPaths: Set = [] ) -> [ScanItem] { guard FileManager.default.fileExists(atPath: path) else { return [] } + if recipe.id != OwnerCommandRecipe.pnpmStorePrune.id { + let canonical = URL(fileURLWithPath: path) + .resolvingSymlinksInPath().standardizedFileURL.path + if protectedOwnerPaths.contains(where: { + let protected = URL(fileURLWithPath: $0) + .resolvingSymlinksInPath().standardizedFileURL.path + return canonical == protected || canonical.hasPrefix(protected + "/") + }) { return [] } + } if recipe.aggregatesPaths { let resolved = recipe.resolvePaths(Self.recipeStoragePaths(homeDirectory: homeDirectory)) - return aggregateItem(recipe: recipe, paths: resolved, homeDirectory: homeDirectory).map { [$0] } ?? [] + return aggregateItem( + recipe: recipe, paths: resolved, homeDirectory: homeDirectory, + excludedPaths: excludedPaths.union( + recipe.id == OwnerCommandRecipe.pnpmStorePrune.id ? [] : Set(protectedOwnerPaths) + ) + ).map { [$0] } ?? [] } if recipe.usageProbe == .simulatorRuntimeLastBooted { return runtimeItems(recipe: recipe, parentPath: path, homeDirectory: homeDirectory) @@ -139,7 +175,9 @@ public struct Scanner: Sendable { url, itemID: itemID, lightWeight: recipe.disposition == .none, - excludedPaths: excludedPaths + excludedPaths: excludedPaths.union( + recipe.id == OwnerCommandRecipe.pnpmStorePrune.id ? [] : Set(protectedOwnerPaths) + ) ) else { return [] } var item = ScanItem( id: itemID, @@ -168,21 +206,40 @@ public struct Scanner: Sendable { /// 聚合路径配方:把 resolvePaths 的多个路径合并为一个条目 /// (项目目录聚类:一个"项目 node_modules"条目汇总所有项目)。 - private func aggregateItem(recipe: Recipe, paths: [String], homeDirectory: String) -> ScanItem? { + private func aggregateItem( + recipe: Recipe, paths: [String], homeDirectory: String, + excludedPaths: Set + ) -> ScanItem? { var size: Int64 = 0 var allocated: Int64 = 0 + var deletableAllocated: Int64 = 0 var count = 0 var newest: Date? var existing: [String] = [] let ageLimitDays = ageDaysByRecipe[recipe.id] ?? recipe.defaultAgeDays for path in paths { guard FileManager.default.fileExists(atPath: path) else { continue } + let canonicalRoot = URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL.path + let canonicalExcluded = excludedPaths.map { + URL(fileURLWithPath: $0).resolvingSymlinksInPath().standardizedFileURL.path + } + // A more specific recipe owns this entire root. + if canonicalExcluded.contains(where: { canonicalRoot == $0 || canonicalRoot.hasPrefix($0 + "/") }) { + continue + } + // `POSIXDirectoryWalker` walks through the spelling of `path`. + // Map excluded canonical descendants back through an aliased root. + let exclusionsForRoot = Set(canonicalExcluded.compactMap { excluded -> String? in + guard excluded.hasPrefix(canonicalRoot + "/") else { return nil } + return path + String(excluded.dropFirst(canonicalRoot.count)) + }) let url = URL(fileURLWithPath: path, isDirectory: true) let itemID = "\(recipe.id):\(path)" guard let (s, a, c, m, _) = try? measureDirectory( url, itemID: itemID, - lightWeight: true + lightWeight: true, + excludedPaths: exclusionsForRoot ) else { continue } // 只聚合“足够老”的子路径:未达到年龄阈值或最近 24h 有修改的 // 项目不进入可清理清单(也不参与清理)。 @@ -198,15 +255,16 @@ public struct Scanner: Sendable { existing.append(path) size += s allocated += a + if exclusionsForRoot.isEmpty { deletableAllocated += a } count += c if let effective, effective > (newest ?? .distantPast) { newest = effective } } guard !existing.isEmpty else { return nil } - var reclaimable = recipe.cleanability == .watchOnly ? 0 : allocated + var reclaimable = recipe.cleanability == .watchOnly ? 0 : deletableAllocated if recipe.cloneProne && recipe.cleanability != .watchOnly { - reclaimable = Int64(Double(allocated) * (cloneRatios[recipe.id] ?? 0.2)) + reclaimable = Int64(Double(deletableAllocated) * (cloneRatios[recipe.id] ?? 0.2)) } return ScanItem( id: "\(recipe.id):aggregate", diff --git a/Sources/poolproblem/Commands/CleanCommand.swift b/Sources/poolproblem/Commands/CleanCommand.swift index 7eaa262..f6812f8 100644 --- a/Sources/poolproblem/Commands/CleanCommand.swift +++ b/Sources/poolproblem/Commands/CleanCommand.swift @@ -26,7 +26,8 @@ struct CleanCommand: ParsableCommand { evaluator: evaluator, deleter: FileManagerFileDeleter(), inspector: PGrepProcessInspector(), - logStore: logStore + logStore: logStore, + homeDirectory: paths.homeDirectory ) let outcome: CleanOutcome if dryRun { diff --git a/Sources/poolproblem/Commands/MCPCommand.swift b/Sources/poolproblem/Commands/MCPCommand.swift index e99dd73..ccedbe0 100644 --- a/Sources/poolproblem/Commands/MCPCommand.swift +++ b/Sources/poolproblem/Commands/MCPCommand.swift @@ -218,7 +218,8 @@ private final class MCPServer { evaluator: evaluator, deleter: FileManagerFileDeleter(), inspector: PGrepProcessInspector(), - logStore: CleanLogStore(paths: paths) + logStore: CleanLogStore(paths: paths), + homeDirectory: paths.homeDirectory ).run( scan: result, config: config, diff --git a/Tests/DiskReservoirCoreTests/PnpmStoreRecipeEndToEndTests.swift b/Tests/DiskReservoirCoreTests/PnpmStoreRecipeEndToEndTests.swift new file mode 100644 index 0000000..c1792e0 --- /dev/null +++ b/Tests/DiskReservoirCoreTests/PnpmStoreRecipeEndToEndTests.swift @@ -0,0 +1,319 @@ +import Foundation +import Testing +@testable import DiskReservoirCore + +private struct PnpmFixture { + let root: URL + let home: URL + let store: URL + + init() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent("pp-pnpm-e2e-\(UUID())") + home = root.appendingPathComponent("home") + store = root.appendingPathComponent("store") + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: store, withIntermediateDirectories: true) + } + + func executable(at relativePath: String, body: String) throws -> String { + let url = root.appendingPathComponent(relativePath) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try ("#!/bin/sh\n" + body + "\n").write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } + + func close() { try? FileManager.default.removeItem(at: root) } +} + +private final class PnpmDeletionRecorder: FileDeleting, @unchecked Sendable { + private let lock = NSLock() + private var recorded: [String] = [] + var paths: [String] { lock.lock(); defer { lock.unlock() }; return recorded } + func delete(url: URL, disposition: CleanDisposition) throws -> Int64 { + lock.lock(); recorded.append(url.path); lock.unlock() + return 1 + } +} + +@Test func customAndOldSnapshotRootsCannotDeleteDynamicPnpmStore() throws { + let f = try PnpmFixture(); defer { f.close() } + let parent = f.root.appendingPathComponent("parent") + let store = parent.appendingPathComponent("unexpected-store") + let safe = f.root.appendingPathComponent("safe") + let alias = f.root.appendingPathComponent("parent-alias") + try FileManager.default.createDirectory(at: store, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: safe, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: parent) + try Data("keep".utf8).write(to: store.appendingPathComponent("sentinel")) + _ = try f.executable(at: "home/.nvm/versions/node/v1/bin/pnpm", body: "echo '\(store.path)'") + let old = Date(timeIntervalSince1970: 1_000_000) + func item(_ path: String, id: String) -> ScanItem { + ScanItem( + id: id, recipeID: "package-manager-custom", name: "old custom cache", + path: path, category: .packageManager, safety: .safeWhileRunning, + disposition: .deletePermanently, sizeBytes: 1000, allocatedBytes: 1000, + reclaimableBytes: 1000, fileCount: 1, lastModified: old + ) + } + let scan = ScanResult( + volume: VolumeInfo(totalBytes: 100_000, availableBytes: 0, timestamp: old), + items: [item(parent.path, id: "parent"), item(alias.path, id: "alias"), item(safe.path, id: "safe")], + records: [], volumeURL: f.root + ) + let recorder = PnpmDeletionRecorder() + let homePath = f.home.path + _ = try Cleaner( + evaluator: RuleEvaluator(config: .default, now: { old }), + deleter: recorder, inspector: AlwaysFalseProcessInspector(), + logStore: CleanLogStore(paths: StoragePaths(baseURL: f.root.appendingPathComponent("logs"), homeDirectory: f.home.path)), + homeDirectory: homePath, availableBytesReader: { _ in 0 }, now: { old }, + ownerStoreProbe: { + OwnerCommandRunner( + recipe: .pnpmStorePrune, + environment: ["PATH": homePath + "/missing-bin"], home: homePath + ).probe() + } + ).run(scan: scan, config: .default, waterlineBytes: 10_000, forceClean: true, ignoreAge: true) + #expect(!recorder.paths.contains(parent.path)) + #expect(!recorder.paths.contains(alias.path)) + #expect(recorder.paths.contains(safe.path)) + #expect(FileManager.default.fileExists(atPath: store.appendingPathComponent("sentinel").path)) +} + +@Test func aggregateScanExcludesNestedPnpmStoreWithoutHidingSiblingCache() throws { + let f = try PnpmFixture(); defer { f.close() } + let cache = f.home.appendingPathComponent(".npm") + let store = cache.appendingPathComponent("custom-pnpm-store") + try FileManager.default.createDirectory(at: store, withIntermediateDirectories: true) + try Data(repeating: 1, count: 11_000_000).write(to: store.appendingPathComponent("package")) + try Data(repeating: 2, count: 2_000_000).write(to: cache.appendingPathComponent("npm-cache")) + let target = OwnerCommandTarget(executable: "/fixture/pnpm", path: store.path) + let recipes = [ + PackageManagerRecipes.make(extraRoots: [], homeDirectory: f.home.path), + OwnerCommandRecipe.pnpmStorePrune.scanRecipe(target: target), + ] + let scan = try Scanner(now: { Date().addingTimeInterval(100 * 86_400) }) + .scan(recipes: recipes, homeDirectory: f.home.path) + let pnpm = try #require(scan.items.first { $0.recipeID == OwnerCommandRecipe.pnpmStorePrune.id }) + let npm = try #require(scan.items.first { $0.recipeID == PackageManagerRecipes.familyID }) + #expect(pnpm.allocatedBytes >= 10_000_000) + #expect(npm.allocatedBytes >= 2_000_000) + #expect(npm.allocatedBytes < pnpm.allocatedBytes) + #expect(npm.reclaimableBytes == 0) +} + +@Test func approvedDefaultCacheFailsClosedWhenPnpmProbeFails() throws { + let f = try PnpmFixture(); defer { f.close() } + let npm = f.home.appendingPathComponent(".npm") + let store = npm.appendingPathComponent("unexpected-store") + try FileManager.default.createDirectory(at: store, withIntermediateDirectories: true) + try Data("keep".utf8).write(to: store.appendingPathComponent("package")) + let old = Date(timeIntervalSince1970: 1_000_000) + let item = ScanItem( + id: "old-family", recipeID: PackageManagerRecipes.familyID, name: "package cache", + path: npm.path, category: .packageManager, safety: .safeWhileRunning, + disposition: .deletePermanently, sizeBytes: 1000, allocatedBytes: 1000, + reclaimableBytes: 1000, fileCount: 1, lastModified: old, + allowsAutomaticPermanentDeletion: true + ) + let scan = ScanResult( + volume: VolumeInfo(totalBytes: 100_000, availableBytes: 0, timestamp: old), + items: [item], records: [], volumeURL: f.root + ) + let recorder = PnpmDeletionRecorder() + _ = try Cleaner( + evaluator: RuleEvaluator(config: .default, now: { old }), + deleter: recorder, inspector: AlwaysFalseProcessInspector(), + logStore: CleanLogStore(paths: StoragePaths(baseURL: f.root.appendingPathComponent("logs"), homeDirectory: f.home.path)), + homeDirectory: f.home.path, availableBytesReader: { _ in 0 }, now: { old }, + ownerStoreProbe: { .failure(.probeFailed(11)) } + ).run(scan: scan, config: .default, waterlineBytes: 10_000, + ignoreAge: true, source: .auto) + #expect(recorder.paths.isEmpty) + #expect(FileManager.default.fileExists(atPath: store.appendingPathComponent("package").path)) + let separateStore = f.root.appendingPathComponent("separate-pnpm-store") + try FileManager.default.createDirectory(at: separateStore, withIntermediateDirectories: true) + let safeRecorder = PnpmDeletionRecorder() + _ = try Cleaner( + evaluator: RuleEvaluator(config: .default, now: { old }), + deleter: safeRecorder, inspector: AlwaysFalseProcessInspector(), + logStore: CleanLogStore(paths: StoragePaths(baseURL: f.root.appendingPathComponent("safe-logs"), homeDirectory: f.home.path)), + homeDirectory: f.home.path, availableBytesReader: { _ in 0 }, now: { old }, + ownerStoreProbe: { + .success(OwnerCommandTarget(executable: "/fixture/pnpm", path: separateStore.path)) + } + ).run(scan: scan, config: .default, waterlineBytes: 10_000, + ignoreAge: true, source: .auto) + #expect(safeRecorder.paths == [npm.path]) +} + +@Test func unavailablePnpmKeepsOtherCachesUsableAndProtectsKnownLocations() throws { + let f = try PnpmFixture(); defer { f.close() } + let npm = f.home.appendingPathComponent(".npm") + let recognizable = f.home.appendingPathComponent("Library/Caches/pnpm") + try FileManager.default.createDirectory(at: npm, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: recognizable, withIntermediateDirectories: true) + let unavailable: Result = .failure(.unavailable) + #expect(OwnerManagedPathGuard.mayDelete( + path: npm.path, recipeID: PackageManagerRecipes.familyID, + probe: unavailable, homeDirectory: f.home.path + )) + #expect(!OwnerManagedPathGuard.mayDelete( + path: recognizable.path, recipeID: "library-caches", + probe: unavailable, homeDirectory: f.home.path + )) + #expect(!OwnerManagedPathGuard.mayDelete( + path: npm.path, recipeID: PackageManagerRecipes.familyID, + probe: unavailable, homeDirectory: f.home.path, + knownStorePaths: [npm.appendingPathComponent("past-store").path] + )) +} + +@Test func historicalStoreAStillProtectsScanAndAutoCleanAfterPnpmMovesToB() throws { + let f = try PnpmFixture(); defer { f.close() } + let a = f.home.appendingPathComponent(".npm/pnpm-store") + let b = f.home.appendingPathComponent("Library/pnpm") + let uv = f.home.appendingPathComponent(".cache/uv") + for directory in [a, b, uv] { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + try Data(repeating: 1, count: 11_000_000).write(to: a.appendingPathComponent("old-package")) + try Data(repeating: 2, count: 11_000_000).write(to: b.appendingPathComponent("current-package")) + try Data(repeating: 3, count: 2_000_000).write(to: uv.appendingPathComponent("uv-cache")) + let paths = StoragePaths(baseURL: f.root.appendingPathComponent("data"), homeDirectory: f.home.path) + let legacyItem = ScanItem( + id: "pnpm-store-prune:\(a.path)", recipeID: OwnerCommandRecipe.pnpmStorePrune.id, + name: "pnpm store", path: a.path, category: .packageManager, + safety: .userConfirm, disposition: .none, sizeBytes: 11_000_000, + allocatedBytes: 11_000_000, reclaimableBytes: 0, fileCount: 1, + lastModified: nil, cleanability: .watchOnly + ) + let volume = VolumeInfo(totalBytes: 100_000_000, availableBytes: 0, timestamp: Date()) + try SnapshotStore(paths: paths).append(Snapshot(volume: volume, items: [legacyItem])) + let knownStore = OwnerCommandKnownTargetsStore(paths: paths) + _ = try knownStore.migrate(snapshots: SnapshotStore(paths: paths).snapshots()) + _ = try knownStore.record([b.path]) + try SnapshotStore(paths: paths).prune(retainingDays: 0) + #expect(try SnapshotStore(paths: paths).snapshots().isEmpty) + let observed = try OwnerCommandKnownTargetsStore(paths: paths).paths() + #expect(Set(observed) == Set([a.path, b.path])) + + let current = OwnerCommandTarget(executable: "/fixture/pnpm", path: b.path) + let recipes = [ + PackageManagerRecipes.make(extraRoots: [], homeDirectory: f.home.path), + OwnerCommandRecipe.pnpmStorePrune.scanRecipe(target: current), + ] + let future = Date().addingTimeInterval(100 * 86_400) + let scan = try Scanner(now: { future }, protectedOwnerPaths: observed) + .scan(recipes: recipes, homeDirectory: f.home.path) + let owner = try #require(scan.items.first { $0.recipeID == currentRecipeID }) + let package = try #require(scan.items.first { $0.recipeID == PackageManagerRecipes.familyID }) + #expect(owner.path == b.path) + #expect(owner.allocatedBytes >= 10_000_000) + #expect(package.allocatedBytes >= 2_000_000) + #expect(package.allocatedBytes < 10_000_000) + #expect(package.reclaimableBytes >= 2_000_000) + #expect(package.reclaimableBytes < 10_000_000) + + let recorder = PnpmDeletionRecorder() + _ = try Cleaner( + evaluator: RuleEvaluator(config: .default, now: { future }), + deleter: recorder, inspector: AlwaysFalseProcessInspector(), + logStore: CleanLogStore(paths: paths), homeDirectory: f.home.path, + availableBytesReader: { _ in 0 }, now: { future }, + ownerStoreProbe: { .success(current) }, knownOwnerStorePaths: observed + ).run(scan: scan, config: .default, + waterlineBytes: scan.volume.availableBytes + 10_000_000, + ignoreAge: true, source: .auto) + #expect(recorder.paths == [uv.path]) + #expect(FileManager.default.fileExists(atPath: a.appendingPathComponent("old-package").path)) + #expect(FileManager.default.fileExists(atPath: b.appendingPathComponent("current-package").path)) +} + +@Test func knownStoreHistoryOverflowFailsClosedForEveryRawDeletion() throws { + let f = try PnpmFixture(); defer { f.close() } + let paths = StoragePaths(baseURL: f.root.appendingPathComponent("data"), homeDirectory: f.home.path) + let knownStore = OwnerCommandKnownTargetsStore(paths: paths) + let candidates = (0...OwnerCommandKnownTargetsStore.maximumPaths).map { + f.home.appendingPathComponent("stores/store-\($0)").path + } + let state = try knownStore.record(candidates) + #expect(state.overflowed) + #expect(state.paths.count == OwnerCommandKnownTargetsStore.maximumPaths) + let reloaded = try OwnerCommandKnownTargetsStore(paths: paths).state() + #expect(reloaded.overflowed) + let unavailable: Result = .failure(.unavailable) + #expect(!OwnerManagedPathGuard.mayDelete( + path: f.home.appendingPathComponent(".npm").path, + recipeID: PackageManagerRecipes.familyID, + probe: unavailable, homeDirectory: f.home.path, + knownStorePaths: reloaded.paths, historyOverflowed: reloaded.overflowed + )) + #expect(!OwnerManagedPathGuard.mayDelete( + path: f.home.appendingPathComponent("unrelated-build-cache").path, + recipeID: "unrelated-recipe", + probe: unavailable, homeDirectory: f.home.path, + knownStorePaths: reloaded.paths, historyOverflowed: reloaded.overflowed + )) +} + +private let currentRecipeID = OwnerCommandRecipe.pnpmStorePrune.id + +@Test func exactCustomRootDoesNotDoubleCountOwnerStore() throws { + let f = try PnpmFixture(); defer { f.close() } + try Data(repeating: 1, count: 11_000_000).write(to: f.store.appendingPathComponent("package")) + let target = OwnerCommandTarget(executable: "/fixture/pnpm", path: f.store.path) + let recipes = [ + PackageManagerRecipes.makeCustom(extraRoots: [f.store.path]), + OwnerCommandRecipe.pnpmStorePrune.scanRecipe(target: target), + ] + let scan = try Scanner(now: { Date().addingTimeInterval(100 * 86_400) }) + .scan(recipes: recipes, homeDirectory: f.home.path) + #expect(scan.items.count == 1) + #expect(scan.items.first?.recipeID == OwnerCommandRecipe.pnpmStorePrune.id) +} + +@Test func pnpmProbeSkipsBrokenShimAndScansExactStoreWithoutDeletion() throws { + let f = try PnpmFixture(); defer { f.close() } + _ = try f.executable(at: "first/pnpm", body: "echo broken >&2; exit 1") + let valid = try f.executable(at: "second/pnpm", body: "echo warning >&2; echo '\(f.store.path)'") + try Data(repeating: 1, count: 11_000_000).write(to: f.store.appendingPathComponent("asset")) + let env = ["PATH": f.root.appendingPathComponent("first").path + ":" + f.root.appendingPathComponent("second").path] + let runner = OwnerCommandRunner(recipe: .pnpmStorePrune, environment: env, home: f.home.path) + let target = try runner.probe().get() + #expect(target.executable == valid) + #expect(target.path == f.store.path) + let recipe = OwnerCommandRecipe.pnpmStorePrune.scanRecipe(target: target) + #expect(recipe.cleanability == .watchOnly) + #expect(recipe.disposition == .none) + #expect(recipe.minimumSizeMB == 10) + let result = try Scanner().scan(recipes: [recipe], homeDirectory: f.home.path) + let item = try #require(result.items.first) + #expect(item.path == f.store.path) + #expect(item.allocatedBytes >= 10_000_000) + #expect(ScanDisplayPolicy.visibleItems(result.items, recipes: [recipe]).count == 1) + #expect(item.reclaimableBytes == 0) + #expect(!item.cleanability.allowsManualCleanup) + #expect(FileManager.default.fileExists(atPath: f.store.appendingPathComponent("asset").path)) +} + +@Test func pnpmProbeRejectsAmbiguousStoresAndDisablesCorepackNetwork() throws { + let f = try PnpmFixture(); defer { f.close() } + let other = f.root.appendingPathComponent("other") + try FileManager.default.createDirectory(at: other, withIntermediateDirectories: true) + _ = try f.executable(at: "first/pnpm", body: "test \"$COREPACK_ENABLE_NETWORK\" = 0 || exit 7; echo '\(f.store.path)'") + _ = try f.executable(at: "second/pnpm", body: "echo '\(other.path)'") + let env = ["PATH": f.root.appendingPathComponent("first").path + ":" + f.root.appendingPathComponent("second").path] + let runner = OwnerCommandRunner(recipe: .pnpmStorePrune, environment: env, home: f.home.path) + #expect(runner.probe() == .failure(.ambiguousTarget)) +} + +@Test func pnpmProbeRejectsMalformedPathsAndNeverRunsPrune() throws { + let f = try PnpmFixture(); defer { f.close() } + let marker = f.root.appendingPathComponent("pruned") + let executable = try f.executable(at: "bin/pnpm", body: "if test \"$2\" = prune; then touch '\(marker.path)'; exit 0; fi; echo /; echo '\(f.store.path)'") + let runner = OwnerCommandRunner(recipe: .pnpmStorePrune, executable: executable, home: f.home.path) + #expect(runner.probe() == .failure(.invalidTarget)) + #expect(!FileManager.default.fileExists(atPath: marker.path)) +} diff --git a/Tests/DiskReservoirCoreTests/RecipeRegistryTests.swift b/Tests/DiskReservoirCoreTests/RecipeRegistryTests.swift index 0815e3f..e81bae5 100644 --- a/Tests/DiskReservoirCoreTests/RecipeRegistryTests.swift +++ b/Tests/DiskReservoirCoreTests/RecipeRegistryTests.swift @@ -126,7 +126,7 @@ import Foundation #expect(recipe.category == .packageManager) let resolved = recipe.resolvePaths(StoragePaths(baseURL: nil, homeDirectory: "/Users/tester")) #expect(resolved.contains("/Users/tester/.npm")) - #expect(resolved.contains("/Users/tester/Library/pnpm")) + #expect(!resolved.contains("/Users/tester/Library/pnpm")) #expect(resolved.contains("/Users/tester/.cache/uv")) #expect(resolved.contains("/Users/tester/Library/Caches/CocoaPods")) #expect(resolved.contains("/Users/tester/Library/Caches/Homebrew")) diff --git a/docs/pnpm-store-recipe-verification.md b/docs/pnpm-store-recipe-verification.md new file mode 100644 index 0000000..e9cf859 --- /dev/null +++ b/docs/pnpm-store-recipe-verification.md @@ -0,0 +1,45 @@ +# pnpm store Recipe 验证 + +## 实现前失败方式清单 + +1. GUI `PATH` 的首个 pnpm 是不可用的 Corepack shim,而 NVM 等目录有可用 pnpm;首个失败不应阻断后续候选。 +2. 多个可执行候选报告不同的规范化 store 路径;必须拒绝歧义,不能随意选择。 +3. 同一 store 经符号链接、重复 PATH 或多个 pnpm 返回时,应该去重并接受。 +4. 进程退出非零、超时、启动失败、输出为空、多行、相对路径、根目录、HOME 目录、非目录或逃逸路径;不得形成 Recipe。 +5. stderr 包含警告或错误时,不能污染 stdout 的路径解析;失败诊断必须有界。 +6. Corepack 探测可能尝试网络;探测环境必须禁用网络,且不得经 shell 命令字符串执行。 +7. 扫描后目标变化或探测失败,旧 Recipe 和旧 ScanItem 不得获得路径删除权限。 +8. store 小于 10 MB 时不能出现在占用列表;达到阈值时显示实际测量占用。 +9. CLI、Cleaner、自动清理、智能清理及旧快照都不能按路径删除 store;官方 `pnpm store prune` 只能通过明确确认的 owner-command 流程执行。 +10. 确认后可执行文件消失或同一可执行文件的 store 路径变化;执行前必须重新探测并拒绝变化。 +11. 旧快照或用户自定义包管理器根目录可能恰好等于 store、是 store 的父目录,或经符号链接别名指向这些目录;即使条目 Recipe ID 不是 pnpm,也不得整体清理。 +12. pnpm store 在扫描后迁移;清理时必须重新探测,不能只用扫描时的路径。若重新探测失败或歧义,自定义根目录必须拒绝按路径清理。 +13. 自定义聚合条目混合安全路径与 store 父目录;可以跳过危险路径并保留安全路径,但 UI 整项按钮不能误导用户认为危险路径可清理。 +14. pnpm store 可被配置在已批准的默认缓存根目录(例如 `~/.npm`)之下;重新探测失败时,包管理器缓存家族也必须拒绝清理,不能把已批准根目录当作 store 安全证明。 +15. pnpm store 可位于 `~/Library/Caches/pnpm` 等一级缓存子目录;详情列表不能提供其清理操作,最终 `cleanCacheChild` 也必须重新探测并拒绝移动该子目录或其父目录。 +16. pnpm 可执行文件被卸载但 store 仍留在已知历史 target 或常见 pnpm 目录;仍需阻止原始路径清理。若没有任何可识别路径或历史记录,任意自定义位置的孤儿 store 无法从本机当前状态推断,保留普通缓存清理并明确记录这一剩余边界。 +17. store 从 A(例如 `~/.npm/pnpm-store`)迁至 B(例如 `~/Library/pnpm`)后,A 仍可保留实体文件;只记录最近一次 B 会令旧 A 被自动清理。必须持久保存有界、去重的所有已观察规范化 store 路径,且重启后加载,不能依赖会按 90 天清理的快照。 +18. A→B 后旧 A 不能继续计入 `.npm` 聚合条目的可回收量;当前 B 仍应独立计量。旧路径保护不得妨碍与 A 无重叠的安全缓存兄弟路径清理。 + +## 端到端验证记录 + +运行命令: + +```sh +mkdir -p "$TMPDIR/poolproblem-pnpm-recipe-verification/cache" +CLANG_MODULE_CACHE_PATH="$TMPDIR/poolproblem-pnpm-recipe-verification/cache" \ +SWIFT_MODULE_CACHE_PATH="$TMPDIR/poolproblem-pnpm-recipe-verification/cache" \ +swift test --disable-sandbox --filter PnpmStoreRecipeEndToEndTests +``` + +环境:macOS arm64、Xcode Swift 6;模块缓存置于 `$TMPDIR` 以满足工作区权限。输入:测试生成的假 pnpm 可执行文件和假 store,执行后自动删除 fixture;不会对真实 store 运行 prune。结果:10 项通过,覆盖路径别名、旧快照、嵌套及完全相同根目录、A→B 持久历史、快照清理后仍保留 A、1,024 条上限溢出保护、探测失败时的拒绝清理,以及安全缓存的保留清理能力。可重复核验的实际输出见 [测试日志](verification-artifacts/pnpm-store-recipe-e2e.log) 和 [运行记录](verification-artifacts/pnpm-store-recipe-e2e.txt)。 + +实现前运行的针对性失败复现及断言输出见 [失败复现记录](verification-artifacts/pnpm-store-recipe-failfirst.txt)。 + +A→B 新失败复现的命令、输入和缺失 API 编译错误见 [历史路径失败复现](verification-artifacts/pnpm-store-history-failfirst.txt)。历史路径写入 `pnpm-known-store-paths.json`,按规范化路径去重,最多保存 1,024 条;超过上限后持久化溢出标记,所有原始路径清理失败关闭,直到历史状态可恢复。初次升级从现存快照迁移历史路径;已在升级前被 90 天快照保留策略删除的更早路径无法恢复。 + +首次 GitHub CI 全量运行暴露了 A→B 测试环境假设:测试把水位固定在 90 MB,但 CI 机器的实际可用空间已经高于这个数,因此 Cleaner 正确地未进入清理。测试现以扫描时的实际可用空间加 10 MB 作为水位,确保测试的清理前置条件成立;改后 10 项针对性端到端测试通过。命令、环境、输入、结果与 CI 失败链接见 [CI 修复验证记录](verification-artifacts/pnpm-store-ci-fix.txt)和 [完整日志](verification-artifacts/pnpm-store-ci-fix.log)。 + +探测总预算为 30 秒,每个候选最多 8 秒;任一候选超时则拒绝形成可操作 target。现有错误 API 只返回进程退出码,stderr 已与路径输出分离并丢弃;因此如 Corepack 退出 11,界面能展示退出码但无法展示原始原因。这项诊断展示仍待补充。 + +AppService 的 macOS 端到端测试源码覆盖 `~/Library/Caches/pnpm` 子项列表与最终删除守卫。最新源码的 `xcodebuild build-for-testing` 成功,[编译记录](verification-artifacts/pnpm-store-child-build-for-testing.txt)含命令与结果;本环境的 test runner 在握手前以 0 退出,Xcode 判定 `Early unexpected exit`,因此断言未执行,[测试运行记录](verification-artifacts/pnpm-store-child-xcode-test.txt)保存此限制。先前一次 Swift Package 全量测试因并发探测耗时超过 3 分钟而中断;本轮最终全量测试的 helper 运行超过 7 分钟仍无输出且处于休眠,遂中断,均无全量结论;[记录](verification-artifacts/pnpm-store-recipe-full-swift-test.txt)保留了命令和结果。两次之间以 `$TMPDIR/poolproblem-pnpm-recipe-ci` 为 scratch 路径执行 `swift test --skip cliScan -q`,报告当时的 210 项 Core 与 1 项 CLI 测试通过;`cliScan` 未包含在该次验证中。新增功能以本页的 10 项针对性端到端结果为准。 diff --git a/docs/verification-artifacts/pnpm-store-child-build-for-testing.txt b/docs/verification-artifacts/pnpm-store-child-build-for-testing.txt new file mode 100644 index 0000000..bfd6461 --- /dev/null +++ b/docs/verification-artifacts/pnpm-store-child-build-for-testing.txt @@ -0,0 +1,6 @@ +command=CLANG_MODULE_CACHE_PATH="$TMPDIR/poolproblem-pnpm-child-failfirst/cache" SWIFT_MODULE_CACHE_PATH="$TMPDIR/poolproblem-pnpm-child-failfirst/cache" xcodebuild build-for-testing -project PoolProblem/PoolProblem.xcodeproj -scheme PoolProblem -destination 'platform=macOS' -derivedDataPath "$TMPDIR/poolproblem-pnpm-child-failfirst/DerivedData" -clonedSourcePackagesDirPath /Users/xingyuwang/develop/poolproblem/.build -disableAutomaticPackageResolution CODE_SIGNING_ALLOWED=NO +environment=macOS arm64, Xcode Swift 6, local SwiftPM checkout; sandbox escalation for Xcode cache access; separate task-specific TMPDIR cache and DerivedData +input=current AppService durable history and AppServiceTests source, including A→B migration and overflow guard +result=2026-09-27 20:11:44 +08:00, exit 0; app and test targets compiled, tests not executed by build-for-testing + +** TEST BUILD SUCCEEDED ** diff --git a/docs/verification-artifacts/pnpm-store-child-xcode-test.txt b/docs/verification-artifacts/pnpm-store-child-xcode-test.txt new file mode 100644 index 0000000..091af6e --- /dev/null +++ b/docs/verification-artifacts/pnpm-store-child-xcode-test.txt @@ -0,0 +1,20 @@ +command=xcodebuild test -project PoolProblem/PoolProblem.xcodeproj -scheme PoolProblem -destination platform=macOS -only-testing:PoolProblemTests/pnpmStoreCacheChildIsNeitherListedForCleaningNorMoved CODE_SIGNING_ALLOWED=NO +environment=macOS arm64, Xcode, local package checkout, escalated filesystem for Xcode services +input=fixture home with 11MB ~/Library/Caches/pnpm and fake pnpm +result=exit 65 + +PruneExplicitPrecompiledModules /var/folders/1z/j4_f6yy56bs74cwpnfm_wnhc0000gn/T/poolproblem-pnpm-child-failfirst/DerivedData/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules + +2026-09-27 19:48:44.574 xcodebuild[78486:16766021] [MT] IDETestOperationsObserverDebug: 24.006 elapsed -- Testing started completed. +2026-09-27 19:48:44.574 xcodebuild[78486:16766021] [MT] IDETestOperationsObserverDebug: 0.000 sec, +0.000 sec -- start +2026-09-27 19:48:44.574 xcodebuild[78486:16766021] [MT] IDETestOperationsObserverDebug: 24.006 sec, +24.006 sec -- end + +Test session results, code coverage, and logs: + /var/folders/1z/j4_f6yy56bs74cwpnfm_wnhc0000gn/T/poolproblem-pnpm-child-failfirst/DerivedData/Logs/Test/Test-PoolProblem-2026.09.27_19-47-52-+0800.xcresult + +Testing failed: + PoolProblem (78872) encountered an error (Early unexpected exit, operation never finished bootstrapping - no restart will be attempted. (Underlying Error: The test runner exited with code 0 before establishing connection.)) + +** TEST FAILED ** + +Testing started diff --git a/docs/verification-artifacts/pnpm-store-ci-fix.log b/docs/verification-artifacts/pnpm-store-ci-fix.log new file mode 100644 index 0000000..1b9633d --- /dev/null +++ b/docs/verification-artifacts/pnpm-store-ci-fix.log @@ -0,0 +1,36 @@ +warning: /Users/xingyuwang/Library/org.swift.swiftpm/configuration is not accessible or not writable, disabling user-level cache features. +warning: /Users/xingyuwang/Library/org.swift.swiftpm/security is not accessible or not writable, disabling user-level cache features. +warning: /Users/xingyuwang/Library/Caches/org.swift.swiftpm is not accessible or not writable, disabling user-level cache features. +warning: 'poolproblem': failed storing manifest for 'poolproblem' in cache: attempt to write a readonly database +warning: 'swift-argument-parser': failed storing manifest for 'swift-argument-parser' in cache: attempt to write a readonly database +Building for debugging... +[Computing dependencies] +[1 / 5] DiskReservoirCoreTests-product +[2 / 5] DiskReservoirCoreTests-product +[4 / 7] DiskReservoirCoreTests-product +[9 / 11] DiskReservoirCoreTests-product +Build complete! (3.18秒) +◇ Test run started. +↳ Testing Library Version: 2084 +↳ Target Platform: arm64e-apple-macos14.0 +◇ Test historicalStoreAStillProtectsScanAndAutoCleanAfterPnpmMovesToB() started. +◇ Test exactCustomRootDoesNotDoubleCountOwnerStore() started. +◇ Test pnpmProbeRejectsAmbiguousStoresAndDisablesCorepackNetwork() started. +◇ Test customAndOldSnapshotRootsCannotDeleteDynamicPnpmStore() started. +◇ Test pnpmProbeSkipsBrokenShimAndScansExactStoreWithoutDeletion() started. +◇ Test approvedDefaultCacheFailsClosedWhenPnpmProbeFails() started. +◇ Test knownStoreHistoryOverflowFailsClosedForEveryRawDeletion() started. +◇ Test unavailablePnpmKeepsOtherCachesUsableAndProtectsKnownLocations() started. +◇ Test aggregateScanExcludesNestedPnpmStoreWithoutHidingSiblingCache() started. +◇ Test pnpmProbeRejectsMalformedPathsAndNeverRunsPrune() started. +✔ Test unavailablePnpmKeepsOtherCachesUsableAndProtectsKnownLocations() passed after 0.148 seconds. +✔ Test approvedDefaultCacheFailsClosedWhenPnpmProbeFails() passed after 0.148 seconds. +✔ Test knownStoreHistoryOverflowFailsClosedForEveryRawDeletion() passed after 0.148 seconds. +✔ Test exactCustomRootDoesNotDoubleCountOwnerStore() passed after 0.151 seconds. +✔ Test aggregateScanExcludesNestedPnpmStoreWithoutHidingSiblingCache() passed after 0.151 seconds. +✔ Test historicalStoreAStillProtectsScanAndAutoCleanAfterPnpmMovesToB() passed after 0.159 seconds. +✔ Test pnpmProbeRejectsMalformedPathsAndNeverRunsPrune() passed after 0.832 seconds. +✔ Test customAndOldSnapshotRootsCannotDeleteDynamicPnpmStore() passed after 1.626 seconds. +✔ Test pnpmProbeRejectsAmbiguousStoresAndDisablesCorepackNetwork() passed after 2.014 seconds. +✔ Test pnpmProbeSkipsBrokenShimAndScansExactStoreWithoutDeletion() passed after 2.539 seconds. +✔ Test run with 10 tests in 0 suites passed after 2.539 seconds. diff --git a/docs/verification-artifacts/pnpm-store-ci-fix.txt b/docs/verification-artifacts/pnpm-store-ci-fix.txt new file mode 100644 index 0000000..fcf3e9b --- /dev/null +++ b/docs/verification-artifacts/pnpm-store-ci-fix.txt @@ -0,0 +1,5 @@ +command=CLANG_MODULE_CACHE_PATH="$TMPDIR/poolproblem-pnpm-ci-fix/cache" SWIFT_MODULE_CACHE_PATH="$TMPDIR/poolproblem-pnpm-ci-fix/cache" swift test --disable-sandbox --filter PnpmStoreRecipeEndToEndTests > docs/verification-artifacts/pnpm-store-ci-fix.log 2>&1 +environment=macOS arm64, Xcode Swift 6, existing SwiftPM dependency checkout; temporary module cache under TMPDIR +input=generated pnpm/store fixtures after correcting the A-to-B test's waterline to be above the actual scanned available capacity; no real store prune +result=exit 0, all 10 pnpm end-to-end tests passed, including historicalStoreAStillProtectsScanAndAutoCleanAfterPnpmMovesToB; full output is pnpm-store-ci-fix.log +CI failure that exposed this fixture issue=https://github.com/wxy/poolproblem/actions/runs/36318571039/job/108617857805 (one expectation failed because the original fixed 90 MB waterline was already below the CI runner's actual available disk space) diff --git a/docs/verification-artifacts/pnpm-store-history-failfirst.txt b/docs/verification-artifacts/pnpm-store-history-failfirst.txt new file mode 100644 index 0000000..7749306 --- /dev/null +++ b/docs/verification-artifacts/pnpm-store-history-failfirst.txt @@ -0,0 +1,17 @@ +command=swift test --disable-sandbox --filter historicalStoreAStillProtectsScanAndAutoCleanAfterPnpmMovesToB +environment=macOS arm64, Xcode Swift 6, cache under TMPDIR +input=legacy A snapshot; current B; .npm parent plus uv sibling; fake current probe +result=exit 1 (expected fail-first missing store/scanner API) +11:/Users/xingyuwang/develop/poolproblem/Tests/DiskReservoirCoreTests/PnpmStoreRecipeEndToEndTests.swift:194:22: error: cannot find 'OwnerCommandKnownTargetsStore' in scope +15: | `- error: cannot find 'OwnerCommandKnownTargetsStore' in scope +19:/Users/xingyuwang/develop/poolproblem/Tests/DiskReservoirCoreTests/PnpmStoreRecipeEndToEndTests.swift:197:24: error: cannot find 'OwnerCommandKnownTargetsStore' in scope +23: | `- error: cannot find 'OwnerCommandKnownTargetsStore' in scope +27:/Users/xingyuwang/develop/poolproblem/Tests/DiskReservoirCoreTests/PnpmStoreRecipeEndToEndTests.swift:206:66: error: extra argument 'protectedOwnerPaths' in call +31: | `- error: extra argument 'protectedOwnerPaths' in call +54:error: Build failed +command=swift test --disable-sandbox --filter knownStoreHistoryOverflowFailsClosedForEveryRawDeletion +environment=macOS arm64, Xcode Swift 6 +input=1025 historical targets, overflow persisted, unrelated raw candidate +result=exit 1 (expected failure before global overflow guard) +✘ Test knownStoreHistoryOverflowFailsClosedForEveryRawDeletion() recorded an issue at PnpmStoreRecipeEndToEndTests.swift:252:5: Expectation failed: !OwnerManagedPathGuard.mayDelete(path: f.home.appendingPathComponent("unrelated-build-cache").path, recipeID: "unrelated-recipe", probe: unavailable, homeDirectory: f.home.path, knownStorePaths: reloaded.paths, historyOverflowed: reloaded.overflowed) +✘ Test run with 1 test in 0 suites failed after 0.261 seconds with 1 issue. diff --git a/docs/verification-artifacts/pnpm-store-real-readonly-probe.log b/docs/verification-artifacts/pnpm-store-real-readonly-probe.log new file mode 100644 index 0000000..d3d3463 --- /dev/null +++ b/docs/verification-artifacts/pnpm-store-real-readonly-probe.log @@ -0,0 +1,6 @@ +probe=success +executable=~/.nvm/versions/node/v26.7.0/bin/pnpm +store=~/Library/pnpm/store/v11 +allocated_bytes=426921984 +visible_above_10_mb=true +prune_executed=false diff --git a/docs/verification-artifacts/pnpm-store-real-readonly-probe.txt b/docs/verification-artifacts/pnpm-store-real-readonly-probe.txt new file mode 100644 index 0000000..d733336 --- /dev/null +++ b/docs/verification-artifacts/pnpm-store-real-readonly-probe.txt @@ -0,0 +1,5 @@ +Command: sh docs/verification/pnpm-owner-recipe/run-readonly-real-probe.sh > docs/verification-artifacts/pnpm-store-real-readonly-probe.log 2>&1 +Environment: macOS arm64, Xcode Swift 6, user HOME, minimal GUI PATH=/usr/bin:/bin:/usr/sbin:/sbin; temporary compiler files are created under TMPDIR and removed on exit. +Input: installed pnpm candidates and the live user store; the harness invokes only pnpm store path and the read-only Scanner. +Result on 2026-09-27: probe succeeded with the NVM pnpm executable despite the broken earlier Corepack candidate. The measured store had 426921984 allocated bytes (about 407 MiB), above the 10 MB visibility threshold. prune_executed=false. The exact output is in pnpm-store-real-readonly-probe.log. +Repeatability: rerun the command above on the same machine; occupancy may change as packages are installed or removed. Never substitute pnpm store prune into this harness. diff --git a/docs/verification-artifacts/pnpm-store-recipe-e2e.log b/docs/verification-artifacts/pnpm-store-recipe-e2e.log new file mode 100644 index 0000000..e26d132 --- /dev/null +++ b/docs/verification-artifacts/pnpm-store-recipe-e2e.log @@ -0,0 +1,39 @@ +warning: /Users/xingyuwang/Library/org.swift.swiftpm/configuration is not accessible or not writable, disabling user-level cache features. +warning: /Users/xingyuwang/Library/org.swift.swiftpm/security is not accessible or not writable, disabling user-level cache features. +warning: /Users/xingyuwang/Library/Caches/org.swift.swiftpm is not accessible or not writable, disabling user-level cache features. +warning: 'poolproblem': failed storing manifest for 'poolproblem' in cache: attempt to write a readonly database +warning: 'swift-argument-parser': failed storing manifest for 'swift-argument-parser' in cache: attempt to write a readonly database +Building for debugging... +[Computing dependencies] +[1 / 5] +[2 / 6] DiskReservoirCore +[3 / 7] DiskReservoirCore +[7 / 10] DiskReservoirCore +[10 / 11] DiskReservoirCoreTests-product +[13 / 16] poolproblem-product +[16 / 17] PoolProblemCLITests-product +Build complete! (2.44秒) +◇ Test run started. +↳ Testing Library Version: 2084 +↳ Target Platform: arm64e-apple-macos14.0 +◇ Test pnpmProbeRejectsAmbiguousStoresAndDisablesCorepackNetwork() started. +◇ Test aggregateScanExcludesNestedPnpmStoreWithoutHidingSiblingCache() started. +◇ Test approvedDefaultCacheFailsClosedWhenPnpmProbeFails() started. +◇ Test exactCustomRootDoesNotDoubleCountOwnerStore() started. +◇ Test pnpmProbeSkipsBrokenShimAndScansExactStoreWithoutDeletion() started. +◇ Test knownStoreHistoryOverflowFailsClosedForEveryRawDeletion() started. +◇ Test customAndOldSnapshotRootsCannotDeleteDynamicPnpmStore() started. +◇ Test unavailablePnpmKeepsOtherCachesUsableAndProtectsKnownLocations() started. +◇ Test historicalStoreAStillProtectsScanAndAutoCleanAfterPnpmMovesToB() started. +◇ Test pnpmProbeRejectsMalformedPathsAndNeverRunsPrune() started. +✔ Test unavailablePnpmKeepsOtherCachesUsableAndProtectsKnownLocations() passed after 0.150 seconds. +✔ Test approvedDefaultCacheFailsClosedWhenPnpmProbeFails() passed after 0.150 seconds. +✔ Test knownStoreHistoryOverflowFailsClosedForEveryRawDeletion() passed after 0.150 seconds. +✔ Test exactCustomRootDoesNotDoubleCountOwnerStore() passed after 0.154 seconds. +✔ Test aggregateScanExcludesNestedPnpmStoreWithoutHidingSiblingCache() passed after 0.154 seconds. +✔ Test historicalStoreAStillProtectsScanAndAutoCleanAfterPnpmMovesToB() passed after 0.162 seconds. +✔ Test pnpmProbeRejectsMalformedPathsAndNeverRunsPrune() passed after 0.832 seconds. +✔ Test customAndOldSnapshotRootsCannotDeleteDynamicPnpmStore() passed after 1.434 seconds. +✔ Test pnpmProbeRejectsAmbiguousStoresAndDisablesCorepackNetwork() passed after 1.833 seconds. +✔ Test pnpmProbeSkipsBrokenShimAndScansExactStoreWithoutDeletion() passed after 2.326 seconds. +✔ Test run with 10 tests in 0 suites passed after 2.327 seconds. diff --git a/docs/verification-artifacts/pnpm-store-recipe-e2e.txt b/docs/verification-artifacts/pnpm-store-recipe-e2e.txt new file mode 100644 index 0000000..67d3988 --- /dev/null +++ b/docs/verification-artifacts/pnpm-store-recipe-e2e.txt @@ -0,0 +1,4 @@ +command=CLANG_MODULE_CACHE_PATH=$TMPDIR/poolproblem-pnpm-history-failfirst/cache SWIFT_MODULE_CACHE_PATH=$TMPDIR/poolproblem-pnpm-history-failfirst/cache swift test --disable-sandbox --filter PnpmStoreRecipeEndToEndTests +environment=macOS arm64, Xcode Swift 6, current workspace +input=generated fake pnpm/store fixtures; durable A-to-B migration, snapshot prune and global bounded-overflow guard; prune command never invoked +result=exit 0 diff --git a/docs/verification-artifacts/pnpm-store-recipe-failfirst.txt b/docs/verification-artifacts/pnpm-store-recipe-failfirst.txt new file mode 100644 index 0000000..fb57c89 --- /dev/null +++ b/docs/verification-artifacts/pnpm-store-recipe-failfirst.txt @@ -0,0 +1,32 @@ +command=swift test --disable-sandbox --filter customAndOldSnapshotRootsCannotDeleteDynamicPnpmStore +environment=macOS arm64, Xcode Swift 6, module cache under TMPDIR +input=fake NVM pnpm; custom parent, symlink alias, safe sibling; recording deleter +result=exit 1 (expected failure before fix) +✘ Test customAndOldSnapshotRootsCannotDeleteDynamicPnpmStore() recorded an issue at PnpmStoreRecipeEndToEndTests.swift:71:5: Expectation failed: !recorder.paths.contains(parent.path) +✘ Test customAndOldSnapshotRootsCannotDeleteDynamicPnpmStore() recorded an issue at PnpmStoreRecipeEndToEndTests.swift:72:5: Expectation failed: !recorder.paths.contains(alias.path) +✘ Test run with 1 test in 0 suites failed after 0.016 seconds with 2 issues. +command=swift test --disable-sandbox --filter aggregateScanExcludesNestedPnpmStoreWithoutHidingSiblingCache +environment=macOS arm64, Xcode Swift 6 +input=fake .npm containing 11MB pnpm store and 2MB npm sibling +result=exit 1 (expected failure before fix) +✘ Test aggregateScanExcludesNestedPnpmStoreWithoutHidingSiblingCache() recorded an issue at PnpmStoreRecipeEndToEndTests.swift:95:5: Expectation failed: npm.allocatedBytes < pnpm.allocatedBytes +✘ Test run with 1 test in 0 suites failed after 0.027 seconds with 1 issue. +command=swift test --disable-sandbox --filter aggregateScanExcludesNestedPnpmStoreWithoutHidingSiblingCache +environment=macOS arm64, Xcode Swift 6 +input=.npm parent contains 11MB pnpm store plus 2MB sibling +result=exit 1 (expected failure on reclaimable before fix) +✘ Test aggregateScanExcludesNestedPnpmStoreWithoutHidingSiblingCache() recorded an issue at PnpmStoreRecipeEndToEndTests.swift:103:5: Expectation failed: npm.reclaimableBytes == 0 +✘ Test run with 1 test in 0 suites failed after 0.030 seconds with 1 issue. +command=swift test --disable-sandbox --filter exactCustomRootDoesNotDoubleCountOwnerStore +environment=macOS arm64, Xcode Swift 6 +input=custom recipe and owner recipe resolve exact same fake store +result=exit 1 (expected failure before precedence fix) +✘ Test exactCustomRootDoesNotDoubleCountOwnerStore() recorded an issue at PnpmStoreRecipeEndToEndTests.swift:161:5: Expectation failed: scan.items.count == 1 +✘ Test exactCustomRootDoesNotDoubleCountOwnerStore() recorded an issue at PnpmStoreRecipeEndToEndTests.swift:162:5: Expectation failed: scan.items.first?.recipeID == OwnerCommandRecipe.pnpmStorePrune.id +✘ Test run with 1 test in 0 suites failed after 0.037 seconds with 2 issues. +command=swift test --disable-sandbox --filter approvedDefaultCacheFailsClosedWhenPnpmProbeFails +environment=macOS arm64, Xcode Swift 6 +input=approved .npm root containing fake pnpm store, probe exit 11, recording deleter +result=exit 1 (expected failure before fix) +✘ Test approvedDefaultCacheFailsClosedWhenPnpmProbeFails() recorded an issue at PnpmStoreRecipeEndToEndTests.swift:132:5: Expectation failed: recorder.paths.isEmpty +✘ Test run with 1 test in 0 suites failed after 0.013 seconds with 1 issue. diff --git a/docs/verification-artifacts/pnpm-store-recipe-full-swift-test.log b/docs/verification-artifacts/pnpm-store-recipe-full-swift-test.log new file mode 100644 index 0000000..e824379 --- /dev/null +++ b/docs/verification-artifacts/pnpm-store-recipe-full-swift-test.log @@ -0,0 +1,220 @@ +warning: /Users/xingyuwang/Library/org.swift.swiftpm/configuration is not accessible or not writable, disabling user-level cache features. +warning: /Users/xingyuwang/Library/org.swift.swiftpm/security is not accessible or not writable, disabling user-level cache features. +warning: /Users/xingyuwang/Library/Caches/org.swift.swiftpm is not accessible or not writable, disabling user-level cache features. +warning: 'poolproblem': failed storing manifest for 'poolproblem' in cache: attempt to write a readonly database +warning: 'swift-argument-parser': failed storing manifest for 'swift-argument-parser' in cache: attempt to write a readonly database +Building for debugging... +[Computing dependencies] +[1 / 4] DiskReservoirCore +Build complete! (0.50秒) +Test Suite 'All tests' started at 2026-09-27 19:40:14.292. +Test Suite 'All tests' passed at 2026-09-27 19:40:14.294. + Executed 0 tests, with 0 failures (0 unexpected) in 0.000 (0.003) seconds +Test Suite 'All tests' started at 2026-09-27 19:40:14.382. +Test Suite 'All tests' passed at 2026-09-27 19:40:14.384. + Executed 0 tests, with 0 failures (0 unexpected) in 0.000 (0.001) seconds +◇ Test run started. +↳ Testing Library Version: 2084 +↳ Target Platform: arm64e-apple-macos14.0 +◇ Test cliScanSavesSnapshot() started. +◇ Test cliStatusOutputsJSONWithoutSnapshots() started. +◇ Test cliScanOutputsJSON() started. +✔ Test cliStatusOutputsJSONWithoutSnapshots() passed after 0.490 seconds. +✔ Test cliScanOutputsJSON() passed after 0.521 seconds. +✔ Test cliScanSavesSnapshot() passed after 0.524 seconds. +✔ Test run with 3 tests in 0 suites passed after 0.524 seconds. +◇ Test run started. +↳ Testing Library Version: 2084 +↳ Target Platform: arm64e-apple-macos14.0 +◇ Test discoveryFindsProjectsWithRegenerableContent() started. +◇ Test progressiveCleanerDoesNothingBelowThreshold() started. +◇ Test coverageMatchesSelfAndDescendants() started. +◇ Test forceRespectsDispositionNone() started. +◇ Test recipeDefaultAgeFallsBackToDeclaredValue() started. +◇ Test surfaceScannerSkipsSmallChildren() started. +◇ Test coverageDeduplicatesPatterns() started. +◇ Test normalizeKeepsPackageManagerCandidatesAsSingles() started. +◇ Test newRecipesAreRegistered() started. +◇ Test suggesterDoesNotTreatArbitraryCacheNameAsSafeRecipe() started. +◇ Test mergerKeepsDifferentPathsSeparate() started. +◇ Test gaugeStepStaysInBoundsForHugeSpan() started. +◇ Test derivedDataSharedAndProjectDirectoriesCanBeChosenAfterTheyBecomeIdle() started. +◇ Test everyRecipeHasDistinctID() started. +◇ Test noneDispositionDoesNothing() started. +◇ Test groupRuleDisablesWholeGroup() started. +◇ Test parentAndSelfProbeUsesParentNewest() started. +◇ Test projectRecipesExpandDevRoots() started. +◇ Test progressiveCleanerSkipsCandidatesBelowFloor() started. +◇ Test detectsProjectByRegenerableDir() started. +◇ Test trashBatchDeleterGroupsItemsInOneFolder() started. +◇ Test mergerGroupsUnknownSpaceByItemID() started. +◇ Test surfaceScannerMeasuresExplicitPaths() started. +◇ Test exactCustomRootDoesNotDoubleCountOwnerStore() started. +◇ Test watchOnlyAssetsRemainVisibleAndCannotEnterCleanup() started. +◇ Test growthRatesUseActualTimeIntervals() started. +◇ Test projectItemSkipsWhenParentRecentlyActive() started. +◇ Test snapshotRoundTripsSource() started. +◇ Test staleRuntimeItemSuggestsManualXcodeDeletion() started. +◇ Test trashRecipeCoversLocalAndICloudTrash() started. +◇ Test discoveryDoesNotFollowProjectSymlinks() started. +◇ Test trashOnlyItemGoesToTrashWhenForced() started. +◇ Test dirtyTrackerMatchesParentEvents() started. +◇ Test normalizeDropsChildCandidateWhenParentSuggested() started. +◇ Test scanItemDecodesLegacyJSONWithoutCleanability() started. +◇ Test configDecodesLegacyJSONWithoutProtectedChildren() started. +◇ Test neverBootedRuntimeHasNilLastModified() started. +◇ Test detectsProjectByTopLevelBuildOutput() started. +◇ Test surfaceScannerMeasuresFirstLevelChildren() started. +◇ Test legacyConfigWithoutMinimumCleanSizeDefaultsTo500() started. +◇ Test thresholdsScaleWithDefaultWaterline() started. +◇ Test mergerCollapsesIdenticalDeltaDuplicates() started. +◇ Test hardLinksShareInodeAndDedup() started. +◇ Test jsonStoreRoundTrip() started. +◇ Test idleWindowDiffersByArtifactType() started. +◇ Test runtimeDisplayNameReadsSimruntimeBundle() started. +◇ Test childDirectoryFlowKeepsOnlySignificantCurrentDirectoriesAndHonestGrowth() started. +◇ Test dirtyTrackerIgnoresUnrelatedEventsAndClears() started. +◇ Test configDefaultProtectsBuildCriticalCacheChildren() started. +◇ Test detectsProjectByManifestOrGit() started. +◇ Test recipesCarryCleanabilityAndProtection() started. +◇ Test progressiveCleanerSkipsChildrenModifiedRecently() started. +◇ Test applyKeepsReclaimableWhenNoRecords() started. +◇ Test activityCandidatesMapToProjectRecipeFamily() started. +◇ Test ledgerCapturesNewItems() started. +◇ Test previewCacheRecipeResolvesToUserData() started. +◇ Test rescanReturnsEmptyForMissingPath() started. +◇ Test persistedGrowthIsHistoricalAndReconciledWithLivePaths() started. +◇ Test runtimeDisplayNameParsesDyldCacheName() started. +◇ Test projectRecipesDeduplicatePaths() started. +◇ Test restoredSnapshotUsesCurrentRecipeForCleanability() started. +◇ Test schemaVersionIsOne() started. +◇ Test scanItemDecodesLegacySnapshotWithoutPaths() started. +◇ Test discoveryFindsProjectsInConventionRoot() started. +◇ Test scannerReportsSizeAndFileCount() started. +◇ Test aPersistedProjectSuggestionRequiresARecognizedProjectAtItsCurrentPath() started. +◇ Test suggestionStorePrunesStaleCandidates() started. +◇ Test regenerableCacheNeedsNoConfirmation() started. +◇ Test ledgerStoreAppendsPrunesAndKeepsSurface() started. +◇ Test attributionComputesDeltas() started. +◇ Test runtimeRecipeSkipsNonRuntimeChildren() started. +◇ Test customAndOldSnapshotRootsCannotDeleteDynamicPnpmStore() started. +◇ Test suggestionStorePreservesUserDecisions() started. +◇ Test pgrepReturnsFalseForImpossibleName() started. +◇ Test growthRatesEstimatePerItemSlope() started. +◇ Test trashOnlyItemNotifiesInAutoMode() started. +◇ Test activityTrackerFiltersByWindow() started. +◇ Test posixWalkerSkipsSubtrees() started. +◇ Test forceSkipsKeptItem() started. +◇ Test cleanerDoesNothingAboveWaterline() started. +◇ Test scannerAggregatesMultiplePathsIntoOneItem() started. +◇ Test forceCleansSafeWhileRunningToTrash() started. +◇ Test gaugeStepAlwaysYieldsReadableE() started. +◇ Test permanentDeleteRemovesDirectory() started. +◇ Test scannerMeasuresDispositionNoneWithoutRecords() started. +◇ Test cleanLogEntryDecodesWithoutItemNamesForBackwardCompatibility() started. +◇ Test oldEnoughItemIsDeleted() started. +◇ Test dataDirRespectsEnvironmentOverride() started. +◇ Test excludedPathPolicySkipsTrashLibraryAndHiddenDirs() started. +◇ Test statvfsFallbackReturnsSaneValues() started. +◇ Test trashBatchDeleterEmptiesOnlyOwnBatches() started. +◇ Test suggesterClustersProjectGrowthAndMapsToExistingRecipe() started. +◇ Test scannerAggregatesOnlyOldEnoughPaths() started. +◇ Test pgrepHandlesMissingBinaryGracefully() started. +◇ Test suggesterIgnoresNonProjectGrowth() started. +◇ Test runtimeRecipeUsesLastBootedDateAsLastModified() started. +◇ Test posixWalkerFirstLevelCount() started. +◇ Test cleanLogEntryRoundTrip() started. +◇ Test rescanMeasuresSingleRecipePath() started. +◇ Test trashBatchDeleterEmptiesSingleNamedBatch() started. +◇ Test configDefaultWaterlineIs30GB() started. +◇ Test cleanerPrefersPermanentDeletesForRealFreedSpace() started. +◇ Test cleanerIgnoresAgeInEmergencyButKeepsDisposition() started. +◇ Test applyPreservesDeletionSafetyMetadata() started. +◇ Test surfaceScannerDefaultRootsAreHomeScoped() started. +◇ Test mergerUsesLatestObservationTime() started. +◇ Test recipeMinimumIsPresentationOnlyAndTrashRemainsVisible() started. +◇ Test groupGuardSkipsWhileXcodeRunning() started. +◇ Test mergerKeepsOnlyLatestEventForSamePath() started. +◇ Test activityTrackerIgnoresTrashAndSystemPaths() started. +◇ Test pnpmProbeRejectsAmbiguousStoresAndDisablesCorepackNetwork() started. +◇ Test configDefaultsMinimumCleanSizeAndRoundTrips() started. +◇ Test configDecodesLegacyWithoutDevRoots() started. +◇ Test snapshotDecodesLegacyJSONWithoutSourceAsFull() started. +◇ Test cleanerSkipsChildOnlyItems() started. +◇ Test cleanerDeletesEveryPathOfAggregateItem() started. +◇ Test configRoundTripsDevRoots() started. +◇ Test patternizeLeavesOtherPathsIntact() started. +◇ Test cleanerStopsAtWaterline() started. +◇ Test pgrepDetectsCurrentShellProcess() started. +◇ Test recentlyModifiedIsSkipped() started. +◇ Test cleanabilityGuardDowngradesPermanentDeleteToTrash() started. +◇ Test temporaryBuildGuardIgnoresUnrelatedGitQueries() started. +◇ Test suggesterDetectsProjectRootFromChildGrowth() started. +◇ Test configDefaultsAutoEmptyBatchesOffAndRoundTrips() started. +◇ Test discoveryFindsNestedProjectsUnderDevContainer() started. +◇ Test surfaceGrowthDiscoveryKeepsSmallBaselineDirectories() started. +◇ Test cleanerSkipsRequiresQuitWhenRunning() started. +◇ Test cleanerSkipsManualItemsEvenWhenForced() started. +◇ Test thresholdsHaveFloorsForSmallWaterline() started. +◇ Test whitelistProtectsAggregateParentsAndDescendants() started. +◇ Test displayOnlyIsUserData() started. +◇ Test userAddedPackageManagerRootsRemainManualOnly() started. +◇ Test scanItemCodableRoundTrip() started. +◇ Test pnpmProbeRejectsMalformedPathsAndNeverRunsPrune() started. +◇ Test progressiveCleanerTrimsOldestChildrenWhenThresholdExceeded() started. +◇ Test patternizeReplacesHomeAndHashes() started. +◇ Test insufficientSamplesReturnsNil() started. +◇ Test diskPressurePolicySeparatesCheapMonitoringAnalysisAndEmergency() started. +◇ Test userConfirmAlwaysRequiresConfirmation() started. +◇ Test suggesterRespectsMinimumAndTopK() started. +◇ Test diskPressureAnalysisIsEdgeTriggeredRateLimitedAndDropSensitive() started. +◇ Test snapshotStoreAppendsAndPrunes() started. +◇ Test aCacheNameAloneCannotCreateAnActionableRecipeSuggestion() started. +◇ Test progressiveCleanerNeverTrimsProtectedChildren() started. +◇ Test forceCleansRequiresQuitWhenProcessNotRunning() started. +◇ Test thresholdsScaleUpWithLargerWaterline() started. +◇ Test childCleanupValidationRequiresExactAuthorizedIdleUnprotectedDirectory() started. +◇ Test xctestdevicesRecipeResolvesToLibraryDeveloper() started. +◇ Test configRoundTripsProtectedChildren() started. +◇ Test devicesupportResolvesOnlyOldVersions() started. +◇ Test predictionUsesActualTimeIntervals() started. +◇ Test ledgerDiffsSurfaceDirectories() started. +◇ Test posixWalkerMatchesFileManager() started. +◇ Test volumeReaderReturnsAvailableCapacity() started. +◇ Test whitelistSkips() started. +◇ Test growthAlertTriggersOnAbsoluteGrowth() started. +◇ Test automaticCleanupRequiresExplicitPermanentDeletionAuthorization() started. +◇ Test packageManagerRecipesMergeDefaultsAndExtras() started. +◇ Test forceNotifiesRequiresQuitWhenProcessRunning() started. +◇ Test temporaryBuildCleanupRequiresWholeTreeToBeIdle() started. +◇ Test activityTrackerKeepsLatestTimestamp() started. +◇ Test surfaceGrowthDiscoveryEndToEnd() started. +◇ Test ledgerSkipsSmallDeltas() started. +◇ Test requiresQuitWithRunningProcessNotifies() started. +◇ Test deviceSupportOldVersionsAreManual() started. +◇ Test automaticCleanupPrioritizesMeasuredFastGrowth() started. +◇ Test approvedDefaultCacheFailsClosedWhenPnpmProbeFails() started. +◇ Test missingPathThrows() started. +◇ Test scannerSkipsMissingPaths() started. +◇ Test cleanerContinuesAfterSingleItemFailure() started. +◇ Test pnpmProbeSkipsBrokenShimAndScansExactStoreWithoutDeletion() started. +◇ Test mergedProtectedChildNamesCombinesRecipeAndConfig() started. +◇ Test temporaryBuildDiscoveryUsesNarrowAllowlistAndRejectsSymlinks() started. +◇ Test projectRecipesTreatScatteredDevRootAsProject() started. +◇ Test statvfsFallbackReturnsNilForMissingPath() started. +◇ Test dirtyTrackerMarksNestedEvents() started. +◇ Test discoveryCandidatesMapToProjectRecipeFamily() started. +◇ Test gaugeStepStaysInBoundsForSmallSpan() started. +◇ Test applyUpdatesReclaimableBytes() started. +◇ Test progressiveCleanerPreviewDoesNotDelete() started. +◇ Test normalizeMergesSamePathFromMultipleSources() started. +◇ Test manualCategoriesAreFlagged() started. +◇ Test shortRealScanIntervalDoesNotBecomeAWeeklyGrowthClaim() started. +◇ Test cleanabilityGuardBlocksDisplayOnly() started. +◇ Test sharedInodeCountsOnce() started. +◇ Test cleanerProcessesLargerItemsFirst() started. +◇ Test cleanerReportsWillDeleteAndCleaned() started. +◇ Test coverageTreatsParentAggregateAsCoveredWhenDirectChildManaged() started. +◇ Test normalizeGroupsSiblingsUnderParentAndKeepsHomeLevelSeparate() started. +◇ Test recipesAreGroupedByEcosystem() started. +◇ Test broadDeveloperRootsDoNotExposeWholeDirectoryDeletion() started \ No newline at end of file diff --git a/docs/verification-artifacts/pnpm-store-recipe-full-swift-test.txt b/docs/verification-artifacts/pnpm-store-recipe-full-swift-test.txt new file mode 100644 index 0000000..10e9b3f --- /dev/null +++ b/docs/verification-artifacts/pnpm-store-recipe-full-swift-test.txt @@ -0,0 +1,9 @@ +command=swift test --disable-sandbox +environment=macOS arm64, Xcode Swift 6 +input=full Swift Package suite +result=interrupted after 3 minutes of concurrent tests; no full-suite verdict + +command=CLANG_MODULE_CACHE_PATH=$TMPDIR/poolproblem-pnpm-final-cache SWIFT_MODULE_CACHE_PATH=$TMPDIR/poolproblem-pnpm-final-cache swift test --disable-sandbox --skip cliScan --scratch-path "$TMPDIR/poolproblem-pnpm-recipe-ci" -q > "$TMPDIR/poolproblem-pnpm-final-suite.log" 2>&1 +environment=macOS arm64, Xcode Swift 6, existing cached SwiftPM dependency checkout under TMPDIR; fresh scratch could not resolve github.com in the restricted network +input=complete Swift Package suite after the A-to-B history fix; no real store prune +result=testing helper stayed asleep at 0% CPU for more than 7 minutes and wrote no stdout/stderr to the log; interrupted with Ctrl-C (exit 130). This attempt gives no full-suite verdict. The focused pnpm E2E 10/10 and Xcode build-for-testing are recorded separately. diff --git a/docs/verification/pnpm-owner-recipe/JOURNAL-REPORT.md b/docs/verification/pnpm-owner-recipe/JOURNAL-REPORT.md new file mode 100644 index 0000000..7a99aaa --- /dev/null +++ b/docs/verification/pnpm-owner-recipe/JOURNAL-REPORT.md @@ -0,0 +1,13 @@ +# pnpm owner-command journal repair verification + +Run from the repository root on macOS with Swift and Clang installed: + +```sh +TMPDIR="${TMPDIR}" sh docs/verification/pnpm-owner-recipe/run-journal.sh +``` + +The script creates and deletes a task-specific temporary directory, compiles the actual `DiskReservoirCore` sources and `JournalE2E.swift`, and runs the controlled `fixture.sh` with an isolated `HOME` and store. It never invokes installed pnpm or cleans the real cache. The repeatable output artifact is [`journal-last-run.log`](journal-last-run.log). + +Inputs: an isolated store containing `unreferenced-package`; a JSON store that throws on its first save, one that throws on its second save, and a normal store. The fixture records each prune invocation and removes only that isolated package. Observed: nine assertions passed. A failed attempt write prevented the invocation and preserved the package. A failed completion write following either command success or command failure returned a distinct result and left a persisted `started` record. Normal execution persisted `started` and linked `success` records before the simulated caller refresh boundary. + +The AppService source path calls `OwnerCommandJournal.execute` in one detached operation, delivers the journal result to the UI, then begins `refreshVolumeCapacity` / `scanNow` after awaiting the operation. This source ordering is verified by inspection; the E2E harness does not launch the macOS UI or its actual scan. The `pnpm.error_launch` localization now reports an uncertain process outcome because launch monitoring can fail after the process starts. Director owns the full app build and UI acceptance. `python3 -m json.tool PoolProblem/PoolProblem/Localizable.xcstrings` and `git diff --check` passed. diff --git a/docs/verification/pnpm-owner-recipe/JournalE2E.swift b/docs/verification/pnpm-owner-recipe/JournalE2E.swift new file mode 100644 index 0000000..8a6add7 --- /dev/null +++ b/docs/verification/pnpm-owner-recipe/JournalE2E.swift @@ -0,0 +1,79 @@ +import Foundation + +private final class SaveCounter: @unchecked Sendable { var count = 0 } +private struct FailAtSave: JSONStoring { + let failureIndex: Int + let counter: SaveCounter + let base = JSONStore() + func save(_ value: T, to url: URL) throws { + counter.count += 1 + if counter.count == failureIndex { throw CocoaError(.fileWriteNoPermission) } + try base.save(value, to: url) + } + func load(_ type: T.Type, from url: URL) throws -> T? { + try base.load(type, from: url) + } +} + +@main struct JournalE2E { + static func main() throws { + let home = ProcessInfo.processInfo.environment["HOME"]! + let executable = ProcessInfo.processInfo.environment["FIXTURE_EXECUTABLE"]! + let storePath = home + "/store" + let fm = FileManager.default + try fm.createDirectory(atPath: storePath, withIntermediateDirectories: true) + let runner = OwnerCommandRunner(recipe: .pnpmStorePrune, executable: executable, + environment: ProcessInfo.processInfo.environment, home: home) + let target = try runner.probe().get() + func check(_ value: Bool, _ name: String) { if !value { fatalError(name) }; print("PASS " + name) } + func package() throws { try Data("package".utf8).write(to: URL(fileURLWithPath: storePath + "/unreferenced-package")) } + func invocations() -> Int { ((try? String(contentsOfFile: home + "/invocations", encoding: .utf8)) ?? "").split(separator: "\n").count } + func paths(_ name: String) -> StoragePaths { StoragePaths(baseURL: URL(fileURLWithPath: home + "/" + name), homeDirectory: home) } + + try package() + let noStartPaths = paths("no-start") + let noStartStore = OwnerCommandRecordStore(paths: noStartPaths, store: FailAtSave(failureIndex: 1, counter: SaveCounter())) + let before = invocations() + let noStart = OwnerCommandJournal(store: noStartStore).execute(recipeID: "pnpm-store-prune", targetPath: target.path) { + runner.perform(confirmed: target) + } + if case .startNotSaved = noStart { check(true, "failed attempt journal blocks command") } else { fatalError("unexpected start result") } + check(invocations() == before && fm.fileExists(atPath: storePath + "/unreferenced-package"), "failed journal leaves package untouched") + + let lostPaths = paths("completion-lost") + let lostStore = OwnerCommandRecordStore(paths: lostPaths, store: FailAtSave(failureIndex: 2, counter: SaveCounter())) + let lost = OwnerCommandJournal(store: lostStore).execute(recipeID: "pnpm-store-prune", targetPath: target.path) { + runner.perform(confirmed: target) + } + if case .completionNotSaved(.success, let attempt) = lost { check(attempt.outcome == "started", "completion write failure reports known command success separately") } + else { fatalError("unexpected completion result") } + check(!fm.fileExists(atPath: storePath + "/unreferenced-package"), "command can succeed despite history failure") + check(try lostStore.entries().map(\.outcome) == ["started"], "persisted attempt remains explicitly incomplete") + + let failedPaths = paths("failed-completion-lost") + let failedStore = OwnerCommandRecordStore(paths: failedPaths, store: FailAtSave(failureIndex: 2, counter: SaveCounter())) + var failedEnvironment = ProcessInfo.processInfo.environment + failedEnvironment["FIXTURE_MODE"] = "failure" + let failingRunner = OwnerCommandRunner(recipe: .pnpmStorePrune, executable: executable, + environment: failedEnvironment, home: home) + let failed = OwnerCommandJournal(store: failedStore).execute(recipeID: "pnpm-store-prune", targetPath: target.path) { + failingRunner.perform(confirmed: target) + } + if case .completionNotSaved(.failure(.actionFailed(17)), _) = failed { + check(true, "failed command plus completion write failure remains distinguishable") + } else { fatalError("unexpected failed command result") } + check(try failedStore.entries().map(\.outcome) == ["started"], "failed command leaves incomplete durable attempt") + + try package() + let goodStore = OwnerCommandRecordStore(paths: paths("success")) + let done = OwnerCommandJournal(store: goodStore).execute(recipeID: "pnpm-store-prune", targetPath: target.path) { + runner.perform(confirmed: target) + } + if case .completed(.success, let record) = done { check(record.outcome == "success", "command completion recorded") } + else { fatalError("unexpected success result") } + let savedBeforeRefresh = try goodStore.entries() + check(savedBeforeRefresh.map(\.outcome) == ["started", "success"] && savedBeforeRefresh[1].attemptID == savedBeforeRefresh[0].id, + "completion durable before caller's refresh boundary") + print("EVIDENCE simulated refresh begins after both records persisted") + } +} diff --git a/docs/verification/pnpm-owner-recipe/OwnerCommandE2E.swift b/docs/verification/pnpm-owner-recipe/OwnerCommandE2E.swift new file mode 100644 index 0000000..eac568d --- /dev/null +++ b/docs/verification/pnpm-owner-recipe/OwnerCommandE2E.swift @@ -0,0 +1,184 @@ +import Foundation +import Darwin + +@main struct OwnerCommandE2E { + static func main() throws { + setbuf(stdout, nil) + let home = ProcessInfo.processInfo.environment["HOME"]! + let fixture = ProcessInfo.processInfo.environment["FIXTURE_EXECUTABLE"]! + let fm = FileManager.default + let store = home + "/store" + try fm.createDirectory(atPath: store, withIntermediateDirectories: true) + try fm.createDirectory(atPath: home + "/other-store", withIntermediateDirectories: true) + let env = ProcessInfo.processInfo.environment + func runner(_ mode: String) -> OwnerCommandRunner { + var e = env; e["FIXTURE_MODE"] = mode + return OwnerCommandRunner(recipe: .pnpmStorePrune, executable: fixture, environment: e, home: home, timeout: 5) + } + func check(_ condition: Bool, _ message: String) { if !condition { fatalError(message) }; print("PASS " + message) } + // Exercise the real file deleter with a persisted aggregate whose + // volume URL is the volume root, not the fixture's user home. + let legacyPnpm = home + "/Library/pnpm" + let safeCache = home + "/.npm" + try fm.createDirectory(atPath: legacyPnpm + "/store/v3", withIntermediateDirectories: true) + try fm.createDirectory(atPath: safeCache, withIntermediateDirectories: true) + let pnpmSentinel = legacyPnpm + "/store/v3/keep-package" + try Data("keep".utf8).write(to: URL(fileURLWithPath: pnpmSentinel)) + try Data("clean".utf8).write(to: URL(fileURLWithPath: safeCache + "/cache-entry")) + let old = Date(timeIntervalSince1970: 1_000_000) + let persisted = ScanItem( + id: "legacy-package-cache", recipeID: PackageManagerRecipes.familyID, + name: "Package cache", path: legacyPnpm, paths: [legacyPnpm, safeCache], + category: .packageManager, safety: .safeWhileRunning, + disposition: .deletePermanently, sizeBytes: 8192, + allocatedBytes: 8192, reclaimableBytes: 8192, fileCount: 2, + lastModified: old, allowsAutomaticPermanentDeletion: true + ) + let legacyScan = ScanResult( + volume: VolumeInfo(totalBytes: 100_000, availableBytes: 10_000, timestamp: old), + items: [persisted], records: [], volumeURL: URL(fileURLWithPath: "/") + ) + let legacyLogs = CleanLogStore(paths: StoragePaths( + baseURL: URL(fileURLWithPath: home + "/legacy-data"), homeDirectory: home + )) + let legacyOutcome = try Cleaner( + evaluator: RuleEvaluator(config: .default, now: { old }), + deleter: FileManagerFileDeleter(), inspector: AlwaysFalseProcessInspector(), + logStore: legacyLogs, homeDirectory: home, now: { old } + ).run(scan: legacyScan, config: .default, waterlineBytes: 99_000, + ignoreAge: true, source: .auto) + check(legacyScan.volumeURL.path != home, "legacy scan volume differs from fixture home") + check(!fm.fileExists(atPath: safeCache), "unrelated aggregate cache actually cleaned") + check(fm.fileExists(atPath: legacyPnpm) && fm.fileExists(atPath: pnpmSentinel), "legacy pnpm tree survives Cleaner") + check(legacyOutcome.entries.map(\.originalPaths) == [[safeCache]], "Cleaner records only safe cache deletion") + check(try legacyLogs.entries().map(\.originalPaths) == [[safeCache]], "durable clean log excludes pnpm") + // A persisted scan can outlive a configured-home change. Its old + // aggregate must not gain permission to delete the previous HOME. + let historicalHome = URL(fileURLWithPath: home).deletingLastPathComponent() + .appendingPathComponent("historical-home").path + let historicalPnpm = historicalHome + "/Library/pnpm" + let freshSafeCache = home + "/.cache/uv" + try fm.createDirectory(atPath: historicalPnpm + "/store/v3", withIntermediateDirectories: true) + try fm.createDirectory(atPath: freshSafeCache, withIntermediateDirectories: true) + let historicalSentinel = historicalPnpm + "/store/v3/keep-package" + try Data("historical".utf8).write(to: URL(fileURLWithPath: historicalSentinel)) + try Data("clean".utf8).write(to: URL(fileURLWithPath: freshSafeCache + "/cache-entry")) + let oldHomeAggregate = ScanItem( + id: "historical-package-cache", recipeID: PackageManagerRecipes.familyID, + name: "Historical package cache", path: historicalPnpm, + paths: [historicalPnpm, freshSafeCache], category: .packageManager, + safety: .safeWhileRunning, disposition: .deletePermanently, + sizeBytes: 8192, allocatedBytes: 8192, reclaimableBytes: 8192, + fileCount: 2, lastModified: old, allowsAutomaticPermanentDeletion: true + ) + let historicalScan = ScanResult( + volume: VolumeInfo(totalBytes: 100_000, availableBytes: 10_000, timestamp: old), + items: [oldHomeAggregate], records: [], volumeURL: URL(fileURLWithPath: "/") + ) + let historicalOutcome = try Cleaner( + evaluator: RuleEvaluator(config: .default, now: { old }), + deleter: FileManagerFileDeleter(), inspector: AlwaysFalseProcessInspector(), + logStore: legacyLogs, homeDirectory: home, now: { old } + ).run(scan: historicalScan, config: .default, waterlineBytes: 99_000, + ignoreAge: true, source: .auto) + check(historicalHome != home, "persisted pnpm tree belongs to a different temporary home") + check(!fm.fileExists(atPath: freshSafeCache), "safe cache still deleted after home changes") + check(fm.fileExists(atPath: historicalPnpm) && fm.fileExists(atPath: historicalSentinel), "historical-home pnpm tree survives Cleaner") + check(historicalOutcome.entries.map(\.originalPaths) == [[freshSafeCache]], "historical aggregate records only safe deletion") + // An old aggregate can contain a parent of pnpm, which a pnpm-root + // pattern check alone cannot protect from recursive deletion. + try fm.createDirectory(atPath: safeCache, withIntermediateDirectories: true) + try Data("clean again".utf8).write(to: URL(fileURLWithPath: safeCache + "/cache-entry")) + let cocoaPodsCache = home + "/Library/Caches/CocoaPods" + let homebrewCache = home + "/Library/Caches/Homebrew" + for path in [cocoaPodsCache, homebrewCache] { + try fm.createDirectory(atPath: path, withIntermediateDirectories: true) + try Data("clean".utf8).write(to: URL(fileURLWithPath: path + "/cache-entry")) + } + let historicalLibrary = historicalHome + "/Library" + let parentAggregate = ScanItem( + id: "parent-package-cache", recipeID: PackageManagerRecipes.familyID, + name: "Parent package cache", path: historicalLibrary, + paths: [historicalLibrary, safeCache, cocoaPodsCache, homebrewCache], category: .packageManager, + safety: .safeWhileRunning, disposition: .deletePermanently, + sizeBytes: 16_384, allocatedBytes: 16_384, reclaimableBytes: 16_384, + fileCount: 4, lastModified: old, allowsAutomaticPermanentDeletion: true + ) + let parentScan = ScanResult( + volume: VolumeInfo(totalBytes: 100_000, availableBytes: 10_000, timestamp: old), + items: [parentAggregate], records: [], volumeURL: URL(fileURLWithPath: "/") + ) + let parentOutcome = try Cleaner( + evaluator: RuleEvaluator(config: .default, now: { old }), + deleter: FileManagerFileDeleter(), inspector: AlwaysFalseProcessInspector(), + logStore: legacyLogs, homeDirectory: home, now: { old } + ).run(scan: parentScan, config: .default, waterlineBytes: 99_000, + ignoreAge: true, source: .auto) + check([safeCache, cocoaPodsCache, homebrewCache].allSatisfy { !fm.fileExists(atPath: $0) }, "all approved default caches deleted beside stale parent") + check(fm.fileExists(atPath: historicalLibrary) && fm.fileExists(atPath: historicalSentinel), "historical Library parent and pnpm survive Cleaner") + check(parentOutcome.entries.map(\.originalPaths) == [[safeCache], [cocoaPodsCache], [homebrewCache]], "parent aggregate records only approved caches") + check(try Array(legacyLogs.entries().suffix(3)).map(\.originalPaths) == [[safeCache], [cocoaPodsCache], [homebrewCache]], "durable parent aggregate log excludes Library and pnpm") + try Data("package".utf8).write(to: URL(fileURLWithPath: store + "/unreferenced-package")) + let target = try runner("success").probe().get() + check(target.path == store, "probe exact isolated store") + let outcome = try runner("success").perform(confirmed: target).get() + check(!fm.fileExists(atPath: store + "/unreferenced-package") && fm.fileExists(atPath: store), "prune retained store root") + check(outcome.target == target, "outcome exact confirmed target") + let paths = StoragePaths(baseURL: URL(fileURLWithPath: home + "/data"), homeDirectory: home) + let records = OwnerCommandRecordStore(paths: paths) + try records.append(OwnerCommandRecord(recipeID: OwnerCommandRecipe.pnpmStorePrune.id, targetPath: target.path, outcome: "success", capacityDeltaBytes: outcome.capacityDeltaBytes)) + check(try records.entries().last?.targetPath == store, "separate owner command history") + check(!fm.fileExists(atPath: paths.cleanLogURL.path), "no fabricated clean log") + try Data("package".utf8).write(to: URL(fileURLWithPath: store + "/unreferenced-package")) + let before = (try? String(contentsOfFile: home + "/invocations", encoding: .utf8)) ?? "" + if case .failure(.actionFailed(17)) = runner("failure").perform(confirmed: target) { check(true, "nonzero action status") } else { fatalError("wrong action result") } + check(fm.fileExists(atPath: store + "/unreferenced-package"), "failed action left package") + let changedTarget = try runner("changed").probe().get() + if case .failure(.targetChanged) = runner("changed").perform(confirmed: changedTarget) { check(true, "changed target blocked") } else { fatalError("changed target executed") } + check(runner("invalid").probe() == .failure(.invalidTarget), "invalid root rejected") + let after = try String(contentsOfFile: home + "/invocations", encoding: .utf8) + check(after.split(separator: "\n").count == before.split(separator: "\n").count + 1, "changed and invalid target did not run prune") + let timeoutFixture = env["TIMEOUT_FIXTURE_EXECUTABLE"]! + let timeoutRunner = OwnerCommandRunner(recipe: .pnpmStorePrune, executable: timeoutFixture, environment: env, home: home, timeout: 0.5) + let timeoutTarget = try timeoutRunner.probe().get() + let victim = store + "/delayed-delete-victim" + try Data("must survive timeout".utf8).write(to: URL(fileURLWithPath: victim)) + let started = Date() + let timeoutResult = timeoutRunner.perform(confirmed: timeoutTarget) + let elapsed = Date().timeIntervalSince(started) + let heartbeatPath = home + "/child-heartbeat" + let atReturn = (try? Data(contentsOf: URL(fileURLWithPath: heartbeatPath))) ?? Data() + Thread.sleep(forTimeInterval: 0.8) + let afterReturn = (try? Data(contentsOf: URL(fileURLWithPath: heartbeatPath))) ?? Data() + // Clean up the deliberately orphaned fixture even when the regression fails. + if let pidText = try? String(contentsOfFile: home + "/child-pid", encoding: .utf8), + let pid = Int32(pidText.trimmingCharacters(in: .whitespacesAndNewlines)) { + kill(pid, SIGKILL) + } + if case .failure(.timedOut) = timeoutResult { check(true, "wrapper action reports timeout") } + else { fatalError("wrapper did not time out") } + check(!atReturn.isEmpty, "child ran after draining 512 KiB of stdout and stderr") + print("EVIDENCE timeout elapsed=\(elapsed) heartbeatBytesAtReturn=\(atReturn.count) heartbeatBytesAfter800ms=\(afterReturn.count)") + check(atReturn == afterReturn, "child stopped before timeout was reported") + check(fm.fileExists(atPath: victim), "child cannot perform delayed deletion after timeout") + check(elapsed < 3, "timeout completes within bounded interval") + var outputEnv = env; outputEnv["LIFETIME_MODE"] = "output" + let outputRunner = OwnerCommandRunner(recipe: .pnpmStorePrune, executable: timeoutFixture, environment: outputEnv, home: home, timeout: 5) + let noisy = try outputRunner.perform(confirmed: timeoutTarget).get() + check(noisy.output.utf8.count == 65_536, "512 KiB drained with only 64 KiB retained") + check(true, "executable observes its own process group before action starts") + try fm.removeItem(atPath: heartbeatPath) + var earlyEnv = env; earlyEnv["LIFETIME_MODE"] = "early-exit" + let earlyRunner = OwnerCommandRunner(recipe: .pnpmStorePrune, executable: timeoutFixture, environment: earlyEnv, home: home, timeout: 5) + _ = try earlyRunner.perform(confirmed: timeoutTarget).get() + let earlyAtReturn = try Data(contentsOf: URL(fileURLWithPath: heartbeatPath)) + Thread.sleep(forTimeInterval: 0.8) + let earlyAfter = try Data(contentsOf: URL(fileURLWithPath: heartbeatPath)) + if let pidText = try? String(contentsOfFile: home + "/child-pid", encoding: .utf8), + let pid = Int32(pidText.trimmingCharacters(in: .whitespacesAndNewlines)) { kill(pid, SIGKILL) } + check(!earlyAtReturn.isEmpty && earlyAtReturn == earlyAfter, "early wrapper exit also stops its child") + check(fm.fileExists(atPath: victim), "early wrapper exit cannot leave delayed deletion running") + check(PackageManagerRecipes.isLegacyPnpmPath(home + "/Library/pnpm/store/v3", homeDirectory: home), "legacy pnpm subtree guarded") + check(!PackageManagerRecipes.defaultPaths(homeDirectory: home).contains(home + "/Library/pnpm"), "default automatic recipe excludes pnpm") + } +} diff --git a/docs/verification/pnpm-owner-recipe/REPORT.md b/docs/verification/pnpm-owner-recipe/REPORT.md new file mode 100644 index 0000000..78701c1 --- /dev/null +++ b/docs/verification/pnpm-owner-recipe/REPORT.md @@ -0,0 +1,40 @@ +# pnpm owner command isolated E2E + +Run from any directory on macOS with Swift installed: + +```sh +sh docs/verification/pnpm-owner-recipe/run.sh +``` + +The script creates a task-specific `$TMPDIR/poolproblem-pnpm-owner.*` directory, compiles the actual `DiskReservoirCore` sources with Swift and Clang module caches inside it, and launches the controlled `fixture.sh` with isolated `HOME`, `store`, and data directory. The input fixture implements `pnpm store path` and `pnpm store prune`; it never invokes installed pnpm. Temporary files are removed at exit. The repeatable output artifact is [`last-run.log`](last-run.log). + +Observed result after the Cleaner fix: 34 assertions pass. They cover exact store probing, successful prune preserving the store directory, separate owner command history and absent fabricated clean log, nonzero exit, target change and invalid target refusing prune, bounded output, timeout stopping the wrapper and ordinary child processes, and legacy/default recipe guards. Thirteen assertions exercise persisted aggregates through the real `Cleaner` and `FileManagerFileDeleter`: with `scan.volumeURL` set to `/`, the configured temporary HOME's pnpm tree survives while `.npm` is deleted; a second aggregate from a different historical temporary HOME likewise preserves its pnpm tree while `.cache/uv` is deleted; a third aggregate includes that historical home's `Library` parent and all three remaining approved default roots. The parent and pnpm sentinel survive, while `.npm`, CocoaPods, and Homebrew caches are deleted. Outcome and durable clean log checks ensure protected targets are not reported as cleaned. No user pnpm store is touched. The separate journal harness passes nine assertions for durable attempt and completion recording; see [`JOURNAL-REPORT.md`](JOURNAL-REPORT.md). It does not establish that the real pnpm command behaves identically or that the macOS UI renders correctly. Live UI acceptance remains open. + +Fail-first evidence: after adding the five Cleaner assertions and before changing the implementation, `sh docs/verification/pnpm-owner-recipe/run.sh` exited 133. [`cleaner-before.log`](cleaner-before.log) records the safe `.npm` cache being removed, then `Fatal error: legacy pnpm tree survives Cleaner`. The command, temporary HOME, aggregate input, and assertions are in `run.sh` and `OwnerCommandE2E.swift`; rerunning the current script produces [`last-run.log`](last-run.log). The failure modes considered before implementation were an old persisted pnpm root or descendant being deleted when volume and HOME differ, a mixed aggregate deleting protected targets, an overbroad guard blocking unrelated caches, an early waterline stop masking the deletion path, and a result-only assertion missing real filesystem deletion. The test checks the actual file tree and durable log with a large deficit. + +Second fail-first evidence: after adding the historical HOME scenario and before broadening the path guard, the same command exited 133. [`mismatched-home-before.log`](mismatched-home-before.log) records the unrelated `.cache/uv` directory being deleted, then `Fatal error: historical-home pnpm tree survives Cleaner`. The second temporary HOME is a sibling of the configured temporary HOME under the task directory. Failure modes considered were a stale absolute path from a prior HOME, symlink or `..` aliases hiding the pnpm segment, an overbroad guard suppressing safe caches, and a result-only check missing deletion. The guard now checks standardized input and resolved canonical paths for the exact `Library/pnpm` directory segment, while retaining the configured home's canonical-root check for aliases into a symlinked pnpm root. The run checks actual file survival and safe-cache deletion. + +Third fail-first evidence: after adding a persisted aggregate with the historical home's `Library` parent and before adding the positive allowlist, the same command exited 133. [`parent-before.log`](parent-before.log) records all approved default caches being deleted, then `Fatal error: historical Library parent and pnpm survive Cleaner`. This proved that a descendant-only pnpm guard does not stop recursive deletion of an ancestor. Failure modes considered were stale parent targets, old extra roots, accidental denial of default caches, and mismatch between actual deletion and logs. The built-in `package-manager-caches` family now permits only exact standardized current default roots, checked for each target in Cleaner and manual AppService cleanup. The broad pnpm path guard remains as backup. User-configured cache roots use the separate manual-only custom recipe in the app. + +Source inspection: default package manager paths exclude `~/Library/pnpm`; `Cleaner` checks old snapshot paths against the positive default-root allowlist and the broad pnpm path guard, with configured home supplied from `StoragePaths.homeDirectory` by GUI, CLI, and MCP callers. Manual AppService cleanup applies the same checks. GUI scan and package/project recipe roots use the same configured home. The dedicated command has no raw path deletion fallback. The custom package-manager recipe follows a separate manual-confirmation path; its own policy remains distinct from the built-in family's allowlist. + +## Real pnpm smoke test + +Run `docs/verification/pnpm-owner-recipe/run-real-pnpm.sh` with pnpm on `PATH`. The script gives pnpm an isolated task-specific `HOME`, XDG directories, and `PNPM_HOME`. It reads `pnpm store path` and refuses to run `prune` unless that path is inside the temporary fixture. Its input is an empty store; the output artifact is [`real-pnpm-last-run.log`](real-pnpm-last-run.log). On 2026-09-27, installed pnpm resolved its store within the fixture and `prune` exited successfully, reporting 0 packages removed. This verifies real command launch and target isolation, not meaningful space recovery from a populated store. + +An attempt to populate the temporary store using `pnpm store add` with a local directory failed in the installed pnpm version (`pkgResponse.fetching is not a function`). The final smoke test therefore uses an empty store and makes no claim about populated-store prune behavior. No command was run on the real user store. + +## Build checks + +- `swift build --arch arm64 --scratch-path "$TMPDIR/poolproblem-pnpm-build"` succeeded with Swift Argument Parser 1.8.2 from the package cache. +- `xcodebuild -project PoolProblem/PoolProblem.xcodeproj -scheme PoolProblem -configuration Debug -destination 'platform=macOS' -derivedDataPath "$TMPDIR/poolproblem-pnpm-derived" CODE_SIGNING_ALLOWED=NO build` succeeded. The only reported warning was skipped App Intents metadata extraction because this app has no AppIntents dependency. + +Both builds were run with permission for Xcode/SwiftPM host caches after the nested Agent Control Plane sandbox could not access them. A live popover interaction has not been verified; launching a disk-cleanup app against the user's real HOME was outside this isolated verification. + +For the current Cleaner change, the source-compiled E2E passed. Both production compile checks passed with approved host cache access: `swift build --arch arm64 --scratch-path "$TMPDIR/poolproblem-pnpm-cleaner-build"` (CLI/MCP/core), and `xcodebuild -project PoolProblem/PoolProblem.xcodeproj -scheme PoolProblem -configuration Debug -destination 'platform=macOS' -derivedDataPath "$TMPDIR/poolproblem-pnpm-cleaner-derived" CODE_SIGNING_ALLOWED=NO build` (GUI). Before host access, two SwiftPM attempts were blocked by module-cache and `sandbox_apply` permissions; those failures did not indicate source errors. The Xcode build was unsigned and did not launch the app. + +The repository CI command `swift test --skip cliScan --scratch-path "$TMPDIR/poolproblem-pnpm-ci"` passed after updating its existing default-path expectation: one CLI test and 202 core tests. The historical expectation that `~/Library/pnpm` remains in the automatic recipe is incompatible with the dedicated manual command recipe. The app was rebuilt again after this correction and the current Cleaner change using the unsigned `xcodebuild` command above; it succeeded. + +After the historical-HOME guard change, `swift build --arch arm64 --scratch-path "$TMPDIR/poolproblem-pnpm-mismatch-build"` and the same unsigned Xcode build with `-derivedDataPath "$TMPDIR/poolproblem-pnpm-mismatch-derived"` both succeeded with approved host cache access. These validate compilation of the final CLI/MCP and GUI source; they do not establish live UI behavior. + +After the positive default-root allowlist, `swift build --arch arm64 --scratch-path "$TMPDIR/poolproblem-pnpm-parent-build"` and the same unsigned Xcode build with `-derivedDataPath "$TMPDIR/poolproblem-pnpm-parent-derived"` both succeeded with approved host cache access. This is the current compiled source state; no app launch or live cleanup was attempted. diff --git a/docs/verification/pnpm-owner-recipe/ReadOnlyRealProbe.swift b/docs/verification/pnpm-owner-recipe/ReadOnlyRealProbe.swift new file mode 100644 index 0000000..6f12849 --- /dev/null +++ b/docs/verification/pnpm-owner-recipe/ReadOnlyRealProbe.swift @@ -0,0 +1,34 @@ +import Foundation + +@main struct ReadOnlyRealProbe { + static func main() throws { + let home = NSHomeDirectory() + var environment = ProcessInfo.processInfo.environment + // Match the minimal PATH observed in the menu bar app. This entry point + // calls `store path` and Scanner only; it never invokes `store prune`. + environment["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin" + let runner = OwnerCommandRunner(recipe: .pnpmStorePrune, environment: environment, home: home) + switch runner.probe() { + case .failure(let failure): + print("probe=failure \(failure)") + throw failure + case .success(let target): + let result = try Scanner().scan( + recipes: [OwnerCommandRecipe.pnpmStorePrune.scanRecipe(target: target)], + homeDirectory: home + ) + guard let item = result.items.first(where: { + $0.recipeID == OwnerCommandRecipe.pnpmStorePrune.id && $0.path == target.path + }) else { throw CocoaError(.fileNoSuchFile) } + let display = ScanDisplayPolicy.visibleItems(result.items, recipes: [ + OwnerCommandRecipe.pnpmStorePrune.scanRecipe(target: target) + ]) + print("probe=success") + print("executable=\(target.executable.replacingOccurrences(of: home, with: "~"))") + print("store=\(target.path.replacingOccurrences(of: home, with: "~"))") + print("allocated_bytes=\(item.allocatedBytes)") + print("visible_above_10_mb=\(!display.isEmpty)") + print("prune_executed=false") + } + } +} diff --git a/docs/verification/pnpm-owner-recipe/TIMEOUT-REPORT.md b/docs/verification/pnpm-owner-recipe/TIMEOUT-REPORT.md new file mode 100644 index 0000000..2c6d033 --- /dev/null +++ b/docs/verification/pnpm-owner-recipe/TIMEOUT-REPORT.md @@ -0,0 +1,11 @@ +# Owner-command process lifetime E2E + +- Scope: `OwnerCommandRecipe.swift` process lifetime plus the existing E2E harness. No app build, unit tests, real pnpm invocation, staging, commits, or publication. +- Command: `sh docs/verification/pnpm-owner-recipe/run.sh` from the repository root on macOS with Xcode command-line tools and an absolute `TMPDIR`. +- Inputs: a temporary HOME/store, existing shell fixture for baseline scenarios, and a compiled C timeout fixture (no shell wrapper for the timeout scenario). +- Existing regression artifact: `timeout-before.log` records the parent-only implementation failing with `child stopped before timeout was reported`. The current developer pass found both the process-group implementation and this artifact already present; it did not recreate that historical run or rewrite the implementation. It extended the E2E first and verified the current implementation. +- Timeout scenario: native wrapper checks that its PGID equals its PID before action, then forks a child which checks inherited group membership, ignores SIGTERM, writes 256 KiB each to stdout/stderr, and appends a heartbeat every 50 ms. Runner timeout is 500 ms. Compare heartbeat at return and after 800 ms; verify a temporary victim scheduled for deletion after 600 ms survives. The harness kills the fixture child on regression failure before asserting. +- Additional scenarios: a successful native command writes 512 KiB and the returned output must contain exactly 65,536 bytes; a wrapper exits after 100 ms while its child continues, and the runner must stop that child before returning, leaving both heartbeat and delayed-deletion victim unchanged over 800 ms. +- Implementation: direct absolute-path `posix_spawn`, argv/env arrays, atomic `POSIX_SPAWN_SETPGROUP` with PGID 0, checked setup calls, nonblocking shared stdout/stderr pipe and 64 KiB retained output. Whole-group SIGKILL precedes leader reaping on both timeout and normal wrapper exit. `waitid(WNOWAIT)` retains the leader PID until group termination. Darwin's zombie-only group can reject a signal; this is accepted only once the group is confirmed absent after reaping. +- Current run: PASS (exit 0), 34 assertions including thirteen Cleaner guards. The exact timeout elapsed and heartbeat counts for this run are in `last-run.log`; the heartbeat stayed unchanged over 800 ms. All deletion inputs are in task-specific temporary HOME directories; no real pnpm executable or real user store is used. The runner uses direct executable/argv spawning; the pre-existing baseline fixture is a shell script, while all process-lifetime scenarios use the native C fixture. +- Limits: process groups cover ordinary inherited descendants, including pnpm wrappers. A program deliberately calling setsid/setpgid to escape is outside this mechanism. The E2E observes heartbeat stability for 800 ms, not every possible descendant behavior. The full app build passed; live UI acceptance remains open. macOS 26 emits a deprecation warning for `posix_spawn_file_actions_addchdir_np`; retained for deployment compatibility with macOS 14. diff --git a/docs/verification/pnpm-owner-recipe/cleaner-before.log b/docs/verification/pnpm-owner-recipe/cleaner-before.log new file mode 100644 index 0000000..d963f6d --- /dev/null +++ b/docs/verification/pnpm-owner-recipe/cleaner-before.log @@ -0,0 +1,3 @@ +PASS legacy scan volume differs from fixture home +PASS unrelated aggregate cache actually cleaned +main/OwnerCommandE2E.swift:18: Fatal error: legacy pnpm tree survives Cleaner diff --git a/docs/verification/pnpm-owner-recipe/fixture.sh b/docs/verification/pnpm-owner-recipe/fixture.sh new file mode 100755 index 0000000..31aea92 --- /dev/null +++ b/docs/verification/pnpm-owner-recipe/fixture.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -eu +case "${1-} ${2-}" in + 'store path') + case "${FIXTURE_MODE-}" in + invalid) printf '%s\n' / ;; + changed) if [ -f "$HOME/probed" ]; then printf '%s\n' "$HOME/other-store"; else : > "$HOME/probed"; printf '%s\n' "$HOME/store"; fi ;; + *) printf '%s\n' "$HOME/store" ;; + esac ;; + 'store prune') + printf '%s\n' prune >> "$HOME/invocations" + if [ "${FIXTURE_MODE-}" = failure ]; then exit 17; fi + rm -f "$HOME/store/unreferenced-package" + printf '%s\n' 'Removed unreferenced package' ;; + *) exit 91 ;; +esac diff --git a/docs/verification/pnpm-owner-recipe/journal-last-run.log b/docs/verification/pnpm-owner-recipe/journal-last-run.log new file mode 100644 index 0000000..81493bc --- /dev/null +++ b/docs/verification/pnpm-owner-recipe/journal-last-run.log @@ -0,0 +1,10 @@ +PASS failed attempt journal blocks command +PASS failed journal leaves package untouched +PASS completion write failure reports known command success separately +PASS command can succeed despite history failure +PASS persisted attempt remains explicitly incomplete +PASS failed command plus completion write failure remains distinguishable +PASS failed command leaves incomplete durable attempt +PASS command completion recorded +PASS completion durable before caller's refresh boundary +EVIDENCE simulated refresh begins after both records persisted diff --git a/docs/verification/pnpm-owner-recipe/last-run.log b/docs/verification/pnpm-owner-recipe/last-run.log new file mode 100644 index 0000000..77f3556 --- /dev/null +++ b/docs/verification/pnpm-owner-recipe/last-run.log @@ -0,0 +1,35 @@ +PASS legacy scan volume differs from fixture home +PASS unrelated aggregate cache actually cleaned +PASS legacy pnpm tree survives Cleaner +PASS Cleaner records only safe cache deletion +PASS durable clean log excludes pnpm +PASS persisted pnpm tree belongs to a different temporary home +PASS safe cache still deleted after home changes +PASS historical-home pnpm tree survives Cleaner +PASS historical aggregate records only safe deletion +PASS all approved default caches deleted beside stale parent +PASS historical Library parent and pnpm survive Cleaner +PASS parent aggregate records only approved caches +PASS durable parent aggregate log excludes Library and pnpm +PASS probe exact isolated store +PASS prune retained store root +PASS outcome exact confirmed target +PASS separate owner command history +PASS no fabricated clean log +PASS nonzero action status +PASS failed action left package +PASS changed target blocked +PASS invalid root rejected +PASS changed and invalid target did not run prune +PASS wrapper action reports timeout +PASS child ran after draining 512 KiB of stdout and stderr +EVIDENCE timeout elapsed=0.5439209938049316 heartbeatBytesAtReturn=18 heartbeatBytesAfter800ms=18 +PASS child stopped before timeout was reported +PASS child cannot perform delayed deletion after timeout +PASS timeout completes within bounded interval +PASS 512 KiB drained with only 64 KiB retained +PASS executable observes its own process group before action starts +PASS early wrapper exit also stops its child +PASS early wrapper exit cannot leave delayed deletion running +PASS legacy pnpm subtree guarded +PASS default automatic recipe excludes pnpm diff --git a/docs/verification/pnpm-owner-recipe/mismatched-home-before.log b/docs/verification/pnpm-owner-recipe/mismatched-home-before.log new file mode 100644 index 0000000..8f3d85b --- /dev/null +++ b/docs/verification/pnpm-owner-recipe/mismatched-home-before.log @@ -0,0 +1,8 @@ +PASS legacy scan volume differs from fixture home +PASS unrelated aggregate cache actually cleaned +PASS legacy pnpm tree survives Cleaner +PASS Cleaner records only safe cache deletion +PASS durable clean log excludes pnpm +PASS persisted pnpm tree belongs to a different temporary home +PASS safe cache still deleted after home changes +main/OwnerCommandE2E.swift:18: Fatal error: historical-home pnpm tree survives Cleaner diff --git a/docs/verification/pnpm-owner-recipe/parent-before.log b/docs/verification/pnpm-owner-recipe/parent-before.log new file mode 100644 index 0000000..6ff5690 --- /dev/null +++ b/docs/verification/pnpm-owner-recipe/parent-before.log @@ -0,0 +1,11 @@ +PASS legacy scan volume differs from fixture home +PASS unrelated aggregate cache actually cleaned +PASS legacy pnpm tree survives Cleaner +PASS Cleaner records only safe cache deletion +PASS durable clean log excludes pnpm +PASS persisted pnpm tree belongs to a different temporary home +PASS safe cache still deleted after home changes +PASS historical-home pnpm tree survives Cleaner +PASS historical aggregate records only safe deletion +PASS all approved default caches deleted beside stale parent +main/OwnerCommandE2E.swift:18: Fatal error: historical Library parent and pnpm survive Cleaner diff --git a/docs/verification/pnpm-owner-recipe/real-pnpm-last-run.log b/docs/verification/pnpm-owner-recipe/real-pnpm-last-run.log new file mode 100644 index 0000000..e6bcde7 --- /dev/null +++ b/docs/verification/pnpm-owner-recipe/real-pnpm-last-run.log @@ -0,0 +1,8 @@ +pnpm executable: /Users/xingyuwang/.nvm/versions/node/v26.7.0/bin/pnpm +isolated store: /var/folders/1z/j4_f6yy56bs74cwpnfm_wnhc0000gn/T/poolproblem-real-pnpm.JIR9UC/pnpm/store/v11 +store size before prune: 0 KiB +Removed all cached metadata files +Removed 0 files (0 B) +Removed 0 packages +store size after prune: 8 KiB +PASS real pnpm prune completed on the isolated temporary store diff --git a/docs/verification/pnpm-owner-recipe/run-journal.sh b/docs/verification/pnpm-owner-recipe/run-journal.sh new file mode 100644 index 0000000..542af46 --- /dev/null +++ b/docs/verification/pnpm-owner-recipe/run-journal.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu +repo=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +case "${TMPDIR-}" in /*) ;; *) echo 'TMPDIR must be absolute' >&2; exit 2;; esac +work=$(mktemp -d "${TMPDIR%/}/poolproblem-pnpm-journal.XXXXXX") +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/home/store" "$work/module-cache" +find "$repo/Sources/DiskReservoirCore" -name '*.swift' -print > "$work/sources" +SWIFT_MODULECACHE_PATH="$work/module-cache" CLANG_MODULE_CACHE_PATH="$work/module-cache" swiftc -parse-as-library -o "$work/journal-e2e" $(cat "$work/sources") "$repo/docs/verification/pnpm-owner-recipe/JournalE2E.swift" +HOME="$work/home" FIXTURE_EXECUTABLE="$repo/docs/verification/pnpm-owner-recipe/fixture.sh" "$work/journal-e2e" > "$repo/docs/verification/pnpm-owner-recipe/journal-last-run.log" 2>&1 +cat "$repo/docs/verification/pnpm-owner-recipe/journal-last-run.log" diff --git a/docs/verification/pnpm-owner-recipe/run-readonly-real-probe.sh b/docs/verification/pnpm-owner-recipe/run-readonly-real-probe.sh new file mode 100644 index 0000000..7f6236c --- /dev/null +++ b/docs/verification/pnpm-owner-recipe/run-readonly-real-probe.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu +repo=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +case "${TMPDIR-}" in /*) ;; *) echo 'TMPDIR must be absolute' >&2; exit 2;; esac +work=$(mktemp -d "${TMPDIR%/}/poolproblem-pnpm-readonly-probe.XXXXXX") +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/module-cache" +rg --files "$repo/Sources/DiskReservoirCore" -g '*.swift' > "$work/sources" +SWIFT_MODULECACHE_PATH="$work/module-cache" CLANG_MODULE_CACHE_PATH="$work/module-cache" \ + swiftc -parse-as-library -suppress-warnings -o "$work/probe" $(cat "$work/sources") \ + "$repo/docs/verification/pnpm-owner-recipe/ReadOnlyRealProbe.swift" +"$work/probe" diff --git a/docs/verification/pnpm-owner-recipe/run-real-pnpm.sh b/docs/verification/pnpm-owner-recipe/run-real-pnpm.sh new file mode 100755 index 0000000..5c57955 --- /dev/null +++ b/docs/verification/pnpm-owner-recipe/run-real-pnpm.sh @@ -0,0 +1,31 @@ +#!/bin/sh +set -eu + +case "${TMPDIR-}" in /*) ;; *) echo 'TMPDIR must be an absolute system temporary directory' >&2; exit 2 ;; esac +pnpm_bin=$(command -v pnpm) +case "$pnpm_bin" in /*) ;; *) echo 'pnpm must resolve to an absolute executable path' >&2; exit 2 ;; esac + +work=$(mktemp -d "${TMPDIR%/}/poolproblem-real-pnpm.XXXXXX") +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/home" "$work/config" "$work/data" "$work/cache" "$work/pnpm" + +run_pnpm() { + env -i HOME="$work/home" TMPDIR="$work" \ + XDG_CONFIG_HOME="$work/config" XDG_DATA_HOME="$work/data" XDG_CACHE_HOME="$work/cache" \ + PNPM_HOME="$work/pnpm" npm_config_userconfig="$work/home/.npmrc" \ + PATH="$(dirname "$pnpm_bin"):/usr/bin:/bin" "$pnpm_bin" "$@" +} + +store=$(cd "$work/home" && run_pnpm store path) +case "$store" in + "$work"/*) ;; + *) echo "Refusing prune: pnpm store is outside isolated fixture: $store" >&2; exit 3 ;; +esac + +echo "pnpm executable: $pnpm_bin" +echo "isolated store: $store" +mkdir -p "$store" +echo "store size before prune: $(du -sk "$store" | cut -f1) KiB" +(cd "$work/home" && run_pnpm store prune) +echo "store size after prune: $(du -sk "$store" | cut -f1) KiB" +echo 'PASS real pnpm prune completed on the isolated temporary store' diff --git a/docs/verification/pnpm-owner-recipe/run.sh b/docs/verification/pnpm-owner-recipe/run.sh new file mode 100755 index 0000000..fffcfe2 --- /dev/null +++ b/docs/verification/pnpm-owner-recipe/run.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu +repo=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +case "${TMPDIR-}" in /*) ;; *) echo 'TMPDIR must be an absolute system temporary directory' >&2; exit 2;; esac +work=$(mktemp -d "${TMPDIR%/}/poolproblem-pnpm-owner.XXXXXX") +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/home" "$work/artifacts" +find "$repo/Sources/DiskReservoirCore" -name '*.swift' -print > "$work/sources" +mkdir -p "$work/module-cache" +SWIFT_MODULECACHE_PATH="$work/module-cache" CLANG_MODULE_CACHE_PATH="$work/module-cache" swiftc -parse-as-library -o "$work/owner-e2e" $(cat "$work/sources") "$repo/docs/verification/pnpm-owner-recipe/OwnerCommandE2E.swift" +cc -Wall -Wextra -o "$work/timeout-fixture" "$repo/docs/verification/pnpm-owner-recipe/timeout-fixture.c" +HOME="$work/home" TIMEOUT_FIXTURE_EXECUTABLE="$work/timeout-fixture" FIXTURE_EXECUTABLE="$repo/docs/verification/pnpm-owner-recipe/fixture.sh" "$work/owner-e2e" > "$repo/docs/verification/pnpm-owner-recipe/last-run.log" 2>&1 +cat "$repo/docs/verification/pnpm-owner-recipe/last-run.log" diff --git a/docs/verification/pnpm-owner-recipe/timeout-before.log b/docs/verification/pnpm-owner-recipe/timeout-before.log new file mode 100644 index 0000000..fc0b886 --- /dev/null +++ b/docs/verification/pnpm-owner-recipe/timeout-before.log @@ -0,0 +1 @@ +main/OwnerCommandE2E.swift:17: Fatal error: child stopped before timeout was reported diff --git a/docs/verification/pnpm-owner-recipe/timeout-fixture.c b/docs/verification/pnpm-owner-recipe/timeout-fixture.c new file mode 100644 index 0000000..ca94437 --- /dev/null +++ b/docs/verification/pnpm-owner-recipe/timeout-fixture.c @@ -0,0 +1,56 @@ +#include +#include +#include +#include +#include +#include + +int main(int argc, char **argv) { + const char *home = getenv("HOME"); + if (!home || argc != 3 || strcmp(argv[1], "store")) return 91; + if (!strcmp(argv[2], "path")) { printf("%s/store\n", home); return 0; } + if (strcmp(argv[2], "prune")) return 92; + /* This is observed inside the executable, before it starts any action. */ + if (getpgrp() != getpid()) return 96; + const char *mode = getenv("LIFETIME_MODE"); + if (mode && !strcmp(mode, "output")) { + char noise[4096]; memset(noise, 'x', sizeof(noise)); + for (int i = 0; i < 64; i++) { + write(STDOUT_FILENO, noise, sizeof(noise)); + write(STDERR_FILENO, noise, sizeof(noise)); + } + return 0; + } + pid_t child = fork(); + if (child < 0) return 93; + if (child == 0) { + if (getpgrp() != getppid()) return 97; + signal(SIGTERM, SIG_IGN); + char path[4096]; + snprintf(path, sizeof(path), "%s/child-heartbeat", home); + /* More than the capture limit on both streams proves they are drained. */ + char noise[4096]; memset(noise, 'x', sizeof(noise)); + for (int i = 0; i < 64; i++) { + write(STDOUT_FILENO, noise, sizeof(noise)); + write(STDERR_FILENO, noise, sizeof(noise)); + } + for (int i = 0; i < 100; i++) { + FILE *f = fopen(path, "a"); + if (!f) return 94; + fprintf(f, "%d\n", i); fclose(f); + if (i == 12) { + char victim[4096]; + snprintf(victim, sizeof(victim), "%s/store/delayed-delete-victim", home); + unlink(victim); + } + usleep(50000); + } + return 0; + } + char path[4096]; snprintf(path, sizeof(path), "%s/child-pid", home); + FILE *f = fopen(path, "w"); if (!f) { kill(child, SIGKILL); return 95; } + fprintf(f, "%d\n", child); fclose(f); + if (mode && !strcmp(mode, "early-exit")) { usleep(100000); return 0; } + waitpid(child, NULL, 0); + return 0; +}