Files
OSGKeyboard/OSGKeyboardShared/Utilities/KeyboardExtensionMemoryTelemetry.swift
rocky a2abfc5e8c fix: keyboard stability, host-only analytics, and auto-reply dedupe
- record analytics/usage only in the host app; the extension no longer opens the
  SQLite queues, so hiding the keyboard cannot hold WAL locks (0xdead10cc)
- sync clipboard history writes across processes so deletes made on one side
  are not reverted by the other
- guard memory-relief reentrancy on controller and telemetry sides to break
  the recursive crash loop
- resolve credentials off the main thread and migrate legacy keys once per
  install, so Keychain reads cannot be caught in the suspend window
- gate Volcengine ASR ping completion to once (SIGTRAP on double resume)
- treat same-text captures as the same logical copy and dedupe auto actions
  by content fingerprint within 30s, stopping Universal Clipboard storms
- realign version line to 2.1.0 (build 110)
2026-09-25 12:14:29 +08:00

350 lines
14 KiB
Swift

// KeyboardExtensionMemoryTelemetry.swift
// OSGKeyboard · Shared
//
// Observes keyboard-extension memory AND sheds load before the system does.
// The host process never starts this monitor, so shared typing code can emit
// extension-only milestones without duplicating host telemetry.
//
// Observation alone was not enough: `didReceiveMemoryWarning` is the only
// system signal an extension gets, and jetsam frequently kills a keyboard at
// the ~60 MiB boundary without ever delivering one. `reliefHandler` lets the
// extension drop its heavy caches on OUR thresholds (40 / 48 MiB), which are
// deliberately below that boundary.
import Darwin
import Foundation
public enum KeyboardExtensionMemoryBudget {
/// Start preserving evidence before the extension reaches its safe ceiling.
public static let warningMB: Double = 36
/// Internal release target. Apple's keyboard-extension limit is not public.
public static let safePeakMB: Double = 40
/// Leave headroom below the observed ~60 MiB device jetsam boundary.
public static let criticalMB: Double = 48
public enum Level: String, Sendable, Equatable {
case normal
case warning
case high
case critical
case unavailable
}
public static func level(forPhysFootprintMB footprintMB: Double) -> Level {
guard footprintMB >= 0 else { return .unavailable }
if footprintMB >= criticalMB { return .critical }
if footprintMB >= safePeakMB { return .high }
if footprintMB >= warningMB { return .warning }
return .normal
}
}
@MainActor
public enum KeyboardExtensionMemoryTelemetry {
private static let peakLogStepMB: Double = 4
private static let bootSampleInterval = Duration.milliseconds(50)
private static let bootSampleDuration: TimeInterval = 4
private static var isActive = false
private static var processID: Int32 = 0
private static var context = "surface=- language=-"
private static var baselineFootprintMB: Double = -1
private static var peakFootprintMB: Double = -1
private static var lastLoggedPeakMB: Double = -1
private static var highestLevel = KeyboardExtensionMemoryBudget.Level.normal
private static var startedAt: TimeInterval = 0
private static var samplingTask: Task<Void, Never>?
/// Highest level already relieved in the current pressure episode. Reset only
/// when the footprint falls back below `warningMB` — see `requestReliefIfNeeded`.
private static var lastReliefLevel = KeyboardExtensionMemoryBudget.Level.normal
/// True while `reliefHandler` is running, so relief can never be requested
/// from inside relief — see `requestReliefIfNeeded`.
private static var isRelieving = false
/// Poll interval once the startup burst is over. Sustained sampling is what
/// catches growth *between* milestones (a long clipboard session, a big
/// candidate list) — milestone-only sampling misses it entirely.
private static let sustainedSampleInterval = Duration.seconds(1)
/// Invoked on the main actor when the footprint crosses `.high` or
/// `.critical`. The keyboard extension installs this to release the Rime
/// engine, the English lexicon and any in-flight pipeline work. Nothing
/// else in the process is allowed to set it.
///
/// Returns whether relief was actually performed. `false` means the host
/// declined *this* attempt (it is mid-composition and will not yank the
/// typing surface out from under the user), and the level stays armed so the
/// next poll asks again — a declined attempt must not be mistaken for a
/// completed one, or the keyboard would sit at 48 MiB having shed nothing.
public static var reliefHandler: (@MainActor @Sendable (KeyboardExtensionMemoryBudget.Level) -> Bool)?
public static func begin(context initialContext: String) {
samplingTask?.cancel()
samplingTask = nil
lastReliefLevel = .normal
isActive = true
processID = getpid()
context = initialContext
startedAt = ProcessInfo.processInfo.systemUptime
let snapshot = OSGDiag.memorySnapshot()
baselineFootprintMB = snapshot.physFootprintMB
peakFootprintMB = snapshot.physFootprintMB
lastLoggedPeakMB = snapshot.physFootprintMB
highestLevel = .normal
emit(stage: "process.begin", snapshot: snapshot, alwaysLog: true)
}
public static func updateContext(_ newContext: String) {
guard isActive else { return }
context = newContext
}
public static func record(_ stage: String, details: String? = nil) {
guard isActive else { return }
let eventContext = details.map { "\(context) \($0)" } ?? context
emit(
stage: stage,
snapshot: OSGDiag.memorySnapshot(),
eventContext: eventContext,
alwaysLog: true
)
}
/// Samples short-lived startup spikes that milestone-only logging can miss,
/// then keeps polling at a low rate for the rest of the presentation so
/// pressure that builds up mid-session still reaches `reliefHandler`.
/// Poll samples log only on a new budget band or each additional 4 MiB peak.
public static func startBootSampling() {
guard isActive else { return }
samplingTask?.cancel()
let deadline = ProcessInfo.processInfo.systemUptime + bootSampleDuration
samplingTask = Task { @MainActor in
while !Task.isCancelled, ProcessInfo.processInfo.systemUptime < deadline {
try? await Task.sleep(for: bootSampleInterval)
guard !Task.isCancelled else { return }
emit(
stage: "boot.sample",
snapshot: OSGDiag.memorySnapshot(),
eventContext: context,
alwaysLog: false
)
}
guard !Task.isCancelled else { return }
record("boot.sample.complete")
await pollPressure()
}
}
/// Resumes low-rate sampling for a re-presented keyboard without replaying
/// the startup burst. No-ops while the boot burst is still running.
public static func startSustainedSamplingIfIdle() {
guard isActive, samplingTask == nil else { return }
samplingTask = Task { @MainActor in
await pollPressure()
}
}
/// Stops sampling (keyboard dismissed). The extension process survives
/// between presentations, so an uncancelled poll would keep waking a hidden
/// keyboard forever.
public static func stopSampling() {
samplingTask?.cancel()
samplingTask = nil
}
private static func pollPressure() async {
while !Task.isCancelled {
try? await Task.sleep(for: sustainedSampleInterval)
guard !Task.isCancelled else { return }
emit(
stage: "pressure.sample",
snapshot: OSGDiag.memorySnapshot(),
eventContext: context,
alwaysLog: false
)
}
}
private static func emit(
stage: String,
snapshot: OSGDiag.MemorySnapshot,
eventContext: String? = nil,
alwaysLog: Bool
) {
let footprint = snapshot.physFootprintMB
if footprint >= 0 {
peakFootprintMB = max(peakFootprintMB, footprint)
}
let level = KeyboardExtensionMemoryBudget.level(forPhysFootprintMB: footprint)
let crossedLevel = levelRank(level) > levelRank(highestLevel)
if crossedLevel {
highestLevel = level
}
#if DEBUG
MemoryDeviceProbe.log("emit.\(stage).\(level.rawValue)")
#endif
// Shed BEFORE logging: at `.critical` we are ~12 MiB from the observed
// jetsam boundary and the log line is the less important half.
requestReliefIfNeeded(level: level, stage: stage)
let peakAdvanced = peakFootprintMB >= 0
&& (lastLoggedPeakMB < 0 || peakFootprintMB - lastLoggedPeakMB >= peakLogStepMB)
guard alwaysLog || crossedLevel || peakAdvanced else { return }
if peakFootprintMB >= 0 {
lastLoggedPeakMB = peakFootprintMB
}
let elapsedMS = max(
0,
Int((ProcessInfo.processInfo.systemUptime - startedAt) * 1_000)
)
let delta = baselineFootprintMB >= 0 && peakFootprintMB >= 0
? peakFootprintMB - baselineFootprintMB
: -1
OSGDiag.log(
String(
format: "extMemory pid=%d stage=%@ elapsed=%dms level=%@ crossed=%d "
+ "rss=%.1fMB foot=%.1fMB peak=%.1fMB delta=%.1fMB "
+ "safe=%dMB critical=%dMB context={%@}",
processID,
stage,
elapsedMS,
level.rawValue,
crossedLevel ? 1 : 0,
snapshot.rssMB,
footprint,
peakFootprintMB,
delta,
Int(KeyboardExtensionMemoryBudget.safePeakMB),
Int(KeyboardExtensionMemoryBudget.criticalMB),
eventContext ?? context
),
category: "memory"
)
}
/// Asks the host to release heavy resources, at most once per level per
/// pressure episode. Escalation (`.high` → `.critical`) still fires: the
/// harder shed must not be swallowed by the soft one that preceded it.
private static func requestReliefIfNeeded(
level: KeyboardExtensionMemoryBudget.Level,
stage: String
) {
// Hysteresis, not a cooldown. A timed gap re-sheds every few seconds
// while the footprint sits above the threshold, which reads to the user
// as the keyboard repeatedly resetting itself. Re-arm only once pressure
// has genuinely receded — back under `warningMB`, a full band below the
// level that triggered the shed. `.warning` itself neither sheds nor
// re-arms: it is the band the keyboard lands in right after shedding.
if level == .normal {
lastReliefLevel = .normal
}
guard level == .high || level == .critical, let handler = reliefHandler else { return }
guard levelRank(level) > levelRank(lastReliefLevel) else { return }
// Shedding records a milestone at every teardown step, and recording
// samples the footprint again — which asks the same handler to shed
// from inside itself. `lastReliefLevel` cannot stop that, because it is
// only assigned once the handler returns. The nesting is unbounded and
// overflowed the main thread's stack instead of relieving anything.
guard !isRelieving else { return }
#if DEBUG
MemoryDeviceProbe.log("relief.fire.\(level.rawValue).\(stage)")
#endif
OSGDiag.log(
"extMemory relief level=\(level.rawValue) stage=\(stage) \(OSGDiag.memoryTag())",
category: "memory"
)
isRelieving = true
let relieved = handler(level)
isRelieving = false
#if DEBUG
MemoryDeviceProbe.log("relief.done.\(level.rawValue).relieved=\(relieved ? 1 : 0)")
#endif
guard relieved else {
OSGDiag.log(
"extMemory relief declined level=\(level.rawValue) stage=\(stage)",
category: "memory"
)
return
}
lastReliefLevel = level
}
#if DEBUG
/// Test seam: drives the threshold + hysteresis logic without needing the
/// test process's real footprint to cross 48 MiB.
static func simulateFootprintForTesting(_ footprintMB: Double, stage: String = "test") {
requestReliefIfNeeded(
level: KeyboardExtensionMemoryBudget.level(forPhysFootprintMB: footprintMB),
stage: stage
)
}
#endif
private static func levelRank(_ level: KeyboardExtensionMemoryBudget.Level) -> Int {
switch level {
case .unavailable:
return -1
case .normal:
return 0
case .warning:
return 1
case .high:
return 2
case .critical:
return 3
}
}
}
#if DEBUG
/// DEBUG-only device probe. `log stream --device` is unavailable on this Mac and
/// tool-hosted tests can't run on a physical device, so the extension's real
/// jetsam footprint can't be read from the console. Instead we append every
/// memory sample to an App Group file and pull it back to the Mac:
///
/// ```
/// xcrun devicectl device copy from --device <udid> \
/// --domain-type appGroupDataContainer \
/// --domain-identifier group.com.osgkeyboard.shared \
/// --source memory-probe.log --destination /tmp/
/// ```
///
/// Each line: `<uptime>s <tag> foot=<MB> rss=<MB>`. Temporary — remove once the
/// footprint budget is re-baselined.
public enum MemoryDeviceProbe {
public static let fileName = "memory-probe.log"
/// devicectl can only pull from the container's Library/Documents/tmp, so
/// the probe lives under Library rather than the container root.
private static var fileURL: URL? {
guard let base = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: AppGroup.identifier)?
.appendingPathComponent("Library", isDirectory: true)
else { return nil }
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
return base.appendingPathComponent(fileName, isDirectory: false)
}
/// Appends one sample. Cheap enough for a short DEBUG run; not for shipping.
public static func log(_ tag: String) {
guard let url = fileURL else { return }
let snap = OSGDiag.memorySnapshot()
let line = String(
format: "%.2f %@ foot=%.1f rss=%.1f\n",
ProcessInfo.processInfo.systemUptime,
tag,
snap.physFootprintMB,
snap.rssMB
)
guard let data = line.data(using: .utf8) else { return }
if let handle = try? FileHandle(forWritingTo: url) {
handle.seekToEndOfFile()
handle.write(data)
try? handle.close()
} else {
try? data.write(to: url, options: .atomic)
}
}
}
#endif