Skip to content
24 changes: 24 additions & 0 deletions Sources/VocaMac/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,10 @@ final class AppState: ObservableObject {
/// Set to `true` in tests to avoid side effects.
let skipSystemIntegration: Bool

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

// MARK: - Initialization

init(
Expand Down Expand Up @@ -1252,6 +1256,26 @@ final class AppState: ObservableObject {
// we don't know yet — we'll detect it after loading completes.
let targetSize = size

// Refuse known-too-large loads before WhisperKit/CoreML can hang the
// UI spinner under memory pressure (vocamac#250).
if let targetSize,
!modelFitsInMemory(targetSize) {
let needed = String(format: "%.1f", targetSize.ramRequiredGB)
let failureMessage =
"Not enough free memory to load \(targetSize.displayName) "
+ "(~\(needed) GB needed). Free RAM or choose a smaller model."
showTemporaryError(failureMessage)
VocaLogger.error(.appState, failureMessage)
await restorePreviousModelIfNeeded(
afterFailedLoadFor: targetSize,
previousSize: previousModelSize,
previousName: previousLoadedModelName,
hadLoadedModel: hadLoadedModel,
originalFailureMessage: failureMessage
)
Comment thread
jatinkrmalik marked this conversation as resolved.
Outdated
return
}

// Mark the model as loading in the UI
if let targetSize = targetSize, let idx = availableModels.firstIndex(where: { $0.size == targetSize }) {
availableModels[idx].isLoading = true
Expand Down
39 changes: 39 additions & 0 deletions Sources/VocaMac/Services/SystemInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//
// Detects system hardware capabilities and recommends optimal whisper model size.

import Darwin
import Foundation

// MARK: - SystemCapabilities
Expand Down Expand Up @@ -147,4 +148,42 @@ enum SystemInfo {
// Use at most half the cores, minimum 2, maximum 8
return max(2, min(cores / 2, 8))
}

/// Approximate reclaimable free memory in bytes. Zero means the probe failed.
static var availableMemoryBytes: UInt64 {
var stats = vm_statistics64()
var count = mach_msg_type_number_t(
MemoryLayout<vm_statistics64_data_t>.stride / MemoryLayout<integer_t>.stride
)
let host = mach_host_self()
Comment thread
jatinkrmalik marked this conversation as resolved.
let kr = withUnsafeMutablePointer(to: &stats) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
host_statistics64(host, HOST_VM_INFO64, $0, &count)
}
}
guard kr == KERN_SUCCESS else { return 0 }
var pageSize: vm_size_t = 0
guard host_page_size(host, &pageSize) == KERN_SUCCESS, pageSize > 0 else { return 0 }
let pages = UInt64(stats.free_count) + UInt64(stats.inactive_count)
return pages * UInt64(pageSize)
Comment thread
jatinkrmalik marked this conversation as resolved.
Outdated
}

/// Whether loading `size` is likely to fit without thrashing.
///
/// Uses the catalog RAM estimate against installed memory and against
/// reclaimable free memory from host_statistics64. A zero available
/// reading is treated as unknown so we do not block loads on a failed probe.
static func canFitModelInMemory(
_ size: ModelSize,
physicalMemoryGB: Int = physicalMemoryGB,
availableBytes: UInt64 = availableMemoryBytes
) -> Bool {
let requiredGB = size.ramRequiredGB
guard Double(physicalMemoryGB) + 0.001 >= requiredGB else {
return false
}
guard availableBytes > 0 else { return true }
let requiredBytes = UInt64((requiredGB * 1024 * 1024 * 1024).rounded(.up))
return availableBytes >= requiredBytes
}
}
37 changes: 37 additions & 0 deletions Tests/VocaMacTests/AppStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,43 @@ final class AppStateModelLoadingTests: XCTestCase {
)
}

@MainActor
func testLowMemoryGateRefusesMediumBeforeWhisperAndRestoresPrevious() async {
UserDefaults.standard.set(ModelSize.small.rawValue, forKey: "vocamac.selectedModelSize")

let modelManager = MockModelManager()
modelManager.downloadedModels = [.small, .medium]

let whisperService = MockWhisperService()
whisperService.loadedModelName = "openai_whisper-small"
whisperService.isModelLoaded = true
whisperService.loadResponses = [
.success("openai_whisper-small"),
]

let (appState, mocks) = AppState.makeTestState(
modelManager: modelManager,
whisperService: whisperService
)
appState.modelFitsInMemory = { $0 != .medium }

await appState.loadModel(.medium)

XCTAssertTrue(
appState.errorMessage?.contains("Not enough") == true
|| appState.errorMessage?.localizedCaseInsensitiveContains("free memory") == true
)
XCTAssertFalse(
mocks.whisperService.loadRequests.map { $0.name }.contains("openai_whisper-medium")
)
XCTAssertEqual(
mocks.whisperService.loadRequests.map { $0.name },
["openai_whisper-small"]
)
XCTAssertEqual(appState.currentModel?.size, .small)
XCTAssertEqual(appState.selectedModelSize, ModelSize.small.rawValue)
}

@MainActor
func testDeleteModelRemovesDownloadedModel() async {
let modelManager = MockModelManager()
Expand Down
2 changes: 2 additions & 0 deletions Tests/VocaMacTests/Mocks/MockServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,8 @@ extension AppState {
permissionManager: permissionManager,
skipSystemIntegration: true
)
// Bypass host free-RAM probe so mock loads are not refused on CI.
appState.modelFitsInMemory = { _ in true }
return (appState, mocks)
}
}
Expand Down
41 changes: 41 additions & 0 deletions Tests/VocaMacTests/ModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,47 @@ final class SystemInfoTests: XCTestCase {
XCTAssertTrue(summary.contains("Metal:"))
XCTAssertTrue(summary.contains("Recommended Model:"))
}

func testCanFitModelRejectsWhenPhysicalMemoryIsTooLow() {
XCTAssertFalse(
SystemInfo.canFitModelInMemory(
.largeV3Latest,
physicalMemoryGB: 4,
availableBytes: UInt64(64) * 1024 * 1024 * 1024
)
)
}

func testCanFitModelRejectsWhenAvailableMemoryIsTooLow() {
XCTAssertFalse(
SystemInfo.canFitModelInMemory(
.base,
physicalMemoryGB: 16,
availableBytes: 64 * 1024 * 1024
)
)
}

func testCanFitModelAllowsWhenAvailableIsUnknown() {
XCTAssertTrue(
SystemInfo.canFitModelInMemory(
.tiny,
physicalMemoryGB: 8,
availableBytes: 0
)
)
}

func testCanFitModelAllowsWhenPhysicalAndAvailableAreEnough() {
let required = UInt64((ModelSize.base.ramRequiredGB * 1024 * 1024 * 1024).rounded(.up))
XCTAssertTrue(
SystemInfo.canFitModelInMemory(
.base,
physicalMemoryGB: 16,
availableBytes: required
)
)
}
}

// MARK: - ModelSize Tests
Expand Down
Loading