feat(app): add crash visibility, memory relief, and clipboard reply work
Squashes the working tree that had accumulated on cloud-agent. Two strands of work, kept in one commit at the maintainer's request. Crash and stability (this session): - Subscribe to MetricKit so crash, hang, CPU, and disk-write diagnostics are recorded on device. Keyboard-extension memory kills are the most likely way a user loses the keyboard mid-sentence and iOS does not classify them as crashes, so they never reach App Store Connect. - Upload those reports to POST /v1/analytics/diagnostics from internal builds only (local Debug and TestFlight), reusing the analytics installation identifier and skipping entirely when analytics are off. App Store builds keep everything on device. - Shed the Chinese engine, English lexicon, and in-flight pipeline work at the 40 MiB and 48 MiB budget thresholds instead of waiting for a system memory warning that jetsam often preempts. - Install audio taps with the input node's live format through one shared adaptive resampler, so an audio-route change mid-recording no longer raises an uncatchable format-mismatch exception. Covers the dictation preview and the Mac recorder, which still used the pattern the Flow capture path had already moved away from. - Harden the librime bridge: never pass a nil string across a nonnull boundary when a user dictionary holds malformed UTF-8, null-check the engine API vector, and distrust a candidate count without its array. - Guard the expanded candidate grid against a list that shrinks while the collection view is still laying out against the previous count. - Resolve localized text without the App Group so the screen shown when that container is missing renders its message instead of crashing on launch, which is the one situation it exists to handle. - Gate CI on the App Group and Keychain wiring that the keyboard extension hard-depends on, with a self-test proving the check fails. Clipboard, reply, and semantics (pre-existing working tree): - Unify the reply center, refresh keyboard AI features, and add native event and reminder exporters. - Expand clipboard semantic routing with authored corpora and a product-blind holdout, plus the scripts that build and validate them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
// KeyboardExtensionMemoryTelemetry.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Observes keyboard-extension memory without changing runtime behavior.
|
||||
// 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
|
||||
@@ -48,10 +54,30 @@ public enum KeyboardExtensionMemoryTelemetry {
|
||||
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
|
||||
|
||||
/// 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
|
||||
@@ -81,7 +107,9 @@ public enum KeyboardExtensionMemoryTelemetry {
|
||||
)
|
||||
}
|
||||
|
||||
/// Samples short-lived startup spikes that milestone-only logging can miss.
|
||||
/// 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 }
|
||||
@@ -98,8 +126,39 @@ public enum KeyboardExtensionMemoryTelemetry {
|
||||
alwaysLog: false
|
||||
)
|
||||
}
|
||||
samplingTask = nil
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +177,9 @@ public enum KeyboardExtensionMemoryTelemetry {
|
||||
if crossedLevel {
|
||||
highestLevel = level
|
||||
}
|
||||
// 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 }
|
||||
@@ -154,6 +216,49 @@ public enum KeyboardExtensionMemoryTelemetry {
|
||||
)
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
OSGDiag.log(
|
||||
"extMemory relief level=\(level.rawValue) stage=\(stage) \(OSGDiag.memoryTag())",
|
||||
category: "memory"
|
||||
)
|
||||
guard handler(level) 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:
|
||||
|
||||
Reference in New Issue
Block a user