Skip to content

Commit 002a31c

Browse files
authored
Fail model load early when free RAM is below the catalog estimate (#251)
* Fail model load early when free RAM is below the catalog estimate Before WhisperKit starts, refuse loads that cannot fit in installed or available memory so the spinner does not hang (vocamac#250). * Cast os_proc_available_memory to UInt64 for App CI Swift imports the probe as Int; coerce non-positive readings to zero so availableMemoryBytes matches its UInt64 return type. * Replace iOS-only memory probe with host_statistics64 os_proc_available_memory is unavailable on macOS and broke App CI. Use free+inactive pages from HOST_VM_INFO64 instead; return 0 on probe failure. * Stub low-RAM model gate in AppState tests CI free+inactive pages often sit under Medium's 5 GB estimate, so the new pre-load check rejected mock medium loads and broke serialization/restore tests. * Address Greptile notes on the low-RAM model gate Refuse the load without restoring or clearing an already loaded model. Count speculative, purgeable, and compressor pages in the host probe, and release the mach_host_self send right. * Retrigger Greptile review on e47f66a Empty commit so Greptile re-scores the low-RAM gate fixes after the prior review on 8108205. No code changes. * Fix low-RAM probe overcounting available pages Do not add speculative pages on top of free_count (Darwin already includes them), and drop compressor pages which still occupy RAM. * Use free+inactive only for the low-RAM probe Purgeable often overlaps the inactive queue on Darwin, so summing both overstates reclaimable memory. Keep the gate conservative.
1 parent e2bf6ba commit 002a31c

5 files changed

Lines changed: 146 additions & 0 deletions

File tree

Sources/VocaMac/Models/AppState.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,10 @@ final class AppState: ObservableObject {
324324
/// Set to `true` in tests to avoid side effects.
325325
let skipSystemIntegration: Bool
326326

327+
/// Pre-load memory gate. Production defaults to SystemInfo; tests stub this
328+
/// so CI free+inactive pages cannot flake medium/large mock loads.
329+
var modelFitsInMemory: (ModelSize) -> Bool = { SystemInfo.canFitModelInMemory($0) }
330+
327331
// MARK: - Initialization
328332

329333
init(
@@ -1258,6 +1262,20 @@ final class AppState: ObservableObject {
12581262
// we don't know yet — we'll detect it after loading completes.
12591263
let targetSize = size
12601264

1265+
// Refuse known-too-large loads before WhisperKit/CoreML can hang the
1266+
// UI spinner under memory pressure (vocamac#250). Leave any already
1267+
// loaded model alone — we never started a load, so do not restore/clear.
1268+
if let targetSize,
1269+
!modelFitsInMemory(targetSize) {
1270+
let needed = String(format: "%.1f", targetSize.ramRequiredGB)
1271+
let failureMessage =
1272+
"Not enough free memory to load \(targetSize.displayName) "
1273+
+ "(~\(needed) GB needed). Free RAM or choose a smaller model."
1274+
showTemporaryError(failureMessage)
1275+
VocaLogger.error(.appState, failureMessage)
1276+
return
1277+
}
1278+
12611279
// Mark the model as loading in the UI
12621280
if let targetSize = targetSize, let idx = availableModels.firstIndex(where: { $0.size == targetSize }) {
12631281
availableModels[idx].isLoading = true

Sources/VocaMac/Services/SystemInfo.swift

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
//
44
// Detects system hardware capabilities and recommends optimal whisper model size.
55

6+
import Darwin
67
import Foundation
78

89
// MARK: - SystemCapabilities
@@ -147,4 +148,49 @@ enum SystemInfo {
147148
// Use at most half the cores, minimum 2, maximum 8
148149
return max(2, min(cores / 2, 8))
149150
}
151+
152+
/// Approximate reclaimable memory in bytes. Zero means the probe failed.
153+
///
154+
/// Uses free + inactive only. On Darwin, `free_count` already includes
155+
/// speculative pages, purgeable pages often overlap the inactive queue,
156+
/// and compressor-resident pages still occupy RAM — so those counters
157+
/// must not be added on top or the gate can over-approve loads.
158+
static var availableMemoryBytes: UInt64 {
159+
var stats = vm_statistics64()
160+
var count = mach_msg_type_number_t(
161+
MemoryLayout<vm_statistics64_data_t>.stride / MemoryLayout<integer_t>.stride
162+
)
163+
let host = mach_host_self()
164+
defer { mach_port_deallocate(mach_task_self_, host) }
165+
let kr = withUnsafeMutablePointer(to: &stats) {
166+
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
167+
host_statistics64(host, HOST_VM_INFO64, $0, &count)
168+
}
169+
}
170+
guard kr == KERN_SUCCESS else { return 0 }
171+
var pageSize: vm_size_t = 0
172+
guard host_page_size(host, &pageSize) == KERN_SUCCESS, pageSize > 0 else { return 0 }
173+
let pages = UInt64(stats.free_count)
174+
+ UInt64(stats.inactive_count)
175+
return pages * UInt64(pageSize)
176+
}
177+
178+
/// Whether loading `size` is likely to fit without thrashing.
179+
///
180+
/// Uses the catalog RAM estimate against installed memory and against
181+
/// reclaimable free memory from host_statistics64. A zero available
182+
/// reading is treated as unknown so we do not block loads on a failed probe.
183+
static func canFitModelInMemory(
184+
_ size: ModelSize,
185+
physicalMemoryGB: Int = physicalMemoryGB,
186+
availableBytes: UInt64 = availableMemoryBytes
187+
) -> Bool {
188+
let requiredGB = size.ramRequiredGB
189+
guard Double(physicalMemoryGB) + 0.001 >= requiredGB else {
190+
return false
191+
}
192+
guard availableBytes > 0 else { return true }
193+
let requiredBytes = UInt64((requiredGB * 1024 * 1024 * 1024).rounded(.up))
194+
return availableBytes >= requiredBytes
195+
}
150196
}

Tests/VocaMacTests/AppStateTests.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,45 @@ final class AppStateModelLoadingTests: XCTestCase {
347347
)
348348
}
349349

350+
@MainActor
351+
func testLowMemoryGateRefusesMediumBeforeWhisperWithoutTouchingLoadedModel() async {
352+
UserDefaults.standard.set(ModelSize.small.rawValue, forKey: "vocamac.selectedModelSize")
353+
354+
let modelManager = MockModelManager()
355+
modelManager.downloadedModels = [.small, .medium]
356+
357+
let whisperService = MockWhisperService()
358+
whisperService.loadResponses = [
359+
.success("openai_whisper-small"),
360+
]
361+
362+
let (appState, mocks) = AppState.makeTestState(
363+
modelManager: modelManager,
364+
whisperService: whisperService
365+
)
366+
367+
await appState.loadModel(.small)
368+
let loadsAfterSmall = mocks.whisperService.loadRequests.count
369+
XCTAssertEqual(appState.currentModel?.size, .small)
370+
XCTAssertTrue(mocks.whisperService.isModelLoaded)
371+
372+
appState.modelFitsInMemory = { $0 != .medium }
373+
await appState.loadModel(.medium)
374+
375+
XCTAssertTrue(
376+
appState.errorMessage?.contains("Not enough") == true
377+
|| appState.errorMessage?.localizedCaseInsensitiveContains("free memory") == true
378+
)
379+
XCTAssertEqual(mocks.whisperService.loadRequests.count, loadsAfterSmall)
380+
XCTAssertFalse(
381+
mocks.whisperService.loadRequests.map { $0.name }.contains("openai_whisper-medium")
382+
)
383+
XCTAssertEqual(appState.currentModel?.size, .small)
384+
XCTAssertEqual(appState.selectedModelSize, ModelSize.small.rawValue)
385+
XCTAssertTrue(mocks.whisperService.isModelLoaded)
386+
XCTAssertEqual(mocks.whisperService.loadedModelName, "openai_whisper-small")
387+
}
388+
350389
@MainActor
351390
func testDeleteModelRemovesDownloadedModel() async {
352391
let modelManager = MockModelManager()

Tests/VocaMacTests/Mocks/MockServices.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,8 @@ extension AppState {
572572
permissionManager: permissionManager,
573573
skipSystemIntegration: true
574574
)
575+
// Bypass host free-RAM probe so mock loads are not refused on CI.
576+
appState.modelFitsInMemory = { _ in true }
575577
return (appState, mocks)
576578
}
577579
}

Tests/VocaMacTests/ModelTests.swift

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,47 @@ final class SystemInfoTests: XCTestCase {
5353
XCTAssertTrue(summary.contains("Metal:"))
5454
XCTAssertTrue(summary.contains("Recommended Model:"))
5555
}
56+
57+
func testCanFitModelRejectsWhenPhysicalMemoryIsTooLow() {
58+
XCTAssertFalse(
59+
SystemInfo.canFitModelInMemory(
60+
.largeV3Latest,
61+
physicalMemoryGB: 4,
62+
availableBytes: UInt64(64) * 1024 * 1024 * 1024
63+
)
64+
)
65+
}
66+
67+
func testCanFitModelRejectsWhenAvailableMemoryIsTooLow() {
68+
XCTAssertFalse(
69+
SystemInfo.canFitModelInMemory(
70+
.base,
71+
physicalMemoryGB: 16,
72+
availableBytes: 64 * 1024 * 1024
73+
)
74+
)
75+
}
76+
77+
func testCanFitModelAllowsWhenAvailableIsUnknown() {
78+
XCTAssertTrue(
79+
SystemInfo.canFitModelInMemory(
80+
.tiny,
81+
physicalMemoryGB: 8,
82+
availableBytes: 0
83+
)
84+
)
85+
}
86+
87+
func testCanFitModelAllowsWhenPhysicalAndAvailableAreEnough() {
88+
let required = UInt64((ModelSize.base.ramRequiredGB * 1024 * 1024 * 1024).rounded(.up))
89+
XCTAssertTrue(
90+
SystemInfo.canFitModelInMemory(
91+
.base,
92+
physicalMemoryGB: 16,
93+
availableBytes: required
94+
)
95+
)
96+
}
5697
}
5798

5899
// MARK: - ModelSize Tests

0 commit comments

Comments
 (0)