fix(keyboard): stabilize Flow startup under memory pressure

Delay competing Rime work, add extension memory telemetry and stress coverage, and refresh the 1.8 release assets and metadata for build 79.
This commit is contained in:
Rocky
2026-08-18 10:24:02 +08:00
parent aa1059135f
commit 0f9280bd00
27 changed files with 809 additions and 105 deletions
+2
View File
@@ -41,6 +41,8 @@ struct OSGKeyboardApp: App {
AIClipboardSkillLayoutDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--assistant-ui-test") {
AssistantKeyboardUITestHarness()
} else if ProcessInfo.processInfo.arguments.contains("--pip-device-ui-test") {
FlowPiPDeviceUITestHarness()
} else if ProcessInfo.processInfo.arguments.contains("--clipboard-demo") {
ClipboardHistoryDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--edit-pager-ui-test") {
@@ -126,7 +126,8 @@ final class FlowSessionManager: ObservableObject {
private var isColdStartHandoff = false
private var coldStartRecoveryTask: Task<Void, Never>?
var shouldDeferHostHeavyWork: Bool {
startingUtteranceId != nil
isStarting
|| startingUtteranceId != nil
|| isUtteranceRecording
|| isUtteranceProcessing
|| hasUnacknowledgedTerminalResult()
@@ -922,6 +923,11 @@ final class FlowSessionManager: ObservableObject {
coldStartRecoveryTask?.cancel()
coldStartRecoveryTask = Task { @MainActor [weak self] in
guard let self else { return }
// The failed controller has been fully torn down. Give the app
// scene and Pegasus service a short settling interval before the
// one bounded cold-start retry creates a new controller generation.
try? await Task.sleep(nanoseconds: 400_000_000)
guard !Task.isCancelled, self.isColdStartHandoff else { return }
let outcome = await self.pipController.startAndWait()
self.traceState("coldStartRecovery.pip", extra: "outcome=\(outcome)")
guard !Task.isCancelled, self.isColdStartHandoff else { return }
@@ -1,19 +1,26 @@
// AIClipboardSkillLayoutDemoView.swift
// OSGKeyboard · Main App (DEBUG-only)
//
// Interactive layout preview of AI Agent clipboard skills on the real
// `AIKeyboardView`. Launch with `--ai-skills-demo` and optional
// `--skills-count=N` (1...8).
// Interactive layout preview and What's New recording host for AI Agent
// clipboard skills on the real unified `AIKeyboardView`. Launch with
// `--ai-skills-demo`, optional `--skills-count=N` (1...8), and optional
// `--whats-new-lang=zh|en`.
#if DEBUG
import SwiftUI
import OSGKeyboardShared
struct AIClipboardSkillLayoutDemoView: View {
@StateObject private var config = ProviderConfig.shared
@StateObject private var state = KeyboardState()
@StateObject private var typing = TypingSessionController()
@State private var count: Int = Self.initialCount
init() {
AIKeyboardView.debugSkipsLongPressCoach = true
AIKeyboardView.debugKeepsSkillTip = true
}
var body: some View {
VStack(spacing: 0) {
controls
@@ -26,10 +33,15 @@ struct AIClipboardSkillLayoutDemoView: View {
.background(Palette.light.background)
}
.background(Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea())
.environment(\.locale, Locale(identifier: "zh-Hans"))
.environment(\.locale, language == .en ? Locale(identifier: "en") : Locale(identifier: "zh-Hans"))
.preferredColorScheme(.light)
.onAppear { apply(count) }
.onChange(of: count) { _, newValue in apply(newValue) }
.task { await runTimeline() }
.onChange(of: count) { _, newValue in showSkills(newValue) }
.onDisappear {
AIKeyboardView.debugPreviewSkills = nil
AIKeyboardView.debugSkipsLongPressCoach = false
AIKeyboardView.debugKeepsSkillTip = false
}
}
private var controls: some View {
@@ -70,19 +82,53 @@ struct AIClipboardSkillLayoutDemoView: View {
.padding(.bottom, 16)
}
private func apply(_ count: Int) {
let clamped = min(max(count, 1), 8)
AIKeyboardView.debugPreviewSkills = Self.previewSkills(count: clamped)
state.surface = .ai
private func prepareState() {
config.uiLanguage = language == .en ? .english : .chinese
AIKeyboardView.debugPreviewSkills = nil
state.surface = .voice
state.aiServiceAvailable = true
state.micDisabled = false
state.layoutWidth = 390
state.usesIPadLayoutMetrics = false
state.clipboardHistoryEnabled = true
state.undoAvailable = true
state.editAvailable = true
state.clipboardSuggestionText = language == .en
? "Meeting at 3pm tomorrow"
: "明天下午三点开会"
state.skillTipText = language == .en
? "Copied text is ready"
: "复制内容已就绪"
state.aiSession.enter()
}
private func showSkills(_ count: Int) {
let clamped = min(max(count, 1), 8)
AIKeyboardView.debugPreviewSkills = Self.previewSkills(count: clamped)
state.enabledClipboardSkillIDs = Array(
AIClipboardSkillCatalog.catalog.map(\.id).prefix(clamped)
)
state.aiSession.enter()
}
private func runTimeline() async {
prepareState()
try? await Task.sleep(for: .seconds(3))
state.clipboardSuggestionText = nil
state.skillTipText = nil
showSkills(count)
try? await Task.sleep(for: .seconds(8))
}
private var language: WhatsNewDemoScenario.Language {
let prefix = "--whats-new-lang="
guard let argument = ProcessInfo.processInfo.arguments.first(
where: { $0.hasPrefix(prefix) }
) else {
return .zh
}
return WhatsNewDemoScenario.Language(
rawValue: String(argument.dropFirst(prefix.count))
) ?? .zh
}
private static var initialCount: Int {
+45 -15
View File
@@ -1,9 +1,10 @@
// AIKeyboardDemoView.swift
// OSGKeyboard · Main App (DEBUG-only)
//
// What's New 1.7.0 recording host. Uses the **real** AI Agent settings page
// and the **real** `AIKeyboardView` (compiled into the app target) driven by a
// scripted `KeyboardState` no ASR / LLM. Launch with `--ai-demo`.
// What's New recording host. Uses the **real** AI Agent settings page and the
// **real** unified `AIKeyboardView` (compiled into the app target), driven by a
// scripted `KeyboardState` no ASR / LLM. Launch with `--ai-demo` and optional
// `--whats-new-lang=zh|en`.
#if DEBUG
import SwiftUI
@@ -15,10 +16,6 @@ struct AIKeyboardDemoView: View {
case keyboard
}
private static let question = "周末去哪儿玩比较合适?"
private static let answer =
"可以去近郊走走:上午逛古镇或公园,下午找一家口碑好的咖啡馆休息,傍晚再吃顿当地特色菜。若想轻松一点,选人少的湖边步道也很合适。"
@StateObject private var config = ProviderConfig.shared
@StateObject private var state = KeyboardState()
@StateObject private var typing = TypingSessionController()
@@ -26,6 +23,10 @@ struct AIKeyboardDemoView: View {
@State private var scene: Scene = .settings
@State private var levelTick = 0.35
init() {
AIKeyboardView.debugSkipsLongPressCoach = true
}
var body: some View {
ZStack {
Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea()
@@ -58,15 +59,18 @@ struct AIKeyboardDemoView: View {
}
}
}
.environment(\.locale, Locale(identifier: "zh-Hans"))
.environment(\.locale, language == .en ? Locale(identifier: "en") : Locale(identifier: "zh-Hans"))
.task { await runTimeline() }
.onDisappear {
AIKeyboardView.debugSkipsLongPressCoach = false
}
}
// MARK: - Scripted timeline (real view models)
private func runTimeline() async {
prepareKeyboardState()
config.uiLanguage = .chinese
config.uiLanguage = language == .en ? .english : .chinese
config.aiResponseLength = .medium
try? await sleep(2.4)
@@ -86,16 +90,16 @@ struct AIKeyboardDemoView: View {
try? await sleep(0.2)
levelTick = Double.random(in: 0.25...0.9)
state.level = levelTick
state.aiSession.updateTranscript(Self.question, utteranceID: utteranceID)
state.aiSession.updateTranscript(question, utteranceID: utteranceID)
}
state.aiSession.beginRecognizing(utteranceID: utteranceID)
try? await sleep(0.55)
state.aiSession.beginGenerating(question: Self.question, utteranceID: utteranceID)
state.aiSession.beginGenerating(question: question, utteranceID: utteranceID)
try? await sleep(0.7)
// Progressive draft so the real answer area updates like production.
let chars = Array(Self.answer)
let chars = Array(answer)
var index = 0
let step = 4
while index < chars.count {
@@ -106,8 +110,10 @@ struct AIKeyboardDemoView: View {
)
try? await sleep(0.05)
}
state.aiSession.receiveAnswer(Self.answer, utteranceID: utteranceID)
try? await sleep(1.0)
state.aiSession.receiveAnswer(answer, utteranceID: utteranceID)
// Hold the explicit-insert review state long enough for screen capture
// and for viewers to read the answer before the demo advances.
try? await sleep(3.0)
state.aiSession.markAnswerInserted(offersSend: true)
try? await sleep(1.1)
@@ -116,7 +122,7 @@ struct AIKeyboardDemoView: View {
}
private func prepareKeyboardState() {
state.surface = .ai
state.surface = .voice
state.aiServiceAvailable = true
state.micDisabled = false
state.layoutWidth = 390
@@ -125,6 +131,30 @@ struct AIKeyboardDemoView: View {
state.aiSession.enter()
}
private var language: WhatsNewDemoScenario.Language {
let prefix = "--whats-new-lang="
guard let argument = ProcessInfo.processInfo.arguments.first(
where: { $0.hasPrefix(prefix) }
) else {
return .zh
}
return WhatsNewDemoScenario.Language(
rawValue: String(argument.dropFirst(prefix.count))
) ?? .zh
}
private var question: String {
language == .en
? "Where should I go this weekend?"
: "周末去哪儿玩比较合适?"
}
private var answer: String {
language == .en
? "Try a nearby town day trip: walk through a park or old street, stop at a café, then have a local dinner."
: "可以去近郊走走:上午逛古镇或公园,下午找一家口碑好的咖啡馆休息,傍晚再吃顿当地特色菜。"
}
/// Slow-mo for screenshot-sequence recording.
private func sleep(_ seconds: Double) async throws {
try await Task.sleep(nanoseconds: UInt64(seconds * 2.4 * 1_000_000_000))
@@ -191,4 +191,54 @@ struct AssistantKeyboardUITestHarness: View {
state.showsSystemGlobeKey = isIPad
}
}
/// Physical-device harness that gives XCUITest a real user tap for PiP.
///
/// `devicectl process launch` is not a user interaction, so iOS may silently
/// ignore a programmatic foreground PiP request even when AVKit reports it as
/// possible. This harness isolates the production controller behind one tap.
struct FlowPiPDeviceUITestHarness: View {
@Environment(\.scenePhase) private var scenePhase
@StateObject private var flowManager = FlowSessionManager()
var body: some View {
VStack(spacing: 20) {
Button("Start PiP") {
flowManager.startSession(reason: "uiTest.userTap")
}
.accessibilityIdentifier("pip.start")
.disabled(flowManager.isStarting || flowManager.isActive)
Text(statusIdentifier)
.accessibilityIdentifier(statusIdentifier)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background {
FlowPiPHostView { view in
flowManager.attachPiPHostView(view)
}
.frame(width: 64, height: 36)
.opacity(0.02)
}
.onAppear {
flowManager.setAppForeground(scenePhase == .active)
}
.onChange(of: scenePhase) { _, phase in
flowManager.handleScenePhase(phase)
}
}
private var statusIdentifier: String {
if flowManager.isActive, FlowSessionBridge.isHostReady() {
return "pip.status.ready"
}
if flowManager.sessionWarning != nil {
return "pip.status.failed"
}
if flowManager.isStarting {
return "pip.status.starting"
}
return "pip.status.idle"
}
}
#endif
+13 -3
View File
@@ -137,8 +137,8 @@ struct MainAppRoot: View {
releaseNotes.presentIfNeeded(onboardingCompleted: true)
}
/// Rime remains startup-owned, but a short delay keeps its CPU and file I/O
/// away from SwiftUI's first-frame layout on installs and version updates.
/// Rime remains startup-owned, but PiP gets exclusive use of the launch
/// critical path before deployment claims CPU, file I/O and memory.
private func scheduleRimeDeployment(reason: String) {
rimeStartupTask?.cancel()
guard !RimeResourceInstaller.isReady else {
@@ -148,12 +148,22 @@ struct MainAppRoot: View {
}
OSGDiag.log(
"rime startup scheduled reason=\(reason) delay=500ms \(OSGDiag.memoryTag())",
"rime startup scheduled reason=\(reason) afterFlowStart \(OSGDiag.memoryTag())",
category: "flow"
)
rimeStartupTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 500_000_000)
guard !Task.isCancelled, scenePhase == .active else { return }
// A normal cold PiP start settles in under one second. Keep a
// bounded ceiling so an unavailable PiP never blocks typing
// resource installation for the rest of the foreground session.
let flowDeadline = Date().addingTimeInterval(12)
while flowManager.shouldDeferHostHeavyWork, Date() < flowDeadline {
guard !Task.isCancelled, scenePhase == .active else { return }
try? await Task.sleep(nanoseconds: 100_000_000)
}
guard !Task.isCancelled, scenePhase == .active else { return }
RimeDeploymentController.shared.deployNow(reason: reason)
rimeStartupTask = nil
}