@@ -55,54 +55,117 @@ public final class InferenceEngine: ObservableObject {
5555 @Published public private( set) var state : ModelState = . idle
5656 @Published public private( set) var thermalLevel : ThermalLevel = . nominal
5757
58+ /// Whether to automatically unload the model when the app backgrounds
59+ /// and reload it when returning to foreground.
60+ /// Defaults to true on iOS (prevents jetsam), false on macOS.
61+ public var autoOffloadOnBackground : Bool = {
62+ #if os(iOS)
63+ return true
64+ #else
65+ return false
66+ #endif
67+ } ( )
68+
5869 /// Shared download + storage manager.
5970 public let downloadManager = ModelDownloadManager ( )
6071
6172 private var container : ModelContainer ?
6273 private var currentModelId : String ?
6374 private var generationTask : Task < Void , Never > ?
64- private var pressureObserver : NSObjectProtocol ?
65- private var thermalObserver : NSObjectProtocol ?
75+
76+ // All NotificationCenter observers collected for clean deregistration
77+ private var observers : [ NSObjectProtocol ] = [ ]
78+
79+ // Track the model ID that was active before we backgrounded,
80+ // so we can restore it when returning to foreground.
81+ private var backgroundedModelId : String ?
6682
6783 public init ( ) {
6884 setupPressureHandlers ( )
6985 }
7086
7187 deinit {
72- if let o = pressureObserver { NotificationCenter . default. removeObserver ( o) }
73- if let o = thermalObserver { NotificationCenter . default. removeObserver ( o) }
88+ observers. forEach { NotificationCenter . default. removeObserver ( $0) }
7489 }
7590
7691 // MARK: — Pressure Handlers
7792
7893 private func setupPressureHandlers( ) {
79- // iOS memory pressure → unload model weights immediately
8094 #if canImport(UIKit)
81- pressureObserver = NotificationCenter . default. addObserver (
82- forName: UIApplication . didReceiveMemoryWarningNotification,
83- object: nil ,
84- queue: . main
85- ) { [ weak self] _ in
86- Task { @MainActor [ weak self] in
87- guard let self else { return }
88- // Only unload if not actively generating
89- if case . generating = self . state { return }
90- self . unload ( )
91- self . state = . error( " Unloaded due to memory pressure. Tap to reload. " )
95+ // ── REACTIVE: Memory warning (last resort) ────────────────────────────
96+ // OS sends this *after* pressure builds. We still handle it as a fallback
97+ // in case the proactive unload wasn't triggered (e.g. app was already
98+ // under pressure from another process).
99+ observers. append (
100+ NotificationCenter . default. addObserver (
101+ forName: UIApplication . didReceiveMemoryWarningNotification,
102+ object: nil , queue: . main
103+ ) { [ weak self] _ in
104+ Task { @MainActor [ weak self] in
105+ guard let self else { return }
106+ if case . generating = self . state { return } // don't interrupt mid-stream
107+ self . unload ( )
108+ self . state = . error( " Unloaded due to memory pressure. Tap to reload. " )
109+ }
92110 }
93- }
111+ )
112+
113+ // ── PROACTIVE: App will background ────────────────────────────────────
114+ // Fire BEFORE iOS hands control back to springboard.
115+ // At this moment the process is still fully foregrounded — Metal context
116+ // is valid, memory limit hasn't changed. We unload now so iOS never
117+ // accumulates memory pressure against us in the background.
118+ observers. append (
119+ NotificationCenter . default. addObserver (
120+ forName: UIApplication . willResignActiveNotification,
121+ object: nil , queue: . main
122+ ) { [ weak self] _ in
123+ Task { @MainActor [ weak self] in
124+ guard let self, self . autoOffloadOnBackground else { return }
125+ // Remember what was loaded so we can restore it
126+ self . backgroundedModelId = self . currentModelId
127+ // Stop any in-flight generation cleanly
128+ self . stopGeneration ( )
129+ self . unload ( )
130+ self . state = . idle // clean slate — no error banner on return
131+ }
132+ }
133+ )
134+
135+ // ── PROACTIVE: App returned to foreground ─────────────────────────────
136+ // Silently reload the model the user was using before they left.
137+ // We show .loading state so the chat UI doesn't appear broken.
138+ observers. append (
139+ NotificationCenter . default. addObserver (
140+ forName: UIApplication . didBecomeActiveNotification,
141+ object: nil , queue: . main
142+ ) { [ weak self] _ in
143+ Task { @MainActor [ weak self] in
144+ guard let self, self . autoOffloadOnBackground else { return }
145+ // Prefer the model that was active when we backgrounded;
146+ // fall back to the last persisted model the user chose.
147+ let modelToReload = self . backgroundedModelId
148+ ?? self . downloadManager. lastLoadedModelId
149+ self . backgroundedModelId = nil
150+ if let modelId = modelToReload {
151+ await self . load ( modelId: modelId)
152+ }
153+ }
154+ }
155+ )
94156 #endif
95157
96- // Thermal state monitoring (all platforms)
97- thermalObserver = NotificationCenter . default. addObserver (
98- forName: ProcessInfo . thermalStateDidChangeNotification,
99- object: nil ,
100- queue: . main
101- ) { [ weak self] _ in
102- Task { @MainActor [ weak self] in
103- self ? . updateThermalLevel ( )
158+ // ── Thermal state monitoring (all platforms) ──────────────────────────
159+ observers. append (
160+ NotificationCenter . default. addObserver (
161+ forName: ProcessInfo . thermalStateDidChangeNotification,
162+ object: nil , queue: . main
163+ ) { [ weak self] _ in
164+ Task { @MainActor [ weak self] in
165+ self ? . updateThermalLevel ( )
166+ }
104167 }
105- }
168+ )
106169 updateThermalLevel ( )
107170 }
108171
0 commit comments