summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-13 23:48:14 +0900
committerAdam Malczewski <[email protected]>2026-06-13 23:48:14 +0900
commitc4939d736ff55fa48cff31a26333f4922430c3d8 (patch)
tree66ce055e9b88e1c519bd55038faec7220d04a726
parent81b40bd6ee8dc2c151f318352a7f68cb7525ef3a (diff)
downloadunbox-c4939d736ff55fa48cff31a26333f4922430c3d8.tar.gz
unbox-c4939d736ff55fa48cff31a26333f4922430c3d8.zip
kernel: fix asset hot-reload regression (watch the whole asset dir, not the .rml basename)
The watch_file refactor (35e5d32) moved the substrate's UI-asset hot-reload onto the shared FileWatcher but armed a BASENAME watch on the document's .rml file only. The dock's styling lives in a separately-<link>ed dock.rcss, so editing it (the common case) never matched the watch — asset hot-reload silently stopped working on the real seat (no "dev hot-reload ON" line, no reload on save), while config watching kept working. The ui_reload_surface() seam test passed because it bypassed the real inotify->reload path. Fix: FileWatcher::add_dir watches the document's whole DIRECTORY (so any .rml/.rcss in it triggers the surface reload); the substrate uses it and restores the "dev hot-reload ON (inotify watching asset dir '...')" log. Added an END-TO-END test mirroring the dock (a doc that <link>s a separate .rcss, real inotify event, wl_event_loop pumped, assert the document actually reloaded) — fails on the buggy code, passes now; no more relying on the seam. Real-seat verified: editing dock.rcss now reloads the live dock (border-radius + background-color changes apply on save). kernel 59 cases/260 assertions green on build + build-asan, no new suppressions. Edits confined to packages/kernel/.
-rw-r--r--packages/kernel/src/file_watcher.cpp17
-rw-r--r--packages/kernel/src/file_watcher.hpp10
-rw-r--r--packages/kernel/src/ui_substrate.cpp23
-rw-r--r--packages/kernel/tests/test_kernel.cpp64
4 files changed, 106 insertions, 8 deletions
diff --git a/packages/kernel/src/file_watcher.cpp b/packages/kernel/src/file_watcher.cpp
index ae292c2..c747fe4 100644
--- a/packages/kernel/src/file_watcher.cpp
+++ b/packages/kernel/src/file_watcher.cpp
@@ -109,6 +109,19 @@ auto FileWatcher::add(const std::string& path, std::function<void()> on_change,
return FileWatch(this, token);
}
+auto FileWatcher::add_dir(const std::string& dir, std::function<void()> on_change, ExtensionId who)
+ -> FileWatch {
+ if (!ensure_started()) {
+ return FileWatch{};
+ }
+ std::string d = dir.empty() ? std::string(".") : dir;
+ const Token token = ++next_token_;
+ // Empty basename => match ANY file change in `d`.
+ entries_.emplace(token, Entry{std::move(d), std::string{}, std::move(on_change), who});
+ arm_dir(entries_.at(token).dir);
+ return FileWatch(this, token);
+}
+
void FileWatcher::remove_watch(Token token) noexcept {
auto it = entries_.find(token);
if (it == entries_.end()) {
@@ -164,7 +177,9 @@ void FileWatcher::on_readable() {
std::vector<Token> to_fire;
for (const auto& [token, e] : entries_) {
for (const auto& [dir, name] : changed) {
- if (e.dir == dir && e.basename == name) {
+ // A file-specific entry matches its basename; a directory entry
+ // (empty basename) matches ANY file change in its dir.
+ if (e.dir == dir && (e.basename.empty() || e.basename == name)) {
to_fire.push_back(token);
break;
}
diff --git a/packages/kernel/src/file_watcher.hpp b/packages/kernel/src/file_watcher.hpp
index fbd6d86..00806db 100644
--- a/packages/kernel/src/file_watcher.hpp
+++ b/packages/kernel/src/file_watcher.hpp
@@ -55,13 +55,21 @@ public:
[[nodiscard]] auto add(const std::string& path, std::function<void()> on_change,
ExtensionId who) -> FileWatch;
+ // Watch the DIRECTORY `dir` for a change to ANY file within it (not a single
+ // basename). Used by the ui-substrate's asset hot-reload: a document and its
+ // `<link>`ed RCSS/assets live in the same dir, and editing ANY of them must
+ // reload — without parsing the document's link set. Same coalescing / error
+ // isolation / RAII as add(). `dir` should be an absolute directory path.
+ [[nodiscard]] auto add_dir(const std::string& dir, std::function<void()> on_change,
+ ExtensionId who) -> FileWatch;
+
// detail::WatchRegistry — stop the watch with this token (FileWatch dtor).
void remove_watch(Token token) noexcept override;
private:
struct Entry {
std::string dir; // watched directory (absolute)
- std::string basename; // file within `dir` to match
+ std::string basename; // file within `dir` to match; EMPTY = any file
std::function<void()> on_change;
ExtensionId who{};
};
diff --git a/packages/kernel/src/ui_substrate.cpp b/packages/kernel/src/ui_substrate.cpp
index 8b7440e..1f289bd 100644
--- a/packages/kernel/src/ui_substrate.cpp
+++ b/packages/kernel/src/ui_substrate.cpp
@@ -809,14 +809,22 @@ Rml::ElementDocument* Substrate::Impl::load_document_first(Surface& s) {
return nullptr;
}
// Dev-only: register an asset hot-reload watch on the kernel's SHARED
- // file watcher (the same machinery Host::watch_file uses). The callback
- // flags this surface for reload, applied (coalesced) at the next
- // tick_all on the GL context. Only this DECISION is UNBOX_DEV-gated; the
- // watcher infra itself is always available.
+ // file watcher (the same machinery Host::watch_file uses). We watch the
+ // document's whole DIRECTORY (add_dir), NOT just the .rml basename:
+ // RmlUi resolves `<link href="x.rcss">` and image srcs relative to the
+ // document dir, and the dock keeps its dock.rcss BESIDE dock.rml — so a
+ // change to ANY file in that dir (the .rml OR a linked .rcss/asset) must
+ // reload. (Watching only the .rml basename was the regression: editing
+ // dock.rcss fired no reload.) The callback flags this surface for reload,
+ // applied (coalesced) at the next tick_all on the GL context. Only this
+ // DECISION is UNBOX_DEV-gated; the watcher infra itself is always
+ // available (Host::watch_file is ungated).
if (hot_reload_enabled() && watcher != nullptr) {
Surface* sp = &s;
- s.asset_watch = watcher->add(
- s.resolved_path,
+ const std::string dir =
+ std::filesystem::path(s.resolved_path).parent_path().string();
+ s.asset_watch = watcher->add_dir(
+ dir,
[this, sp] {
if (std::find(pending_reloads.begin(), pending_reloads.end(), sp) ==
pending_reloads.end()) {
@@ -824,6 +832,9 @@ Rml::ElementDocument* Substrate::Impl::load_document_first(Surface& s) {
}
},
s.who);
+ wlr_log(WLR_INFO,
+ "ui-substrate: dev hot-reload ON (inotify watching asset dir '%s')",
+ dir.c_str());
}
} else {
doc = s.context->LoadDocumentFromMemory(s.rml_inline);
diff --git a/packages/kernel/tests/test_kernel.cpp b/packages/kernel/tests/test_kernel.cpp
index cc6d04e..49f0ad5 100644
--- a/packages/kernel/tests/test_kernel.cpp
+++ b/packages/kernel/tests/test_kernel.cpp
@@ -1448,6 +1448,70 @@ TEST_CASE("substrate: hot-reload re-parses RCSS (file change -> new color)") {
unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM");
}
+TEST_CASE("substrate: END-TO-END dev hot-reload (real inotify, not the seam)") {
+ // This exercises the REAL path the seam-based test does NOT: UNBOX_DEV on,
+ // the substrate arms its own asset watch on the kernel's shared FileWatcher,
+ // and a WRITE to a file on disk fires the real inotify event through the
+ // wl_event_loop, which must run the reload callback. (The ui_reload_surface
+ // seam passed even while this real path was broken — that gap is the bug.)
+ //
+ // It mirrors the STAGE DOCK exactly: dock.rml <link>s a SEPARATE dock.rcss
+ // in the same dir, and the user edits the RCSS. The regression watched only
+ // the .rml basename, so an RCSS edit fired no reload — this test catches it.
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "gles2", 1);
+ setenv("WLR_HEADLESS_OUTPUTS", "1", 1);
+ setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1);
+ setenv("UNBOX_DEV", "1", 1); // arm the substrate's asset watch
+
+ // A doc dir holding doc.rml (links style.rcss) + style.rcss (the body color).
+ const auto dir = std::filesystem::temp_directory_path() / "unbox-kernel-tests" /
+ (std::string("e2e-") + std::to_string(::getpid()));
+ std::error_code ec;
+ std::filesystem::create_directories(dir, ec);
+ const auto rml = dir / "doc.rml";
+ const auto rcss = dir / "style.rcss";
+ REQUIRE(write_file(rml,
+ "<rml><head><link type=\"text/rcss\" href=\"style.rcss\"/></head>"
+ "<body data-model=\"ui\"><div id=\"fill\"></div></body></rml>"));
+ REQUIRE(write_file(rcss, "body{margin:0px;} #fill{display:block;position:absolute;"
+ "left:0px;top:0px;width:4000px;height:4000px;"
+ "background-color:#20c040;}")); // green
+
+ auto server = unbox::kernel::Server::create({});
+ auto* ext = new PathSurfaceExtension(rml.string());
+ server->install(std::unique_ptr<unbox::kernel::Extension>(ext));
+ server->activate_extensions();
+ pump(*server, 30); // first render: loads the doc + arms the asset-DIR watch
+
+ if (!ext->has_surface() || server->ui_frame_count() == 0) {
+ unsetenv("UNBOX_DEV");
+ unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM");
+ std::filesystem::remove_all(dir, ec);
+ return; // no GL path: skip
+ }
+ REQUIRE(is_green(server->ui_pixel(40, 40)));
+
+ // Edit the LINKED RCSS on disk (the dock's real case). Do NOT call the seam —
+ // let the real inotify watch on the document's DIRECTORY fire through the
+ // loop and drive the reload. Pump until the pixel flips; on the buggy code
+ // (basename-only watch of doc.rml) the RCSS change never matched -> no flip.
+ REQUIRE(write_file(rcss, "body{margin:0px;} #fill{display:block;position:absolute;"
+ "left:0px;top:0px;width:4000px;height:4000px;"
+ "background-color:#d03020;}")); // red
+ bool reloaded = false;
+ for (int i = 0; i < 100 && !reloaded; ++i) {
+ server->dispatch(20); // pump the loop: delivers inotify + ticks surfaces
+ reloaded = is_red(server->ui_pixel(40, 40));
+ }
+ CHECK(reloaded); // FAILS on the regression
+ CHECK_FALSE(is_green(server->ui_pixel(40, 40)));
+
+ unsetenv("UNBOX_DEV");
+ unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM");
+ std::filesystem::remove_all(dir, ec);
+}
+
namespace {
// A file-backed list document + an extension that binds a runtime-sized list.
const char* kListReloadRml = R"RML(<rml>