fix(flow): spread PiP recovery attempts across the budget after a media server reset
- Schedule up to four attempts at fixed offsets (0, 0.5, 1.5, 3 s) from the start of recovery instead of short gaps after each fast failure. - Wake the wait early when AVAudioSession reports mediaServicesWereReset. - Re-apply the playback category and reactivate the session instead of trusting cached state from before the reset.
This commit is contained in:
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
- **Keyboard killed after hiding on pinyin**: hiding the keyboard while the pinyin/English typing surface was showing kept librime's user dictionary open, and its LevelDB `LOCK` file in the shared App Group got the suspended extension killed as `0xdead10cc` (about 30 kills in 3 days on one test device). The typing engine is now closed on every hide and reopened on the next show, which adds about 30–50 ms only when a still-running keyboard is reopened. / **拼音界面收起后键盘被系统终止**:在拼音/英文打字界面收起键盘时,librime 用户词库仍保持打开,其位于共享 App Group 的 LevelDB `LOCK` 文件导致挂起的扩展被系统以 `0xdead10cc` 终止(一台测试机 3 天约 30 次)。现在每次收起都会关闭打字引擎、下次弹出时重新打开,仅在复用仍存活的键盘进程时多约 30–50 毫秒。
|
||||
- **Voice session failing after a system audio restart**: when iOS restarted its media service while OSG was in the background, starting the voice session on return used up all three tries in about 1 second, before the system had recovered (about 3 seconds), and showed “needs recovery”. Automatic tries are now spread across 5 seconds (immediately, then at 0.5, 1.5 and 3 seconds), retry at once when the system reports the media service is back, and reconfigure the audio session instead of trusting state from before the restart. Tapping Retry uses the same schedule. / **系统音频重启后语音会话启动失败**:OSG 在后台期间若 iOS 重启了媒体服务,回到前台时启动语音会话会在约 1 秒内用完全部 3 次尝试,而系统约 3 秒后才恢复,于是显示「后台会话需要恢复」。现在自动尝试分布在 5 秒内(立即、0.5 秒、1.5 秒、3 秒),系统通知媒体服务已恢复时立即重试,并重新配置音频会话,不再沿用重启前的状态。手动点「重试」也按同样节奏进行。
|
||||
- **Misleading edit hint after insertion**: stop showing “Hold to edit your last input” for 10 seconds after every insertion. Holding the microphone asks AI; editing the last input uses the pencil button beside it. / **插入后的误导性编辑提示**:每次插入后不再显示 10 秒的「长按编辑上一条」。长按麦克风是向 AI 提问,编辑上一条请使用旁边的铅笔按钮。
|
||||
- **Saving invitation posters**: declare add-only Photos access in the built app and preflight the privacy description before calling PhotoKit, preventing Save Image from terminating the app when the generated project configuration is stale. / **保存邀请海报**:在 App 构建配置中声明“仅添加照片”权限,并在调用 PhotoKit 前检查权限说明,避免生成的工程配置过期时点击“保存图片”导致 App 被系统终止。
|
||||
- **Invitation redemption retrying forever**: an invitation the server permanently rejected (unknown code, self-referral, already bound, or binding window expired) stayed stored and was retried on every sign-in. These rejections now show a specific message and drop the stored code; network failures still retry. / **邀请兑换无限重试**:服务器明确拒绝的邀请(邀请码无效、邀请自己、已绑定或超出绑定期限)会一直保留并在每次登录时重试。现在这些情况会给出具体提示并清除暂存的邀请码;网络失败仍会重试。
|
||||
|
||||
@@ -99,7 +99,7 @@ struct FlowPiPLossReportGate: Equatable, Sendable {
|
||||
}
|
||||
|
||||
enum FlowPiPRecoveryPolicy {
|
||||
static let maxAttempts = 3
|
||||
static let maxAttempts = 4
|
||||
static let totalBudget: TimeInterval = 5
|
||||
|
||||
static func reconciliationDecision(
|
||||
@@ -125,11 +125,16 @@ enum FlowPiPRecoveryPolicy {
|
||||
&& !taskIsCancelled
|
||||
}
|
||||
|
||||
static func retryDelay(beforeAttempt attempt: Int) -> TimeInterval {
|
||||
/// Earliest start of each attempt, measured from the start of recovery.
|
||||
/// Audio activation failures return almost instantly, so gaps measured
|
||||
/// after each failure would spend every attempt within ~1 s, while a reset
|
||||
/// media server needs ~3 s before activation succeeds again.
|
||||
static func attemptOffset(beforeAttempt attempt: Int) -> TimeInterval {
|
||||
switch attempt {
|
||||
case 1: return 0
|
||||
case 2: return 0.25
|
||||
default: return 0.6
|
||||
case 2: return 0.5
|
||||
case 3: return 1.5
|
||||
default: return 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1167,6 +1172,20 @@ final class FlowSessionManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sleeps until the next scheduled PiP attempt, waking early once the
|
||||
/// system media server finishes a reset so the retry lands right away.
|
||||
private func waitForNextPiPAttempt(seconds: TimeInterval) async {
|
||||
let wakeAt = Date().addingTimeInterval(seconds)
|
||||
let resetCount = FlowAudioSessionCoordinator.shared.mediaServicesResetCount
|
||||
while Date() < wakeAt, !Task.isCancelled {
|
||||
if FlowAudioSessionCoordinator.shared.mediaServicesResetCount != resetCount {
|
||||
traceState("pipRecovery.wokeOnMediaServicesReset")
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
private func startSessionAsync(
|
||||
duration: TimeInterval?,
|
||||
trigger: FlowPiPActivationTrigger,
|
||||
@@ -1207,7 +1226,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
let deadline = Date().addingTimeInterval(FlowPiPRecoveryPolicy.totalBudget)
|
||||
let attemptsStartedAt = Date()
|
||||
let deadline = attemptsStartedAt.addingTimeInterval(FlowPiPRecoveryPolicy.totalBudget)
|
||||
var lastFailure: FlowPiPStartFailure = .timedOut
|
||||
var attemptsPerformed = 0
|
||||
|
||||
@@ -1234,11 +1254,10 @@ final class FlowSessionManager: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
let delay = FlowPiPRecoveryPolicy.retryDelay(beforeAttempt: attempt)
|
||||
if delay > 0 {
|
||||
try? await Task.sleep(
|
||||
nanoseconds: UInt64(delay * 1_000_000_000)
|
||||
)
|
||||
let wait = FlowPiPRecoveryPolicy.attemptOffset(beforeAttempt: attempt)
|
||||
- Date().timeIntervalSince(attemptsStartedAt)
|
||||
if wait > 0 {
|
||||
await waitForNextPiPAttempt(seconds: wait)
|
||||
}
|
||||
guard FlowPiPRecoveryPolicy.canContinue(
|
||||
operation: operation,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import os
|
||||
#if canImport(OSGKeyboardShared)
|
||||
import OSGKeyboardShared
|
||||
#endif
|
||||
@@ -67,8 +68,24 @@ public final class FlowAudioSessionCoordinator: @unchecked Sendable {
|
||||
)
|
||||
private var mode: Mode = .inactive
|
||||
private var active = false
|
||||
private let mediaServicesResetCounter = OSAllocatedUnfairLock(initialState: UInt64(0))
|
||||
|
||||
private init() {}
|
||||
/// Increments every time the system media server finishes a reset, so a
|
||||
/// caller backing off between activation attempts can retry immediately.
|
||||
public var mediaServicesResetCount: UInt64 {
|
||||
mediaServicesResetCounter.withLock { $0 }
|
||||
}
|
||||
|
||||
private init() {
|
||||
// Process-lifetime singleton, so the observer is never removed.
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: AVAudioSession.mediaServicesWereResetNotification,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { [mediaServicesResetCounter] _ in
|
||||
mediaServicesResetCounter.withLock { $0 &+= 1 }
|
||||
}
|
||||
}
|
||||
|
||||
public func activateCapture() async throws -> FlowAudioSessionSnapshot {
|
||||
let activation: CaptureActivation = try await perform {
|
||||
@@ -127,20 +144,22 @@ public final class FlowAudioSessionCoordinator: @unchecked Sendable {
|
||||
// tap are stopped separately, so the microphone is released,
|
||||
// while avoiding a category flip that eventually yields !pri
|
||||
// after repeated PiP/capture cycles.
|
||||
if self.active, self.mode == .capture {
|
||||
// A media server reset drops the category and activation
|
||||
// without telling this cache, so confirm against the live
|
||||
// session. Re-activating an active session is a no-op.
|
||||
if self.active, self.mode == .capture,
|
||||
self.session.category == .playAndRecord {
|
||||
return self.makeSnapshot()
|
||||
}
|
||||
if self.mode != .playback {
|
||||
if self.mode != .playback || self.session.category != .playback {
|
||||
try self.session.setCategory(
|
||||
.playback,
|
||||
mode: .moviePlayback,
|
||||
options: [.mixWithOthers]
|
||||
)
|
||||
}
|
||||
if !self.active {
|
||||
try self.session.setActive(true)
|
||||
self.active = true
|
||||
}
|
||||
try self.session.setActive(true)
|
||||
self.active = true
|
||||
self.mode = .playback
|
||||
return self.makeSnapshot()
|
||||
}
|
||||
|
||||
@@ -54,15 +54,15 @@ final class FlowPiPRecoveryPolicyTests: XCTestCase {
|
||||
|
||||
// MARK: - Bounded retry
|
||||
|
||||
func testRetryPolicyUsesThreeIncreasingBoundedAttempts() {
|
||||
XCTAssertEqual(FlowPiPRecoveryPolicy.maxAttempts, 3)
|
||||
XCTAssertEqual(
|
||||
(1...FlowPiPRecoveryPolicy.maxAttempts).map {
|
||||
FlowPiPRecoveryPolicy.retryDelay(beforeAttempt: $0)
|
||||
},
|
||||
[0, 0.25, 0.6]
|
||||
)
|
||||
func testRetryPolicySpreadsFourAttemptsAcrossTheBudget() {
|
||||
XCTAssertEqual(FlowPiPRecoveryPolicy.maxAttempts, 4)
|
||||
let offsets = (1...FlowPiPRecoveryPolicy.maxAttempts).map {
|
||||
FlowPiPRecoveryPolicy.attemptOffset(beforeAttempt: $0)
|
||||
}
|
||||
XCTAssertEqual(offsets, [0, 0.5, 1.5, 3])
|
||||
XCTAssertEqual(FlowPiPRecoveryPolicy.totalBudget, 5)
|
||||
// The last attempt must still leave time for PiP to become active.
|
||||
XCTAssertLessThan(offsets.last ?? 0, FlowPiPRecoveryPolicy.totalBudget - 1)
|
||||
}
|
||||
|
||||
func testRetryTimeoutsNeverExceedRemainingBudget() {
|
||||
|
||||
Reference in New Issue
Block a user