Linux Debug E2E (desktop-shell) fails on roughly 6% of main runs. This writes up what was established, what was ruled out, and what is still unknown, so the next person does not repeat the search.
The failure
Always the same assertion, never any other:
❌ the real runner registers a tray icon and hides instead of quitting
Timed out after 60s waiting for the window to become hidden
packages/desktop_app/integration_test/debug_desktop_shell_e2e_test.dart:46
The native window still reports visible 60s after window.hide() returned. PluginDesktopWindow.hide() already re-issues hide ten times over 500ms on Linux (packages/app/lib/src/features/desktop/infrastructure/desktop_shell.dart:260-266), so the window survives ten hide requests and then stays visible for another 60s. The "some Linux window managers drop the first request while the compositor is busy" reading in that comment does not explain it — nothing is dropping a request, something is refusing or undoing all of them.
Observed on runs 31801730371, 31758523525, 31686404819.
This is a regression, not an always-flaky test
Every main pipeline run from 2026-08-10T11:25Z to 2026-08-14T20:07Z (120 runs) was scanned for a desktop-shell failure:
| window |
runs |
desktop-shell failures |
| 08-10 11:25 → 08-13 09:24 |
~70 |
0 |
| 08-13 09:24 → 08-14 20:07 |
~50 |
3 |
If the post-08-13 rate (6%) had held before, ~4 failures would be expected in the earlier 70 runs; observing zero has probability ≈0.94^70 ≈ 1.3%. The split is real.
641b087b build: upgrade Flutter to 3.47.0 (#254) landed 2026-08-13T04:00Z, inside the gap between the last clean run and the first failure. It bumped 3.44.8 → 3.47.0. The only other commits in that gap are 1edad188 (tinyrack_ui 0.59.0) and c1ee04f6 (chat scrolling) — neither touches the native window path.
Linux embedder changes in that Flutter range, from the 3.47.0 release notes, ordered by suspicion:
- Turn linux impeller on by default (flutter#187573)
- [Linux] Move Flutter rendering into a new FlViewRenderer widget (flutter#188289) — changes the GTK widget hierarchy under the toplevel that
window_manager hides
[linux]: fixes crash when resizing windows, Fix crash if FlView is destroyed during a draw, opts the linux embedder into sdf rendering
Ruled out
Each of these was checked and is not the discriminator:
- A per-call race. A diagnostic build ran 100+ hide/show cycles across several fresh processes; a healthy process hides in ~7ms and never failed once. Healthy processes stay healthy.
- App startup speed.
readyMilliseconds from the uploaded E2E reports — failing: 56421, 52948, 47457; passing: 37775, 51213, 48357, 49266, 48784. A failing run was faster than four passing ones. This also weakens any "the first frame arrives late and re-shows the window" theory.
- The tray. Hide works both before and after
tray.install in healthy runs, so app_indicator_new taking its no-StatusNotifier-host fallback under xvfb-run is not sufficient on its own.
- Parallel app instances. The report shows
jobs: 2 but lanes: [1] — one app process, and E2E lanes already set TINYRACK_TINEST_ALLOW_MULTIPLE_INSTANCES, so my_application_activate cannot be re-entered by a second launch.
- Native warnings. No Gtk/Gdk/GLib warning appears in a failing job log.
Still unknown
The one question that would split the remaining hypotheses was never captured, because the flake did not reproduce in 9 instrumented attempts:
When it fails, does window.visible (the app-level notifier) also flip back to true?
The relay installed by interceptClose publishes the plugin''s raw GTK show/hide events into that notifier. So:
- notifier flips back to
true → something delivered a native show that undid our hide.
- notifier stays
false while the native query says visible → the hide never reached GTK.
Those need different fixes.
Reproduction notes for whoever picks this up
The instrumentation is on diagnose-desktop-shell-hide-flake (PR #285, closed unmerged). Two lessons from using it:
- Do not run probe cycles before the original sequence. Consuming the process''s first-ever hide earlier can mask the bug. The final version keeps the original order and records around it.
- A burst of cycles only samples one instant. The first version finished 23 cycles inside 700ms. The second spreads 80 cycles over ~40s.
Neither version reproduced in 9 job runs, consistent with ~6%: 9 attempts only gives ~57% odds. Budget ~30 runs per arm for any confirm-or-deny experiment — proving a 6% flake is gone is much more expensive than reproducing it.
Two experiments worth the CI time, in order:
- Disable Impeller for the Linux E2E job and compare failure rate over ~30 runs. Cheapest shot at the most likely single cause, and it yields a usable setting if it lands.
- Pin
FLUTTER_VERSION back to 3.44.8 on a branch for ~30 runs. Tests the regression claim most directly, but reverting the SDK is not a fix, so it only redirects the search.
Impact
CI only, so far. A failed check does not clear auto-merge, but GitHub will not re-run it by itself, so every occurrence stalls a PR until someone runs gh run rerun --failed. No user-facing report of the app refusing to hide to tray has been tied to this.
Linux Debug E2E (desktop-shell)fails on roughly 6% ofmainruns. This writes up what was established, what was ruled out, and what is still unknown, so the next person does not repeat the search.The failure
Always the same assertion, never any other:
The native window still reports visible 60s after
window.hide()returned.PluginDesktopWindow.hide()already re-issues hide ten times over 500ms on Linux (packages/app/lib/src/features/desktop/infrastructure/desktop_shell.dart:260-266), so the window survives ten hide requests and then stays visible for another 60s. The "some Linux window managers drop the first request while the compositor is busy" reading in that comment does not explain it — nothing is dropping a request, something is refusing or undoing all of them.Observed on runs 31801730371, 31758523525, 31686404819.
This is a regression, not an always-flaky test
Every
mainpipeline run from 2026-08-10T11:25Z to 2026-08-14T20:07Z (120 runs) was scanned for adesktop-shellfailure:If the post-08-13 rate (6%) had held before, ~4 failures would be expected in the earlier 70 runs; observing zero has probability ≈0.94^70 ≈ 1.3%. The split is real.
641b087b build: upgrade Flutter to 3.47.0 (#254)landed 2026-08-13T04:00Z, inside the gap between the last clean run and the first failure. It bumped 3.44.8 → 3.47.0. The only other commits in that gap are1edad188(tinyrack_ui 0.59.0) andc1ee04f6(chat scrolling) — neither touches the native window path.Linux embedder changes in that Flutter range, from the 3.47.0 release notes, ordered by suspicion:
window_managerhides[linux]: fixes crash when resizing windows,Fix crash if FlView is destroyed during a draw,opts the linux embedder into sdf renderingRuled out
Each of these was checked and is not the discriminator:
readyMillisecondsfrom the uploaded E2E reports — failing: 56421, 52948, 47457; passing: 37775, 51213, 48357, 49266, 48784. A failing run was faster than four passing ones. This also weakens any "the first frame arrives late and re-shows the window" theory.tray.installin healthy runs, soapp_indicator_newtaking its no-StatusNotifier-host fallback underxvfb-runis not sufficient on its own.jobs: 2butlanes: [1]— one app process, and E2E lanes already setTINYRACK_TINEST_ALLOW_MULTIPLE_INSTANCES, somy_application_activatecannot be re-entered by a second launch.Still unknown
The one question that would split the remaining hypotheses was never captured, because the flake did not reproduce in 9 instrumented attempts:
The relay installed by
interceptClosepublishes the plugin''s raw GTKshow/hideevents into that notifier. So:true→ something delivered a nativeshowthat undid our hide.falsewhile the native query says visible → the hide never reached GTK.Those need different fixes.
Reproduction notes for whoever picks this up
The instrumentation is on
diagnose-desktop-shell-hide-flake(PR #285, closed unmerged). Two lessons from using it:Neither version reproduced in 9 job runs, consistent with ~6%: 9 attempts only gives ~57% odds. Budget ~30 runs per arm for any confirm-or-deny experiment — proving a 6% flake is gone is much more expensive than reproducing it.
Two experiments worth the CI time, in order:
FLUTTER_VERSIONback to 3.44.8 on a branch for ~30 runs. Tests the regression claim most directly, but reverting the SDK is not a fix, so it only redirects the search.Impact
CI only, so far. A failed check does not clear auto-merge, but GitHub will not re-run it by itself, so every occurrence stalls a PR until someone runs
gh run rerun --failed. No user-facing report of the app refusing to hide to tray has been tied to this.