summaryrefslogtreecommitdiffhomepage
path: root/packages/kernel/src/ui_substrate.cpp
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-13 23:23:15 +0900
committerAdam Malczewski <[email protected]>2026-06-13 23:23:15 +0900
commit35e5d32901c9a35700d3d8b046971dafc9bed5fe (patch)
treef6fbee5ef84c0d78d7d8f15f44bdc21d177f063b /packages/kernel/src/ui_substrate.cpp
parent37ff3e1762187198c6d38eebb20ea37c2c937c96 (diff)
downloadunbox-35e5d32901c9a35700d3d8b046971dafc9bed5fe.tar.gz
unbox-35e5d32901c9a35700d3d8b046971dafc9bed5fe.zip
kernel: generalize the inotify watcher into a Host::watch_file service
The hot-reload watcher was substrate-internal; expose it as a typed RAII primitive any extension can use (config hot-reload is the first consumer), per "the kernel owns the event/service bus; extensions never hold raw event-loop glue". - New public watch.hpp: `class FileWatch` (move-only RAII; ~/reset() stop the watch) + `Host::watch_file(path, on_change) -> FileWatch`. on_change fires on the event-loop thread, COALESCED (one save = one call), EDITOR-SAFE (dir-watch the basename across temp+rename), fires on CREATE of a not-yet-existing file, and is ERROR-ISOLATED to the calling extension (carries its id; a throw disables only that extension). UNGATED — works without UNBOX_DEV. - New src/file_watcher.{hpp,cpp}: ONE session-wide inotify instance on the wl_event_loop multiplexing all watched paths. The substrate's UI-asset hot-reload was refactored onto it (no second inotify); only the substrate's *decision* to watch UI assets stays UNBOX_DEV-gated. Created lazily on first watch; torn down leak-clean before the loop dies. host.hpp/kernel.md documented. kernel 58 cases/254 assertions green on build + build-asan (incl. the inotify path), no new suppressions. Edits confined to packages/kernel/.
Diffstat (limited to 'packages/kernel/src/ui_substrate.cpp')
-rw-r--r--packages/kernel/src/ui_substrate.cpp192
1 files changed, 44 insertions, 148 deletions
diff --git a/packages/kernel/src/ui_substrate.cpp b/packages/kernel/src/ui_substrate.cpp
index 0794536..8b7440e 100644
--- a/packages/kernel/src/ui_substrate.cpp
+++ b/packages/kernel/src/ui_substrate.cpp
@@ -1,5 +1,6 @@
#include "ui_substrate.hpp"
+#include "file_watcher.hpp"
#include "rmlui_renderer_gl3.h"
#include <RmlUi/Core/Context.h>
@@ -20,12 +21,6 @@
#include <GLES2/gl2ext.h> // glEGLImageTargetTexture2DOES
#include <GLES3/gl32.h>
-// inotify is libc (not wlroots), so it does NOT go through wlr.hpp. Integrated
-// into the kernel's wl_event_loop via wl_event_loop_add_fd so the fd is polled,
-// never blocking the loop.
-#include <sys/inotify.h>
-#include <unistd.h>
-
#include <algorithm>
#include <cstdint>
#include <cstdlib>
@@ -384,6 +379,10 @@ struct Surface {
std::string rml_path; // the spec's path (as the extension passed it)
std::string resolved_path; // absolute path actually loaded (file-backed only)
bool doc_loaded = false;
+ // Dev asset hot-reload (UNBOX_DEV): a watch on resolved_path's dir whose
+ // callback flags this surface for reload. Released when the Surface dies
+ // (stops the underlying inotify watch). Inactive for inline/non-dev surfaces.
+ FileWatch asset_watch;
// Data bindings. Each bound scalar pairs a getter with a stable slot the
// getter writes into; RmlUi binds to the slot's address. Bound BEFORE the
@@ -605,36 +604,21 @@ struct Substrate::Impl {
std::list<Surface> surfaces; // stable addresses (handles borrow Surface*)
- // ---- Dev hot-reload watcher (UNBOX_DEV-gated; see top-of-file helpers) ----
- // ONE inotify fd integrated into the wl_event_loop. We watch DIRECTORIES (not
- // inodes) because editors save via temp-file + rename — IN_CLOSE_WRITE /
- // IN_MOVED_TO on the dir, matched by filename, catches that reliably. Each
- // watched dir maps to the file basenames in it that back a surface. Reloads
- // are coalesced and applied at the next tick_all (debounce within a frame).
- wl_event_loop* loop = nullptr;
- int inotify_fd = -1;
- wl_event_source* inotify_source = nullptr;
- // inotify watch descriptor (one per watched directory) -> directory path.
- std::unordered_map<int, std::string> watch_dirs;
- // directory path -> the surfaces whose document (or same-dir RCSS/assets)
- // live there. A change to ANY file in the dir reloads them (covers the .rml
- // AND a sibling .rcss the document <link>s, with no need to parse links).
- std::unordered_map<std::string, std::vector<Surface*>> dir_surfaces;
+ // ---- Dev asset hot-reload (UNBOX_DEV-gated) ----
+ // The substrate does NOT own an inotify fd: it borrows the kernel's ONE
+ // shared FileWatcher (injected at create). Each file-backed surface holds a
+ // FileWatch whose callback flags the surface in `pending_reloads`; the
+ // reload itself is applied (coalesced) at the next tick_all so it happens
+ // inside the frame, on the GL context, like every other render step. Only
+ // the DECISION to watch UI assets is UNBOX_DEV-gated — the watcher infra is
+ // always available (config watching via Host::watch_file is ungated).
+ FileWatcher* watcher = nullptr; // kernel-owned borrow (may be null: no loop)
// surfaces flagged dirty by a file event, drained (coalesced) at tick_all.
std::vector<Surface*> pending_reloads;
- // Bring up the inotify fd + its wl_event_loop source (dev only). Idempotent;
- // a failure leaves inotify_fd < 0 (watching simply disabled, no error).
- void init_watcher(wl_event_loop* event_loop);
- // Remove the event source + close the inotify fd (teardown / no surfaces).
- void teardown_watcher();
- // Watch the directory of `abs_file` and remember that `s` depends on it.
- // No-op if hot-reload is disabled or the fd is unusable.
- void watch_file_for(Surface* s, const std::string& abs_file);
- // Stop tracking `s` across all watched dirs (on destroy / before re-watch).
+ // Stop flagging `s` for reload (on destroy). The FileWatch on the Surface
+ // itself stops the inotify watch when the Surface is erased.
void unwatch_surface(Surface* s);
- // Drain the inotify fd; flag dependent surfaces for reload (coalesced).
- void on_inotify_readable();
// Reload `s`'s document from its file, preserving context/model/bindings/
// geometry/visibility/previews; error-isolated (keeps the old doc on a bad
// parse). Returns true if a NEW document was installed. Caller holds nothing;
@@ -812,50 +796,7 @@ bool Substrate::Impl::init_surface_gl(Surface& s) {
return true;
}
-// ---- Document load (first) + dev hot-reload --------------------------------
-
-namespace {
-// wl_event_loop fd callback: just drains inotify (never blocks the loop).
-auto inotify_dispatch(int /*fd*/, std::uint32_t /*mask*/, void* data) -> int {
- static_cast<Substrate::Impl*>(data)->on_inotify_readable();
- return 0;
-}
-} // namespace
-
-void Substrate::Impl::init_watcher(wl_event_loop* event_loop) {
- loop = event_loop;
- if (!hot_reload_enabled() || loop == nullptr || inotify_fd >= 0) {
- return;
- }
- inotify_fd = inotify_init1(IN_NONBLOCK | IN_CLOEXEC);
- if (inotify_fd < 0) {
- wlr_log(WLR_ERROR, "ui-substrate: inotify_init1 failed; hot-reload disabled");
- return;
- }
- inotify_source = wl_event_loop_add_fd(loop, inotify_fd, WL_EVENT_READABLE,
- inotify_dispatch, this);
- if (inotify_source == nullptr) {
- close(inotify_fd);
- inotify_fd = -1;
- wlr_log(WLR_ERROR, "ui-substrate: wl_event_loop_add_fd failed; hot-reload disabled");
- return;
- }
- wlr_log(WLR_INFO, "ui-substrate: dev hot-reload ON (inotify watching asset dirs)");
-}
-
-void Substrate::Impl::teardown_watcher() {
- if (inotify_source != nullptr) {
- wl_event_source_remove(inotify_source);
- inotify_source = nullptr;
- }
- if (inotify_fd >= 0) {
- close(inotify_fd); // also drops all inotify watches
- inotify_fd = -1;
- }
- watch_dirs.clear();
- dir_surfaces.clear();
- pending_reloads.clear();
-}
+// ---- Document load (first) + dev asset hot-reload --------------------------
Rml::ElementDocument* Substrate::Impl::load_document_first(Surface& s) {
Rml::ElementDocument* doc = nullptr;
@@ -867,9 +808,22 @@ Rml::ElementDocument* Substrate::Impl::load_document_first(Surface& s) {
s.rml_path.c_str(), s.resolved_path.c_str());
return nullptr;
}
- // Dev-only: watch the document's directory for editor saves.
- if (hot_reload_enabled()) {
- watch_file_for(&s, s.resolved_path);
+ // 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.
+ if (hot_reload_enabled() && watcher != nullptr) {
+ Surface* sp = &s;
+ s.asset_watch = watcher->add(
+ s.resolved_path,
+ [this, sp] {
+ if (std::find(pending_reloads.begin(), pending_reloads.end(), sp) ==
+ pending_reloads.end()) {
+ pending_reloads.push_back(sp);
+ }
+ },
+ s.who);
}
} else {
doc = s.context->LoadDocumentFromMemory(s.rml_inline);
@@ -882,74 +836,14 @@ Rml::ElementDocument* Substrate::Impl::load_document_first(Surface& s) {
return doc;
}
-void Substrate::Impl::watch_file_for(Surface* s, const std::string& abs_file) {
- if (inotify_fd < 0) {
- return;
- }
- std::error_code ec;
- const std::string dir = std::filesystem::path(abs_file).parent_path().string();
- if (dir.empty()) {
- return;
- }
- auto& deps = dir_surfaces[dir];
- if (std::find(deps.begin(), deps.end(), s) == deps.end()) {
- deps.push_back(s);
- }
- // Add the dir watch once (inotify_add_watch on the same path returns the
- // SAME wd, so this is idempotent — re-arming after a watched file is
- // replaced is automatic since we watch the directory, not the inode).
- const int wd = inotify_add_watch(inotify_fd, dir.c_str(),
- IN_CLOSE_WRITE | IN_MOVED_TO | IN_CREATE);
- if (wd >= 0) {
- watch_dirs[wd] = dir;
- }
-}
-
void Substrate::Impl::unwatch_surface(Surface* s) {
- for (auto it = dir_surfaces.begin(); it != dir_surfaces.end();) {
- auto& deps = it->second;
- deps.erase(std::remove(deps.begin(), deps.end(), s), deps.end());
- if (deps.empty()) {
- it = dir_surfaces.erase(it);
- } else {
- ++it;
- }
- }
+ // The Surface's own FileWatch (asset_watch) stops the inotify watch when the
+ // Surface is erased; here we just drop any queued reload so a destroyed
+ // surface is never reloaded.
pending_reloads.erase(std::remove(pending_reloads.begin(), pending_reloads.end(), s),
pending_reloads.end());
}
-void Substrate::Impl::on_inotify_readable() {
- // Drain ALL queued inotify events (the fd is non-blocking; one readable
- // notification may carry many). Flag every surface in a changed directory
- // for reload — coalesced into pending_reloads (dedup) and applied once at
- // the next tick_all, so a temp+rename burst causes a single reload.
- alignas(struct inotify_event) char buf[4096];
- for (;;) {
- const ssize_t n = read(inotify_fd, buf, sizeof(buf));
- if (n <= 0) {
- break; // EAGAIN (drained) or closed
- }
- std::size_t off = 0;
- while (off + sizeof(struct inotify_event) <= static_cast<std::size_t>(n)) {
- auto* ev = reinterpret_cast<struct inotify_event*>(buf + off);
- auto wd_it = watch_dirs.find(ev->wd);
- if (wd_it != watch_dirs.end()) {
- auto deps_it = dir_surfaces.find(wd_it->second);
- if (deps_it != dir_surfaces.end()) {
- for (Surface* s : deps_it->second) {
- if (std::find(pending_reloads.begin(), pending_reloads.end(), s) ==
- pending_reloads.end()) {
- pending_reloads.push_back(s);
- }
- }
- }
- }
- off += sizeof(struct inotify_event) + ev->len;
- }
- }
-}
-
bool Substrate::Impl::reload_surface(Surface& s) {
// Only file-backed, already-loaded surfaces reload. The data model + the
// extension's registered bind_*/bind_list*/bind_event getters are CONTEXT-
@@ -1192,10 +1086,11 @@ bool Substrate::Impl::resize_surface_gl(Surface& s, int w, int h) {
}
void Substrate::Impl::destroy_surface(Surface* s) {
- // Drop the hot-reload watch tracking for this surface FIRST (so a queued
- // file event can never reload a dying surface). The inotify dir watch itself
- // is left armed (cheap) and is reaped at teardown_watcher.
+ // Drop any queued reload for this surface FIRST (so a coalesced file event
+ // can never reload a dying surface). The surface's FileWatch (asset_watch)
+ // stops the underlying inotify watch when the Surface is erased below.
unwatch_surface(s);
+ s->asset_watch.reset();
const bool cur = gl.make_current();
if (s->scene_buffer != nullptr) {
wlr_scene_node_destroy(&s->scene_buffer->node);
@@ -1457,14 +1352,14 @@ void Substrate::Impl::destroy_preview(PreviewState* p) {
// ---- Substrate (private surface) --------------------------------------------
auto Substrate::create(EGLDisplay egl_display, wlr_allocator* allocator, wlr_renderer* renderer,
- wl_event_loop* loop, SubstrateDisableFn disable)
+ FileWatcher* watcher, SubstrateDisableFn disable)
-> std::unique_ptr<Substrate> {
auto impl = std::make_unique<Impl>();
impl->allocator = allocator;
impl->renderer = renderer;
impl->disable = std::move(disable);
+ impl->watcher = watcher; // shared kernel-owned file watcher (asset hot-reload)
impl->gl.init(egl_display); // sets gl.ok; failure => unavailable substrate
- impl->init_watcher(loop); // dev-only (UNBOX_DEV); no-op otherwise
return std::unique_ptr<Substrate>(new Substrate(std::move(impl)));
}
@@ -1482,7 +1377,8 @@ Substrate::~Substrate() {
while (!impl_->surfaces.empty()) {
impl_->destroy_surface(&impl_->surfaces.front());
}
- impl_->teardown_watcher(); // remove the wl_event_loop source + close the fd
+ // The shared FileWatcher is kernel-owned (NOT the substrate's): each
+ // surface's FileWatch released above already removed its asset watch.
impl_->gl.teardown();
}