Skip to content

Commit 9ad595a

Browse files
p-linnaneclaude
andauthored
fix(kit): DMG mount-leak and release-loading robustness (#487)
* fix(scanner): unmount system DMG when cryptex mount fails The system DMG was only unmounted on the success path. If mounting the cryptex DMG threw, the system volume stayed mounted while the subsequent work-directory cleanup deleted its backing file, leaving a zombie mount that needs a manual `hdiutil detach`. Wrap the post-mount work so the system DMG is unmounted on every exit path. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Patrick Linnane <patrick@linnane.io> * fix(kit): error when no indexed releases can be loaded fetchAllReleases mapped every per-release fetch failure to nil, so a fully-failed load returned an empty array indistinguishable from a legitimately empty catalog. Throw DataProviderError.allReleasesFailed when the index is non-empty but nothing loaded, so the UI can surface the failure instead of showing an empty list. Also use Release.displayName in the per-release success log so Xcode releases aren't mislabeled as macOS. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Patrick Linnane <patrick@linnane.io> * docs: bump Claude commit trailer to Opus 4.8 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Patrick Linnane <patrick@linnane.io> --------- Signed-off-by: Patrick Linnane <patrick@linnane.io> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1a08f7b commit 9ad595a

3 files changed

Lines changed: 40 additions & 25 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ Common types: `feat`, `fix`, `refactor`, `docs`, `ci`, `chore`
202202

203203
All commits must:
204204
- Use `git commit -s` for DCO sign-off
205-
- Include a `Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>` trailer when authored with Claude
205+
- Include a `Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer when authored with Claude
206206

207207
## Git workflow
208208

Sources/macOSdbKit/DataProvider.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,14 @@ public actor DataProvider {
5959
let release = try decoder.decode(Release.self, from: data)
6060
cachedReleases[entry.buildNumber] = release
6161

62-
Self.logger.info("Loaded release: macOS \(release.osVersion) (\(release.buildNumber))")
62+
Self.logger.info("Loaded release: \(release.displayName) (\(release.buildNumber))")
6363
return release
6464
}
6565

6666
public func fetchAllReleases(for productType: ProductType = .macOS) async throws -> [Release] {
6767
let index = try await fetchReleaseIndex(for: productType)
6868

69-
return await withTaskGroup(of: Release?.self, returning: [Release].self) { group in
69+
let releases = await withTaskGroup(of: Release?.self, returning: [Release].self) { group in
7070
for entry in index {
7171
group.addTask {
7272
do {
@@ -88,6 +88,12 @@ public actor DataProvider {
8888
}
8989
return releases.sorted()
9090
}
91+
92+
// Surface an all-failed load as an error — an empty array reads as an empty catalog.
93+
guard index.isEmpty || !releases.isEmpty else {
94+
throw DataProviderError.allReleasesFailed(count: index.count)
95+
}
96+
return releases
9197
}
9298

9399
public func findRelease(osVersion: String, productType: ProductType = .macOS) async throws -> Release? {
@@ -118,11 +124,14 @@ public actor DataProvider {
118124

119125
enum DataProviderError: LocalizedError {
120126
case httpError(statusCode: Int, url: URL)
127+
case allReleasesFailed(count: Int)
121128

122129
var errorDescription: String? {
123130
switch self {
124131
case .httpError(let statusCode, let url):
125132
"HTTP \(statusCode) fetching \(url)"
133+
case .allReleasesFailed(let count):
134+
"Loaded the release index but failed to fetch any of its \(count) releases"
126135
}
127136
}
128137
}

Sources/macOSdbKit/Scanner/IPSWScanner.swift

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -212,35 +212,34 @@ public actor IPSWScanner {
212212
sendProgress(.mountingDMG)
213213
Self.logger.info("Mounting system DMG: \(systemDMG.lastPathComponent)")
214214
let systemMount = try await dmgMounter.mount(dmgPath: systemDMG)
215+
215216
var fsComponents = await extractFilesystemComponents(mountPoint: systemMount)
216217

218+
// Unmount the system DMG on every path; a throw here leaks the volume, then cleanup deletes its backing file.
217219
let dyldComponents: [Component]
218-
if let cryptexDMG {
219-
sendProgress(.mountingCryptex)
220-
Self.logger.info("Mounting cryptex DMG: \(cryptexDMG.lastPathComponent)")
221-
let cryptexMount = try await dmgMounter.mount(dmgPath: cryptexDMG)
222-
223-
// Scan filesystem components from cryptex too (macOS 13+ moved some binaries there)
224-
let cryptexFsComponents = await extractFilesystemComponents(mountPoint: cryptexMount)
225-
let systemNames = Set(fsComponents.map(\.name))
226-
for component in cryptexFsComponents {
227-
if systemNames.contains(component.name) {
228-
// Cryptex version overrides system version
229-
fsComponents.removeAll { $0.name == component.name }
230-
}
231-
fsComponents.append(component)
220+
do {
221+
if let cryptexDMG {
222+
sendProgress(.mountingCryptex)
223+
Self.logger.info("Mounting cryptex DMG: \(cryptexDMG.lastPathComponent)")
224+
let cryptexMount = try await dmgMounter.mount(dmgPath: cryptexDMG)
225+
226+
// Scan filesystem components from cryptex too (macOS 13+ moved some binaries there)
227+
let cryptexFsComponents = await extractFilesystemComponents(mountPoint: cryptexMount)
228+
fsComponents = merging(fsComponents, overriddenBy: cryptexFsComponents)
229+
230+
dyldComponents = await extractDyldCacheComponents(mountPoint: cryptexMount)
231+
sendProgress(.unmountingDMG)
232+
await dmgMounter.unmount(cryptexMount)
233+
} else {
234+
dyldComponents = await extractDyldCacheComponents(mountPoint: systemMount)
235+
sendProgress(.unmountingDMG)
232236
}
233-
234-
dyldComponents = await extractDyldCacheComponents(mountPoint: cryptexMount)
235-
sendProgress(.unmountingDMG)
236-
await dmgMounter.unmount(cryptexMount)
237-
await dmgMounter.unmount(systemMount)
238-
} else {
239-
dyldComponents = await extractDyldCacheComponents(mountPoint: systemMount)
240-
sendProgress(.unmountingDMG)
237+
} catch {
241238
await dmgMounter.unmount(systemMount)
239+
throw error
242240
}
243241

242+
await dmgMounter.unmount(systemMount)
244243
return (fsComponents + dyldComponents).sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
245244
}
246245

@@ -455,6 +454,13 @@ private func resolveDylibPath(
455454
return allPaths.first { $0.hasPrefix(prefix) && $0.hasSuffix(".dylib") }
456455
}
457456

457+
/// Merges cryptex components over system ones, with cryptex winning on name collisions.
458+
private func merging(_ system: [Component], overriddenBy cryptex: [Component]) -> [Component] {
459+
guard !cryptex.isEmpty else { return system }
460+
let cryptexNames = Set(cryptex.map(\.name))
461+
return system.filter { !cryptexNames.contains($0.name) } + cryptex
462+
}
463+
458464
// MARK: - Board codename device mapping
459465

460466
/// Fallback device mapping for kernelcache filenames that use board codenames

0 commit comments

Comments
 (0)