todo-app/docs/DOCS-desktop-performance-interaction-lag.md
Krzysztof kuhy Rudnicki 59ee407d80
Some checks failed
file-length / file length <= 250 lines (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Adopt the four markdown namespaces and gate them
Renames this repo's markdown onto README.md / CLAUDE*.md / DOCS*.md /
TODO*.md so a future session can tell a live task from a finished
record without reading the file to find out.

TODO files carry the literal "REMOVE ME AFTER FINISH" and are deleted
when the work lands. Enforced by the md-naming pre-commit hook and CI
workflow, both delegating to the shared gate in kuhyx/utils so this
repo's idea of a valid name cannot drift from every other repo's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
2026-08-24 15:54:35 +02:00

217 lines
10 KiB
Markdown

# Linux desktop performance, part 2: interaction lag
The second half of the investigation started in
[desktop-performance-findings.md](desktop-performance-findings.md), which
covers frame rate. Split only to keep each file under the 250-line cap;
the author signposted the two parts explicitly.
## Part 2: the interaction lag is a separate, larger problem
The 60 Hz cap above is real but is **not** what the user is complaining about.
Driven by the user directly (synthetic input having proved unreliable), with an
instrument that logs individual outliers plus event-loop blocking rather than
per-second percentiles:
- Stalls are **raster-dominated**. Build (Dart) stays at 0.1-0.9 ms throughout;
single frames reach 76 ms of raster at a 1280x1417 window.
- The UI isolate does block occasionally (65-101 ms at startup and on some
interactions), but this is the minority of the problem.
- The user reports the lag is **constant, on every interaction** — which rules
out both GitHub sync (periodic) and shader compilation (one-off, warms up).
- Impeller/Vulkan halved slow frames (18 → 7) and removed the worst 76 ms
spike, but the user perceived **no difference**.
**Raster cost scales hard with window pixel count**, and the user runs the app
fullscreen on the 4K monitor. Measured under a nested X server:
| Window | Achieved | build p50/p90 | raster p50/p90 |
|---|---|---|---|
| 1280x720 | 68.5 fps | 0.30 / 0.40 ms | 1.50 / 2.00 ms |
| 3840x2160 | 19.9 fps | 0.40 / 0.50 ms | 8.40 / 44.60 ms |
Those first 4K figures came from plain (software) Xephyr and overstated raster.
Repeated **fullscreen on the real 3840x2160 monitor**:
| Window (real display) | Achieved | build p50 | raster p50 |
|---|---|---|---|
| 1280x720 | 68.6 fps | 0.30 ms | 1.50 ms |
| 3840x2160 | **18.6 fps** | 0.30 ms | **2.70 ms** |
This is the key result, and it is *not* a rasterisation wall. At 4K each frame
costs ~3 ms of a 16.7 ms budget, yet the app achieves 18.6 fps (~54 ms per
frame). Roughly **50 ms per frame is dead time** — neither build nor raster.
The bottleneck is the presentation/swap path, which is also why Impeller never
helped: Impeller changes rasterisation, not presentation.
### It is Flutter, not todo
Run through an identical harness, the **stock `flutter create` counter app** —
one spinner on a blank Scaffold, zero todo code — collapses the same way:
| App | 1280x720 | 3840x2160 |
|---|---|---|
| stock Flutter counter | 68.5 fps | **20.5 fps** |
| todo | 68.5 fps | **20.6 fps** |
Flutter's Linux embedder manages ~20 fps rendering a **blank screen** at 4K.
Build time is 0.1-0.4 ms throughout. No application-level change can fix this.
Size curve (harness), showing the cliff begins at 1440p:
| size | Mpx | fps | frame cost | dead time |
|---|---|---|---|---|
| 1280x720 | 0.92 | 68.5 | 1.70 ms | 12.9 ms |
| 1920x1080 | 2.07 | 60.5 | 2.40 ms | 14.1 ms |
| 2560x1440 | 3.69 | 40.3 | 3.70 ms | 21.1 ms |
| 3840x2160 | 8.29 | 20.6 | 8.80 ms | 39.8 ms |
Related upstream reports:
[#66463](https://github.com/flutter/flutter/issues/66463) (Linux desktop failure
based on window size on 4K monitors),
[#88996](https://github.com/flutter/flutter/issues/88996) (high frame latency on
Linux desktop).
## Test harness (for iterating without taking over the user's screens)
`Xephyr -glamor` runs a nested X server that renders through OpenGL on the real
GPU. Validated against ground truth: it reproduced the 4K symptom at 19.8 fps
versus the real display's 18.6 fps. It inflates raster attribution (7.2 ms vs
2.7 ms real), so use it for pass/fail, **not** for attributing where time goes.
Plain `Xephyr` without `-glamor` is a software X server and is useless here: it
copies a ~33 MB framebuffer per frame at 4K and dominates the measurement.
**The harness is validated for the Flutter desktop embedder only.** A Chrome
run inside it reported 10 fps at 4K; measuring the same page on the real display
gave 142.9 fps, confirming that number was a harness artifact (Chrome disables
GPU acceleration on an unrecognised nested display). Never benchmark a browser
inside it.
## The decisive comparison: same content, same machine, real 4K display
The identical spinner probe, rendered at 3840x2160 on the real monitor:
| Renderer | Achieved at 4K |
|---|---|
| Flutter **Linux desktop embedder** | **20.5 fps** |
| Flutter **Web (CanvasKit) in Chrome** | **142.9 fps** (cadence p50 7.00 ms) |
A **7x difference**, with the same framework and the same Dart code. This is not
a hardware limit, a driver limit, or an application limit — it is specific to
Flutter's Linux desktop embedder. It validates the web route at the size that
actually matters.
## Why the 60 Hz cap exists
`libflutter_linux_gtk.so` imports `gdk_monitor_get_refresh_rate` (and GTK
correctly reports 144.00 Hz here — verified independently), but imports **no**
`gdk_frame_clock_*` symbols; the engine falls back to a fixed 60 FPS vsync
waiter.
This is a known, open upstream limitation:
[flutter/flutter#183703 — "[Proposal] Support high refresh rate (>60) on
Linux"](https://github.com/flutter/flutter/issues/183703). Opened 2026-03-14,
labelled **P2**, **no assignee, no linked PR, no milestone**. Linux is the last
Flutter platform without >60 Hz support. Related:
[#93058](https://github.com/flutter/flutter/issues/93058) (Windows, since
fixed), [#49757](https://github.com/flutter/flutter/issues/49757).
## Implication for the "switch technology" question
A rewrite in another language is **not** warranted by the evidence. The same
Flutter framework and the same Dart code reach ~143 fps on this exact machine
when built for web and run in Chrome. Only the Linux *desktop embedder* is
capped.
The leading candidate is therefore **shipping the desktop app as a Flutter web
build in a Chrome `--app` window**, reusing the UI and domain code.
**Validated at 4K on the real display: 142.9 fps vs the desktop embedder's
20.5 fps on identical content.** That settles the renderer question.
What remains unproven is todo's *own* widget tree on web: the probe is a
spinner, and web CanvasKit raster plus WASM/JS build costs differ from the
desktop's 0.30/1.50 ms. Given desktop build time is only 0.3 ms and the web
renderer has 7x the headroom, this is very likely fine — but the proof-of-
concept should build the actual app and re-measure before the port is finished.
What is reused is the UI and domain logic. The platform-adapter layer needs web
variants throughout: persistence durability under a browser profile, token
storage, device auth, sync-on-background (Page Visibility API), and the
launch mechanism (local server vs `file://`).
Port surface (the storage seam is small, because `crdt_sync_dart` was designed
for it):
- `LogStore` is deliberately `dart:io`-free and documented as web-usable;
`FileLogPersistence` is already isolated behind the `crdt_sync_io.dart`
entrypoint. `LogPersistence` is a two-method interface (`read()` /
`write(String)`), so a web implementation over IndexedDB/localStorage is
roughly 15 lines.
- `github_client.dart` has **no** `dart:io` and uses `package:http`; the GitHub
REST API sends permissive CORS headers, so sync works from a browser origin.
- `sqlite_crdt`/`sqflite_common_ffi` remain only for the one-time legacy DB
migration (`note_repository.dart:350`), which does not apply on web and can be
skipped behind `kIsWeb`.
- `path_provider` is only used to choose the file path for `FileLogPersistence`;
unnecessary on web.
- `shared_preferences`, `url_launcher`, `share_plus`, `file_selector`,
`flutter_secure_storage` all have web implementations.
### Known costs of the web route (both are security regressions)
1. **The token loses the OS keystore.** Commit `9841aec` deliberately moved the
GitHub token off plaintext and into the OS keyring via
`flutter_secure_storage`. A Chrome `--app` build cannot reach the keyring;
`flutter_secure_storage` on web falls back to WebCrypto-wrapped
`localStorage`. That is materially weaker than what is shipping today and
partially undoes that commit.
2. **Device-flow auth breaks.** GitHub's OAuth device-flow endpoints
(`github.com/login/device/code`) do not send CORS headers, so one-tap device
auth will not work from a browser origin. The settings screen's
paste-a-personal-access-token path would have to become the desktop auth
route — which also means a long-lived PAT living in the weaker storage above.
These two stack, and they are the strongest argument against the web route.
## Agreed plan (decided 2026-07-20)
The desktop app moves to a Flutter **web** build launched in Chrome. Decisions
taken, so the next session can start from here:
- **Auth:** simple PAT pasted into `localStorage` (via
`flutter_secure_storage_web`). The local-helper design that would have kept
the OS keyring and device-flow auth was considered and **rejected** for
simplicity. Consequences accepted: the token is no longer in the keyring, and
the AES-GCM wrapping is obfuscation only (the wrap key sits in the same
`localStorage`).
- Requires a **classic PAT with "No expiration"** to be a one-time paste.
- The launcher must pin a **fixed port** and a **fixed `--user-data-dir`**,
and call `navigator.storage.persist()`. Any of those changing loses the
token: `localStorage` is keyed by origin and lives in the Chrome profile.
- **The Linux desktop build is deleted**, not kept as a fallback. (Android is
unaffected and remains a normal Flutter mobile build.)
- **Launch:** a wrapper script serves the web build on a fixed port and opens
Chrome with `--app=http://localhost:<port>`.
- **Done-condition:** the real todo app, fullscreen at 4K, sustains **>=100 fps**
while scrolling the notes list, and sync works against `kuhyx/syncs`.
Still to be specified before coding: the `WebLogPersistence` implementation,
what replaces `path_provider`/`sqflite` on web (the legacy sqlite migration path
at `note_repository.dart:350` does not apply), packaging/`install_arch.sh`
changes, and how the desktop entry launches the wrapper.
## Alternatives, if the web route is rejected
Ranked by cost. All require re-implementing the UI, and the two non-Dart options
also require a third `crdt_sync` implementation (it exists today only in Python
and Dart).
1. **Accept 60 fps.** Zero cost. The app is genuinely smooth *at* 60.
2. **Qt 6 / QML (C++).** Vulkan RHI, real 144 Hz, best NVIDIA/X11 record. Weeks.
3. **Rust + Slint.** Lightweight, but the highest total cost.
Not recommended: **Tauri** — on Linux it renders via WebKitGTK, which has its
own NVIDIA/X11 DMABUF rendering bugs; it trades one renderer problem for
another.