Skip to content
18 changes: 18 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,20 @@ 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). Leave any already
// loaded model alone — we never started a load, so do not restore/clear.
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)
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
46 changes: 46 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,49 @@ enum SystemInfo {
// Use at most half the cores, minimum 2, maximum 8
return max(2, min(cores / 2, 8))
}

/// Approximate reclaimable memory in bytes. Zero means the probe failed.
///
/// Uses free + inactive only. On Darwin, `free_count` already includes
/// speculative pages, purgeable pages often overlap the inactive queue,
/// and compressor-resident pages still occupy RAM — so those counters
/// must not be added on top or the gate can over-approve loads.
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.
defer { mach_port_deallocate(mach_task_self_, host) }
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)
}

/// 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
}
}
39 changes: 39 additions & 0 deletions Tests/VocaMacTests/AppStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,45 @@ final class AppStateModelLoadingTests: XCTestCase {
)
}

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

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

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

let (appState, mocks) = AppState.makeTestState(
modelManager: modelManager,
whisperService: whisperService
)

await appState.loadModel(.small)
let loadsAfterSmall = mocks.whisperService.loadRequests.count
XCTAssertEqual(appState.currentModel?.size, .small)
XCTAssertTrue(mocks.whisperService.isModelLoaded)

appState.modelFitsInMemory = { $0 != .medium }
await appState.loadModel(.medium)

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

@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