bread-theme::gtk per-output theme/palette hot-reload never fires (uses rename source path, not destination) #14
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What's broken
bread-theme/src/gtk.rs'sarm_dir_reload_watch(lines 312-325) is the directory watcher behindensure_themes_watch()/ensure_palettes_watch(), which drive live per-monitor recolouring for every window bound viabind_window/bind_window_auto/bind_window_with_app_css/bind_window_auto_with_app_css. It never actually fires on a real write, because it inspects the wrong path for a rename event:Every write this watch needs to react to goes through
atomic_write(bread-theme/src/output.rs:68-83): write to.{name}.tmp.{pid}, thenrename()to the finalthemes/<output>.css/palettes/<output>.json. WithGFileMonitorFlags::WATCH_MOVESset (which this code does request), GIO/inotify pairs that rename into a singleRENAMEDevent wherefileis the source (old/tmp) path andotheris the destination (final) path — this is standard GIO semantics forWATCH_MOVES, andwatch_theme_filetwo functions above (line 97-115) already relies on exactly this and correctly checks bothfileandother.arm_dir_reload_watchinstead takesfile.path()unconditionally when it'sSome(line 318) — which it always is, since the tmp path is a valid path — soother(the only path with the right extension) is never consulted.reload_stem_for_eventthen runs against the tmp filename (e.g..eDP-1.css.tmp.12345), whose extension is12345, notcss/json, fails the extension check, and the event is silently dropped.Evidence
Empirically verified with a standalone GIO test (PyGObject, same
WATCH_MOVESflag) reproducingatomic_write's write-then-rename sequence on a real filesystem:The only event carrying the real destination filename (
eDP-1.css) is therenamedevent, and it's in theotherfield, notfile. None of the four events givefilea path with a.css/.jsonextension. This exact "tmp-suffixed name → no match" case is already unit-tested in this same file (reload_stem_for_event_requires_the_right_extension, asserting.eDP-1.css.tmp.42returnsNone) — but no test covers which offile/otheractually gets passed to that function from a liveFileMonitor, so the bug slipped throughECO-6's otherwise-thorough per-monitor-theming audit (introduced in commit9486a8c, "bread-theme/gtk: fix per-monitor bind lifecycle/race leaks + tests").Why it matters
Every GTK app that binds windows via
bind_window/bind_window_auto/*_with_app_css— confirmed in breadclip, breadcast, breadpad, breadman, breadpaper, breadsearch, breadhelp (and presumably breadbar/breadbox) — never picks up a per-output palette change live. Concrete repro: runbread-theme generate-output <output> <image>(orbreadpaper set_onon a monitor) while e.g. breadclip's popup is open on that monitor — the window's theme/app CSS providers are never reloaded; the window keeps showing the old palette until the app is restarted or some other trigger (e.g.bind_auto'senter-monitor/realize/mapre-bind, which re-reads the palette fresh each time) happens to force a full re-bind. So the practical symptom is intermittent/stale colours rather than a hard crash — it only "works" when something unrelated causes a rebind, which will read the freshly-written file sinceload_palette_forisn't itself broken; only the push notification is dead.Note: this is isolated to the
gio-based watcher inbread-theme::gtk. Thenotify-crate-based equivalents in breadlock (theme_watch.rs) and bos-settings (commands/theme.rs) are unaffected —notify's rename-"To" event carries the final path directly, and both already filter on it correctly.Suggested fix
Prefer
otheroverfilewhen present (mirroring howwatch_theme_filealready checks both), or explicitly branch on_event == gio::FileMonitorEvent::Renamedand useotherin that case, falling back tofilefor direct CREATE/CHANGED events (e.g. a non-atomic editor write). Something like:Add a regression test that exercises the real
connect_changedcallback (not justreload_stem_for_eventin isolation) against an actual rename, the waydir_reload_watches_arm_and_create_their_directoriesexercises real arming — e.g. write a tmp file, rename it intothemes_dir()/palettes_dir(), pump the main context, and assertreload_binds_for_sanitizedactually ran (a spy/counter on a bound test window, or at minimum assert the callback observed the right stem).Confirmed the root cause exactly as described in the audit.
bread-theme/src/gtk.rs:318(arm_dir_reload_watch'sconnect_changedcallback) read:Since
atomic_write(output.rs) writes every.css/.jsonupdate as write-to-.{name}.tmp.{pid}-then-rename(), andWATCH_MOVESis set, the resulting event hasfile= the tmp source andother= the real destination.file.path()is alwaysSomefor a valid tmp path, soother(the only field with the right extension) was never consulted, andreload_stem_for_eventalways failed its extension check against the tmp name — silently dropping every reload.watch_theme_filetwo functions above already handles this correctly by checking both fields.Fix: extracted the file/other selection into a small pure function
reload_event_paththat prefersother(rename destination), falling back tofilefor a direct CREATE/CHANGED with no rename.arm_dir_reload_watchnow uses it.Added a unit test (
reload_event_path_prefers_the_rename_destination_over_the_tmp_source). Also tried a full FileMonitor + real-rename integration test — it worked in isolation but collided withshell::hotreload's own glib-MainContext-pumping tests under the default parallel test runner (the same one CI uses), aborting the test binary via glib's thread guard. Dropped that in favor of the pure-function test, consistent with this file's existing precedent for isolating GTK/GIO-event-loop-dependent tests.cargo build --releaseandcargo test --release --workspace --locked --features gtkpass (run 3x, no flakiness).PR: #16