summaryrefslogtreecommitdiffhomepage
path: root/packages/kernel/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/kernel/src')
-rw-r--r--packages/kernel/src/file_watcher.cpp196
-rw-r--r--packages/kernel/src/file_watcher.hpp87
-rw-r--r--packages/kernel/src/server.cpp29
-rw-r--r--packages/kernel/src/server_impl.hpp19
-rw-r--r--packages/kernel/src/ui_substrate.cpp192
-rw-r--r--packages/kernel/src/ui_substrate.hpp13
6 files changed, 379 insertions, 157 deletions
diff --git a/packages/kernel/src/file_watcher.cpp b/packages/kernel/src/file_watcher.cpp
new file mode 100644
index 0000000..ae292c2
--- /dev/null
+++ b/packages/kernel/src/file_watcher.cpp
@@ -0,0 +1,196 @@
+#include "file_watcher.hpp"
+
+// 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 — never blocks.
+#include <sys/inotify.h>
+#include <unistd.h>
+
+#include <algorithm>
+#include <filesystem>
+
+namespace unbox::kernel {
+
+FileWatcher::FileWatcher(wl_event_loop* loop, std::function<void(ExtensionId)> disable)
+ : loop_(loop), disable_(std::move(disable)) {}
+
+FileWatcher::~FileWatcher() {
+ // Tear down before the loop/display dies: remove the event source, then
+ // close the fd (closing drops every inotify watch).
+ if (source_ != nullptr) {
+ wl_event_source_remove(source_);
+ source_ = nullptr;
+ }
+ if (fd_ >= 0) {
+ close(fd_);
+ fd_ = -1;
+ }
+}
+
+auto FileWatcher::dispatch(int /*fd*/, std::uint32_t /*mask*/, void* data) -> int {
+ static_cast<FileWatcher*>(data)->on_readable();
+ return 0;
+}
+
+bool FileWatcher::ensure_started() {
+ if (fd_ >= 0) {
+ return true;
+ }
+ if (loop_ == nullptr) {
+ return false;
+ }
+ fd_ = inotify_init1(IN_NONBLOCK | IN_CLOEXEC);
+ if (fd_ < 0) {
+ wlr_log(WLR_ERROR, "file-watcher: inotify_init1 failed; watching disabled");
+ return false;
+ }
+ source_ = wl_event_loop_add_fd(loop_, fd_, WL_EVENT_READABLE, dispatch, this);
+ if (source_ == nullptr) {
+ close(fd_);
+ fd_ = -1;
+ wlr_log(WLR_ERROR, "file-watcher: wl_event_loop_add_fd failed; watching disabled");
+ return false;
+ }
+ return true;
+}
+
+void FileWatcher::stop_if_idle() noexcept {
+ if (!entries_.empty()) {
+ return;
+ }
+ if (source_ != nullptr) {
+ wl_event_source_remove(source_);
+ source_ = nullptr;
+ }
+ if (fd_ >= 0) {
+ close(fd_); // drops all inotify watches
+ fd_ = -1;
+ }
+ wd_dirs_.clear();
+}
+
+void FileWatcher::arm_dir(const std::string& dir) {
+ if (fd_ < 0) {
+ return;
+ }
+ // inotify_add_watch on an already-watched path returns the SAME wd, so this
+ // is idempotent and auto re-arms after a watched file is replaced (we watch
+ // the directory, not the inode).
+ const int wd = inotify_add_watch(fd_, dir.c_str(),
+ IN_CLOSE_WRITE | IN_MOVED_TO | IN_CREATE);
+ if (wd >= 0) {
+ wd_dirs_[wd] = dir;
+ }
+}
+
+void FileWatcher::rearm_all_dirs() {
+ for (const auto& [token, e] : entries_) {
+ arm_dir(e.dir);
+ }
+}
+
+auto FileWatcher::add(const std::string& path, std::function<void()> on_change, ExtensionId who)
+ -> FileWatch {
+ if (!ensure_started()) {
+ return FileWatch{}; // no loop / inotify unavailable: inert handle
+ }
+ std::filesystem::path p(path);
+ std::string dir = p.parent_path().string();
+ if (dir.empty()) {
+ dir = "."; // a bare filename watches the cwd
+ }
+ std::string base = p.filename().string();
+ if (base.empty()) {
+ return FileWatch{};
+ }
+
+ const Token token = ++next_token_;
+ entries_.emplace(token, Entry{std::move(dir), std::move(base), 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()) {
+ return;
+ }
+ entries_.erase(it);
+ // Re-derive the inotify dir watches from the surviving entries: drop watches
+ // for directories no longer referenced. Cheapest correct approach — clear
+ // all wd watches and re-arm the dirs still in use. (Watch counts are tiny:
+ // unbox.toml + a handful of asset dirs.)
+ if (fd_ >= 0) {
+ for (const auto& [wd, dir] : wd_dirs_) {
+ inotify_rm_watch(fd_, wd);
+ }
+ wd_dirs_.clear();
+ rearm_all_dirs();
+ }
+ stop_if_idle();
+}
+
+void FileWatcher::on_readable() {
+ if (fd_ < 0) {
+ return;
+ }
+ // Drain ALL queued events (one readable notification may carry many; the fd
+ // is non-blocking). Collect the set of (dir, basename) pairs that changed,
+ // then fire each matching watch's callback AT MOST ONCE this drain
+ // (coalesced: one save = one callback even though it emits several events).
+ alignas(struct inotify_event) char buf[4096];
+ std::vector<std::pair<std::string, std::string>> changed; // (dir, name)
+ for (;;) {
+ const ssize_t n = read(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 = wd_dirs_.find(ev->wd);
+ if (wd_it != wd_dirs_.end() && ev->len > 0) {
+ changed.emplace_back(wd_it->second, std::string(ev->name));
+ }
+ off += sizeof(struct inotify_event) + ev->len;
+ }
+ }
+ if (changed.empty()) {
+ return;
+ }
+
+ // Find the tokens whose (dir, basename) matched at least one event. Snapshot
+ // them so a callback that destroys its own (or another) watch mid-fire can't
+ // invalidate the iteration.
+ std::vector<Token> to_fire;
+ for (const auto& [token, e] : entries_) {
+ for (const auto& [dir, name] : changed) {
+ if (e.dir == dir && e.basename == name) {
+ to_fire.push_back(token);
+ break;
+ }
+ }
+ }
+ for (const Token token : to_fire) {
+ auto it = entries_.find(token);
+ if (it == entries_.end()) {
+ continue; // removed by an earlier callback this drain
+ }
+ // Copy what we need before invoking: the callback may remove this watch.
+ std::function<void()> cb = it->second.on_change;
+ const ExtensionId who = it->second.who;
+ if (!cb) {
+ continue;
+ }
+ try {
+ cb();
+ } catch (...) {
+ // Same isolation boundary as a throwing hook/getter: disable the
+ // owning extension, never take down the loop/session.
+ if (disable_) {
+ disable_(who);
+ }
+ }
+ }
+}
+
+} // namespace unbox::kernel
diff --git a/packages/kernel/src/file_watcher.hpp b/packages/kernel/src/file_watcher.hpp
new file mode 100644
index 0000000..fbd6d86
--- /dev/null
+++ b/packages/kernel/src/file_watcher.hpp
@@ -0,0 +1,87 @@
+#pragma once
+
+#include <unbox/kernel/hooks.hpp> // ExtensionId
+#include <unbox/kernel/watch.hpp>
+#include <unbox/kernel/wlr.hpp> // wl_event_loop / wl_event_source
+
+#include <functional>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+// The kernel's ONE inotify-on-the-wl_event_loop file watcher, shared by every
+// consumer: Host::watch_file (config + any extension) AND the ui substrate's
+// (UNBOX_DEV-gated) asset hot-reload. There is exactly one inotify instance per
+// session; multiple watched paths multiplex over it.
+//
+// Editor-save safe: editors save by writing a temp file + renaming over the
+// target, so the inode changes and IN_MODIFY on it is unreliable. We watch the
+// containing DIRECTORY for IN_CLOSE_WRITE / IN_MOVED_TO / IN_CREATE and match
+// the basename — this also fires when a not-yet-existing file is first created.
+//
+// Coalesced: a single readable notification is drained fully and each affected
+// watch's callback fires AT MOST ONCE per drain (one save = one callback).
+//
+// Error-isolated: a throwing callback is caught at the boundary and the owning
+// extension is disabled via the injected sink (same contract as hooks/getters),
+// never the session.
+//
+// Lazy: the inotify fd + wl_event_loop source are created on the FIRST add()
+// (whichever consumer is first) and torn down when the watcher is destroyed
+// (before the loop/display dies) — kept open for the session while ≥1 watch
+// lives; closed when the last watch is removed (re-created on the next add).
+//
+// Single wl_event_loop thread throughout; no internal locking.
+
+namespace unbox::kernel {
+
+class FileWatcher final : public detail::WatchRegistry {
+public:
+ using Token = detail::WatchRegistry::Token;
+
+ // `loop` is the kernel's wl_event_loop (may be null on a backend without
+ // one — then add() degrades to a no-op handle). `disable` disables the
+ // owning extension when its callback throws (injected by the kernel, same
+ // as the bus/substrate isolation sink).
+ FileWatcher(wl_event_loop* loop, std::function<void(ExtensionId)> disable);
+ ~FileWatcher() override;
+ FileWatcher(const FileWatcher&) = delete;
+ auto operator=(const FileWatcher&) -> FileWatcher& = delete;
+
+ // Watch `path` (resolved by the caller to an absolute/usable path) for
+ // content changes; fire `on_change` (coalesced, event-loop thread,
+ // error-isolated to `who`). Returns a FileWatch RAII handle (inactive if
+ // the watcher could not be set up). The handle removes the watch on destroy.
+ [[nodiscard]] auto add(const std::string& path, 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::function<void()> on_change;
+ ExtensionId who{};
+ };
+
+ bool ensure_started(); // lazy inotify_init + wl_event_loop_add_fd
+ void stop_if_idle() noexcept; // close fd + source when no entries remain
+ void arm_dir(const std::string& dir); // (re)add the inotify dir watch
+ void rearm_all_dirs(); // re-add every distinct dir's watch
+ void on_readable(); // drain inotify, coalesce, fire callbacks
+
+ static auto dispatch(int fd, std::uint32_t mask, void* data) -> int;
+
+ wl_event_loop* loop_ = nullptr;
+ std::function<void(ExtensionId)> disable_;
+ int fd_ = -1;
+ wl_event_source* source_ = nullptr;
+
+ Token next_token_ = 0;
+ std::unordered_map<Token, Entry> entries_; // token -> watch
+ std::unordered_map<int, std::string> wd_dirs_; // inotify wd -> directory
+};
+
+} // namespace unbox::kernel
diff --git a/packages/kernel/src/server.cpp b/packages/kernel/src/server.cpp
index 5d78b70..cbd23c3 100644
--- a/packages/kernel/src/server.cpp
+++ b/packages/kernel/src/server.cpp
@@ -376,13 +376,25 @@ void Server::Impl::start_substrate() {
}
// A data-event/getter throw disables the owning extension via the same
// isolation path the bus uses (Server::Impl is the DisableSink). The
- // wl_event_loop lets the substrate poll the dev hot-reload inotify fd
- // (UNBOX_DEV-gated) without ever blocking the loop.
- substrate = Substrate::create(display_egl, allocator, renderer,
- wl_display_get_event_loop(display),
+ // substrate uses the kernel's ONE shared FileWatcher for (UNBOX_DEV-gated)
+ // asset hot-reload — the same watcher Host::watch_file uses for config.
+ substrate = Substrate::create(display_egl, allocator, renderer, file_watcher(),
[this](ExtensionId who) { disable(who); });
}
+auto Server::Impl::file_watcher() -> FileWatcher* {
+ // Lazily create the ONE shared inotify watcher on first use (config watch or
+ // asset hot-reload), carrying the kernel's disable sink for error isolation.
+ if (watcher == nullptr) {
+ if (display == nullptr) {
+ return nullptr;
+ }
+ watcher = std::make_unique<FileWatcher>(wl_display_get_event_loop(display),
+ [this](ExtensionId who) { disable(who); });
+ }
+ return watcher.get();
+}
+
void Server::Impl::shutdown() {
// Destroy extensions FIRST, in reverse activation order: their RAII members
// (Subscriptions, Listeners, scene nodes) release while the wlr objects
@@ -396,9 +408,16 @@ void Server::Impl::shutdown() {
extensions.clear();
// The ui substrate owns scene nodes + GL objects on a sibling context and
- // borrows scene/renderer/allocator: tear it down before they die.
+ // borrows scene/renderer/allocator: tear it down before they die. (Its asset
+ // FileWatch handles release here, removing those watches from the watcher.)
substrate.reset();
+ // The shared file watcher removes its wl_event_loop source + closes the
+ // inotify fd here — AFTER every FileWatch holder (extensions, substrate) is
+ // gone, and while the display/loop is still alive (the source must be
+ // removed before wl_display_destroy).
+ watcher.reset();
+
if (display != nullptr) {
wl_display_destroy_clients(display);
}
diff --git a/packages/kernel/src/server_impl.hpp b/packages/kernel/src/server_impl.hpp
index bcdb693..61c073f 100644
--- a/packages/kernel/src/server_impl.hpp
+++ b/packages/kernel/src/server_impl.hpp
@@ -5,6 +5,7 @@
#include <unbox/kernel/ui.hpp>
#include <unbox/kernel/wlr.hpp>
+#include "file_watcher.hpp"
#include "listener.hpp"
#include "ui_substrate.hpp"
@@ -93,6 +94,16 @@ struct Server::Impl : detail::DisableSink {
// per-extension facades (PerExtensionUi, one per HostImpl) borrow it.
std::unique_ptr<Substrate> substrate;
+ // The ONE inotify-on-the-wl_event_loop file watcher for the session, shared
+ // by Host::watch_file (config + extensions) AND the substrate's asset
+ // hot-reload. Created lazily on the first watch (asset or watch_file) via
+ // file_watcher(); torn down in shutdown() BEFORE the display/loop dies (so
+ // its event source is removed while the loop is still alive).
+ std::unique_ptr<FileWatcher> watcher;
+ // Get-or-create the shared watcher (lazy). Returns nullptr only if there is
+ // no wl_event_loop (never in practice — the display always has one).
+ auto file_watcher() -> FileWatcher*;
+
std::list<std::unique_ptr<Output>> outputs;
std::list<std::unique_ptr<Keyboard>> keyboards;
std::list<std::unique_ptr<TouchDevice>> touch_devices;
@@ -256,6 +267,14 @@ protected:
}
void adopt_hook(detail::HookBase& hook) override { server_->register_hook(hook); }
auto surface_store() -> detail::PointerAssoc& override { return server_->surface_assoc; }
+ auto register_file_watch(std::string path, std::function<void()> on_change)
+ -> FileWatch override {
+ FileWatcher* w = server_->file_watcher();
+ if (w == nullptr) {
+ return FileWatch{};
+ }
+ return w->add(path, std::move(on_change), id_);
+ }
private:
Server::Impl* server_;
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();
}
diff --git a/packages/kernel/src/ui_substrate.hpp b/packages/kernel/src/ui_substrate.hpp
index 4ba477f..32072b4 100644
--- a/packages/kernel/src/ui_substrate.hpp
+++ b/packages/kernel/src/ui_substrate.hpp
@@ -36,6 +36,11 @@ class RenderInterface_GL3;
namespace unbox::kernel {
+// The kernel's shared file watcher (src/file_watcher.hpp); the substrate borrows
+// it for (UNBOX_DEV-gated) asset hot-reload. Forward-declared to keep the header
+// free of inotify internals.
+class FileWatcher;
+
// Callback the substrate invokes to disable an extension whose data-event
// callback threw — injected by the kernel (Server::Impl). Mirrors the bus's
// detail::DisableSink but scoped to the substrate so ui.hpp carries no kernel
@@ -124,11 +129,11 @@ class Substrate {
public:
// Build the substrate on the wlr renderer's EGLDisplay. `egl_display` may
// be EGL_NO_DISPLAY (no gles2 renderer) — then available() is false and
- // create_surface yields nullptr. `loop` is the kernel's wl_event_loop, used
- // (dev only, UNBOX_DEV-gated) to poll the hot-reload inotify fd without ever
- // blocking; pass nullptr to disable watching. Never throws.
+ // create_surface yields nullptr. `watcher` is the kernel's ONE shared
+ // FileWatcher, used (dev only, UNBOX_DEV-gated) for asset hot-reload; pass
+ // nullptr to disable watching. Never throws.
static auto create(EGLDisplay egl_display, wlr_allocator* allocator,
- wlr_renderer* renderer, wl_event_loop* loop,
+ wlr_renderer* renderer, FileWatcher* watcher,
SubstrateDisableFn disable) -> std::unique_ptr<Substrate>;
~Substrate();