The architecture loads. Weights are in memory. Generate still must not start, or continue, unless the scene is active. SwiftUI publishes that as scenePhase. The values you care about are .active, .inactive, and .background.
.inactive is already too late to keep submitting GPU work. Control Center, a lock, or an incoming call can put the scene there. Treat any phase that is not .active as a stop.
flowchart LR
P[scenePhase] --> A[.active]
P --> I[not active]
A --> G[Generate on Metal]
I --> S[Cancel generate]
Hold the generate Task in one place. Start it only from .active. On phase change, cancel it. Inside the generate loop, check cancellation so you stop requesting the next token. Confirm the exact generate callback type in MLXLMCommon for your pin. The idea does not change: stop the loop, then return.
import SwiftUI
import MLXLMCommon
@MainActor
final class InferenceController {
private var generateTask: Task<Void, Never>?
func startIfActive(phase: ScenePhase, prompt: String) {
guard phase == .active else { return }
cancel()
generateTask = Task { [weak self] in
await self?.runGenerate(prompt: prompt)
}
}
func handlePhase(_ phase: ScenePhase) {
if phase != .active {
cancel()
}
}
func cancel() {
generateTask?.cancel()
generateTask = nil
}
private func runGenerate(prompt: String) async {
// Sketch: call the MLXLMCommon generate API you confirmed
// in the docs for your pin. Check Task.isCancelled in the
// token callback and return the stop case.
if Task.isCancelled { return }
_ = prompt
}
}
@main
struct ProductionMLXApp: App {
@Environment(\.scenePhase) private var scenePhase
@State private var inference = InferenceController()
var body: some Scene {
WindowGroup {
ContentView(inference: inference)
}
.onChange(of: scenePhase) { _, phase in
inference.handlePhase(phase)
}
}
}
Resume policy
Do not auto-start Metal when the scene becomes active again. The user left. The GPU was taken. KV cache and the Task are gone or half finished. Show the partial text you already painted. Let the user tap again, or offer Resume only after scenePhase == .active. If you do resume, start a new generate from a saved prompt, not from a live Metal queue that survived the background. It did not.
flowchart LR
B[.background] --> C[Cancel Task]
C --> W[Wait]
W --> A[.active]
A --> U[User starts again]
Test this on a real device, not only in Simulator. Send a long prompt, press Home or lock while tokens stream, and confirm the Task cancels. If you still see Metal errors in the console, you cancelled too late or you started a second generate from .inactive.
Streaming the tokens you do keep is the same product idea as Stream tokens and measure speed. The harness idea is the same as The agent harness (and how to improve it): the model does not own the stop rule. Your control layer does.
scenePhase is not .active, cancel generate. Do not leave Metal work running.Next, a short production checklist.
Key concepts
scenePhaseis.active,.inactive, or.background.- Cancel generate when the phase is not
.active. Inactive already lost the GPU path. - Hold one generate
Task. Check cancellation inside the token loop. - Resume only in the foreground, from a saved prompt, after a user action.
Takeaways
- Never leave Metal work running when the scene is inactive or backgrounded.
- Do not auto-resume generate on the way back to
.active. - Prove the gate on a real device with Home or lock during a long run.