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>
6 files changed,
+99
−5
docs/native-perf.md+2 −1ios/LChatCore/Sources/LChatCore/HangWatchdog.swift+6 −3ios/LChatCore/Sources/LChatCore/HitchRecorder.swift+55 −0ios/LChatCore/Sources/LChatCore/Perf.swift+15 −1ios/LChatCore/Tests/LChatCoreTests/PerfTests.swift+11 −0tools/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); |