eleven / messenger Public

native: catch the felt-jank band — 250ms watchdog + frame-hitch recorder (iOS) (#1204)

Day one of field data: Paul felt hangs tapping around, the ledger recorded
none — they live below the 400ms watchdog floor. Drop the floor to 250ms
(Apple's responsiveness bar) and add HitchRecorder: a display link that
records rendered-frame gaps ≥120ms as "hitch" ledger events (iOS only —
no CADisplayLink on Mac, where the watchdog covers). Hitches surface in
the Settings summary and the report digester.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell cf6e27f986c38423f2499c12c7442f0f379a52b7 parent 54ea0c2
6 files changed, +99 −5
  • docs/native-perf.md +2 −1
  • ios/LChatCore/Sources/LChatCore/HangWatchdog.swift +6 −3
  • ios/LChatCore/Sources/LChatCore/HitchRecorder.swift +55 −0
  • ios/LChatCore/Sources/LChatCore/Perf.swift +15 −1
  • ios/LChatCore/Tests/LChatCoreTests/PerfTests.swift +11 −0
  • tools/ios-perf-report.mjs +10 −0
diff --git a/docs/native-perf.md b/docs/native-perf.md
index e464e26..7325807 100644
--- a/docs/native-perf.md
+++ b/docs/native-perf.md
@@ -54,7 +54,8 @@ the stack is Apple-native + self-owned end to end.
| `api.get` / `api.post` | LChatAPI choke points (detail = path, never query/token) | 1000ms |
| `socket.connect` | ChatSocket connect → handshake (failed attempts unrecorded) | 3000ms |
| `image.load` | BlobImageLoader fetch+decrypt+decode | 1000ms |
-| `main.hang` | HangWatchdog (>400ms main-thread wedge) | — |
+| `main.hang` | HangWatchdog (>250ms main-thread wedge — Apple's responsiveness bar) | — |
+| `frame.hitch` | HitchRecorder (iOS only: rendered-frame gap ≥120ms; the felt-jank band below the watchdog — the Mac has no CADisplayLink and keeps just the watchdog) | — |
| `socket.reconnect` | note event, 30s same-name floor | — |
Adding one is two lines (`Perf.begin`/`end`) — budgets are per-call-site, so
diff --git a/ios/LChatCore/Sources/LChatCore/HangWatchdog.swift b/ios/LChatCore/Sources/LChatCore/HangWatchdog.swift
index 02820f1..ad9afb4 100644
--- a/ios/LChatCore/Sources/LChatCore/HangWatchdog.swift
+++ b/ios/LChatCore/Sources/LChatCore/HangWatchdog.swift
@@ -5,8 +5,11 @@ import Foundation
// timer pings the main queue every 200ms; any ping serviced late means main
// was wedged, and the lag ≈ how long. Cheap enough to run for a whole
// session: one async block per 200ms, no stack sampling (MetricKit's hang
-// diagnostics carry the call trees; this catches the sub-hang stutters —
-// 400ms–2s — that MetricKit's threshold ignores but fingers feel).
+// diagnostics carry the call trees; this catches the sub-hang stutters
+// that MetricKit's threshold ignores but fingers feel). The 250ms floor is
+// Apple's own responsiveness bar — the first field day showed felt hangs
+// living below the original 400ms floor, invisible; HitchRecorder covers
+// the band below this.
final class HangWatchdog {
private var ticker: DispatchSourceTimer?
// A long hang queues several pings that all drain in one burst; the first
@@ -24,7 +27,7 @@ final class HangWatchdog {
guard let self else { return }
let now = DispatchTime.now()
let lagMs = Double(now.uptimeNanoseconds &- sent.uptimeNanoseconds) / 1e6
- guard lagMs > 400, now > self.suppressUntil else { return }
+ guard lagMs > 250, now > self.suppressUntil else { return }
self.suppressUntil = now + .seconds(1)
Perf.recordHang(ms: lagMs)
}
diff --git a/ios/LChatCore/Sources/LChatCore/HitchRecorder.swift b/ios/LChatCore/Sources/LChatCore/HitchRecorder.swift
new file mode 100644
index 0000000..333dedd
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/HitchRecorder.swift
@@ -0,0 +1,55 @@
+import Foundation
+#if os(iOS) && canImport(UIKit)
+import UIKit
+
+// 🤖 Frame-hitch capture (iOS): the layer between "smooth" and a watchdog
+// hang. The first day of field data showed hangs people FEEL that never
+// trip the 250ms watchdog — scroll-storm frame drops in the 100–250ms band
+// (the same class HitchLogger was built to chase). A display link ticks
+// once per rendered frame; a gap ≥120ms is a properly dropped bunch of
+// frames and records as a "hitch" ledger event. Started/stopped with
+// recording (Perf.setRecording), so Release builds without consent never
+// run the link. Mac has no CADisplayLink — the watchdog covers it there
+// (deliberately iOS-only).
+final class HitchRecorder {
+ private var link: CADisplayLink?
+ private var last: CFTimeInterval = 0
+ private var observers: [NSObjectProtocol] = []
+
+ func start() {
+ guard link == nil else { return }
+ let l = CADisplayLink(target: self, selector: #selector(tick))
+ l.add(to: .main, forMode: .common)
+ link = l
+ // The link pauses in the background; the first tick after resume
+ // spans the whole suspension and would read as a monster hitch.
+ // Reset the baseline on every foreground transition instead.
+ let nc = NotificationCenter.default
+ observers.append(nc.addObserver(forName: UIApplication.willEnterForegroundNotification,
+ object: nil, queue: .main) { [weak self] _ in self?.last = 0 })
+ }
+
+ func stop() {
+ link?.invalidate()
+ link = nil
+ last = 0
+ observers.forEach { NotificationCenter.default.removeObserver($0) }
+ observers.removeAll()
+ }
+
+ @objc private func tick(_ l: CADisplayLink) {
+ defer { last = l.timestamp }
+ guard last > 0 else { return }
+ let gapMs = (l.timestamp - last) * 1000
+ // >5s can only be a suspension the notification didn't cover
+ // (scene detach, system UI) — never a real render gap.
+ guard gapMs >= 120, gapMs < 5000 else { return }
+ Perf.recordHitch(ms: gapMs)
+ }
+}
+#else
+final class HitchRecorder {
+ func start() {}
+ func stop() {}
+}
+#endif
diff --git a/ios/LChatCore/Sources/LChatCore/Perf.swift b/ios/LChatCore/Sources/LChatCore/Perf.swift
index e73daf9..6bb4302 100644
--- a/ios/LChatCore/Sources/LChatCore/Perf.swift
+++ b/ios/LChatCore/Sources/LChatCore/Perf.swift
@@ -48,6 +48,7 @@ public enum Perf {
private static var recording = false
private static var installedAt = DispatchTime.now()
private static var watchdog: HangWatchdog?
+ private static var hitches: HitchRecorder?
private static var metrics: MetricKitCollector?
static var isRecording: Bool {
@@ -77,10 +78,13 @@ public enum Perf {
if on {
if watchdog == nil { watchdog = HangWatchdog() }
watchdog?.start()
+ if hitches == nil { hitches = HitchRecorder() }
+ hitches?.start()
if metrics == nil { metrics = MetricKitCollector() }
metrics?.start()
} else {
watchdog?.stop()
+ hitches?.stop()
metrics?.stop()
}
}
@@ -157,6 +161,12 @@ public enum Perf {
record(kind: "hang", name: "main.hang", ms: ms, detail: nil)
}
+ /// The hitch recorder's report: a rendered-frame gap of ~ms (iOS).
+ static func recordHitch(ms: Double) {
+ poi.emitEvent("hitch")
+ record(kind: "hitch", name: "frame.hitch", ms: ms, detail: nil)
+ }
+
private static var firstFrameNoted = false
/// Called by the app when its first real screen is up — the honest
@@ -333,12 +343,16 @@ public struct PerfEvent: Codable, Equatable, Sendable {
/// last. Nil when there's nothing to say.
public static func summary(_ events: [PerfEvent]) -> String? {
let hangs = events.filter { $0.kind == "hang" }.count
+ let hitches = events.filter { $0.kind == "hitch" }.count
let slows = events.filter { $0.kind == "slow" }
- guard hangs > 0 || !slows.isEmpty else { return nil }
+ guard hangs > 0 || hitches > 0 || !slows.isEmpty else { return nil }
var parts: [String] = []
if hangs > 0 {
parts.append("\(hangs.formatted()) \(hangs == 1 ? "hang" : "hangs")")
}
+ if hitches > 0 {
+ parts.append("\(hitches.formatted()) \(hitches == 1 ? "hitch" : "hitches")")
+ }
if !slows.isEmpty {
parts.append("\(slows.count.formatted()) slow \(slows.count == 1 ? "moment" : "moments")")
}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/PerfTests.swift b/ios/LChatCore/Tests/LChatCoreTests/PerfTests.swift
index dff02d6..e58f0c2 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/PerfTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/PerfTests.swift
@@ -42,6 +42,17 @@ final class PerfTests: XCTestCase {
XCTAssertEqual(PerfEvent.summary(hangs), "1 hang")
}
+ func testSummaryIncludesHitches() {
+ let one = [PerfEvent(t: 0, kind: "hitch", name: "frame.hitch", ms: 180)]
+ XCTAssertEqual(PerfEvent.summary(one), "1 hitch")
+ let mixed = [
+ PerfEvent(t: 0, kind: "hang", name: "main.hang", ms: 600),
+ PerfEvent(t: 1, kind: "hitch", name: "frame.hitch", ms: 180),
+ PerfEvent(t: 2, kind: "hitch", name: "frame.hitch", ms: 250),
+ ]
+ XCTAssertEqual(PerfEvent.summary(mixed), "1 hang · 2 hitches")
+ }
+
func testCapDiagnosticsKeepsNewestUnderBudget() {
let a = String(repeating: "a", count: 100)
let b = String(repeating: "b", count: 100)
diff --git a/tools/ios-perf-report.mjs b/tools/ios-perf-report.mjs
index 059357e..441e307 100644
--- a/tools/ios-perf-report.mjs
+++ b/tools/ios-perf-report.mjs
@@ -99,6 +99,16 @@ if (hangs.length) {
console.log(` busiest: ${busy.map(([h, l]) => `${h}:00Z ×${fmt(l.length)}`).join(" · ")}\n`);
}
+const hitches = byKind.get("hitch") ?? [];
+if (hitches.length) {
+ const ms = hitches.map((e) => e.ms ?? 0).sort((a, b) => a - b);
+ const q = (p) => ms[Math.min(ms.length - 1, Math.floor((p / 100) * ms.length))];
+ console.log(`Frame hitches (≥120ms gaps): ${plural(hitches.length, "hitch", "hitches")} · p50 ${fmt(q(50))}ms · worst ${fmt(ms.at(-1))}ms`);
+ const byHour = Map.groupBy(hitches, (e) => new Date(e.t).toISOString().slice(0, 13));
+ const busy = [...byHour.entries()].sort((a, b) => b[1].length - a[1].length).slice(0, 3);
+ console.log(` busiest: ${busy.map(([h, l]) => `${h}:00Z ×${fmt(l.length)}`).join(" · ")}\n`);
+}
+
const boots = byKind.get("boot") ?? [];
if (boots.length) {
const ms = boots.map((e) => e.ms ?? 0).sort((a, b) => a - b);