mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 20:27:01 +01:00
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
217 lines
10 KiB
Markdown
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.
|