todo-app/lib/frame_stats.dart
Krzysztof kuhy Rudnicki c740cac2f0 Add an env-guarded frame instrument for the desktop performance work
Keeps the diagnostic used to produce docs/desktop-performance-findings.md, so
the web proof-of-concept can be measured against the same numbers rather than a
freshly-written and differently-behaving probe.

Armed only by TODO_FRAME_STATS=1, so it is inert in normal use. It reports:

- individual slow frames, not per-second percentiles -- a user-visible hitch is
  one long frame, which averaging over a 61-frame window hides completely;
- event-loop blocking via a periodic timer, which is unambiguous where frame
  gaps are not (a static UI produces no frames, so an idle user and a stalled
  isolate look identical);
- every frame when TODO_FRAME_STATS_ALL=1, for raster-cost-vs-window-size
  sweeps.

The capture screen gets a spinner under the same flag because cadence can only
be sampled while something animates continuously. Its branch is unreachable
under the test runner, hence coverage:ignore -- the suite stays at 100%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 12:48:50 +02:00

85 lines
3.0 KiB
Dart

// coverage:ignore-file
// TEMPORARY diagnostic instrument (desktop interaction-lag investigation).
// Guarded by TODO_FRAME_STATS=1 so it is inert in normal use.
//
// v3 adds an event-loop blocking monitor, which is the decisive instrument.
// Frame gaps are ambiguous in a mostly-static UI: no frame is produced when
// nothing needs drawing, so an idle user looks identical to a stalled isolate.
// A periodic timer, by contrast, is scheduled regardless of drawing -- if it
// fires late, the UI isolate was genuinely blocked, and by exactly how long.
import 'dart:async';
import 'dart:io';
import 'package:flutter/scheduler.dart';
/// Whether the frame-stats instrument is armed for this process.
bool get frameStatsEnabled => Platform.environment['TODO_FRAME_STATS'] == '1';
/// A frame whose own work exceeded this is a hitch worth naming. Set to zero
/// by TODO_FRAME_STATS_ALL=1 so every frame is logged, which is what the
/// raster-cost-vs-window-size sweep needs.
int get _slowFrameUs =>
Platform.environment['TODO_FRAME_STATS_ALL'] == '1' ? -1 : 8000;
/// How often the event-loop probe is scheduled.
const _probePeriod = Duration(milliseconds: 16);
/// Lateness beyond this means the isolate was blocked long enough to be felt.
const _blockedMs = 24;
final Stopwatch _clock = Stopwatch();
Timer? _probe;
String get _at => (_clock.elapsedMilliseconds / 1000).toStringAsFixed(2);
/// Installs the frame-timing and event-loop instruments.
void installFrameStats() {
if (!frameStatsEnabled) return;
_clock.start();
stdout.writeln(
'[frame-stats v3] armed; logging slow frames and UI-isolate blocking',
);
_installFrameTimings();
_installEventLoopProbe();
}
void _installFrameTimings() {
SchedulerBinding.instance.addTimingsCallback((timings) {
for (final t in timings) {
final build = t.buildDuration.inMicroseconds;
final raster = t.rasterDuration.inMicroseconds;
if (build + raster > _slowFrameUs) {
stdout.writeln(
'[FRAME $_at s] total=${((build + raster) / 1000)
.toStringAsFixed(1)}ms '
'(build=${(build / 1000).toStringAsFixed(1)} '
'raster=${(raster / 1000).toStringAsFixed(1)})',
);
}
}
});
}
/// Schedules a timer every [_probePeriod] and reports how late it actually
/// fired. Lateness is time the UI isolate spent unable to run anything -- e.g.
/// a synchronous JSON serialize of the whole note log.
void _installEventLoopProbe() {
var expected = _clock.elapsedMicroseconds + _probePeriod.inMicroseconds;
void tick(Timer _) {
final now = _clock.elapsedMicroseconds;
final lateMs = (now - expected) / 1000;
if (lateMs > _blockedMs) {
stdout.writeln(
'[UI-BLOCKED $_at s] event loop stalled '
'${lateMs.toStringAsFixed(1)}ms',
);
}
expected = now + _probePeriod.inMicroseconds;
}
_probe = Timer.periodic(_probePeriod, tick);
}
/// Stops the probe. Only needed if the instrument outlives the app.
void disposeFrameStats() => _probe?.cancel();