summaryrefslogtreecommitdiffhomepage
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
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/.
-rw-r--r--packages/kernel/include/unbox/kernel/host.hpp33
-rw-r--r--packages/kernel/include/unbox/kernel/watch.hpp86
-rw-r--r--packages/kernel/kernel.md12
-rw-r--r--packages/kernel/meson.build1
-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
-rw-r--r--packages/kernel/tests/test_kernel.cpp168
11 files changed, 679 insertions, 157 deletions
diff --git a/packages/kernel/include/unbox/kernel/host.hpp b/packages/kernel/include/unbox/kernel/host.hpp
index 4ac06a6..f68875d 100644
--- a/packages/kernel/include/unbox/kernel/host.hpp
+++ b/packages/kernel/include/unbox/kernel/host.hpp
@@ -2,9 +2,13 @@
#include <unbox/kernel/hooks.hpp>
#include <unbox/kernel/surface_registry.hpp>
+#include <unbox/kernel/watch.hpp>
#include <unbox/kernel/wlr.hpp>
#include <cstdint>
+#include <functional>
+#include <string>
+#include <string_view>
#include <typeindex>
namespace unbox::kernel {
@@ -193,6 +197,29 @@ public:
// present — UiSubstrate::available()/create_surface report that.)
[[nodiscard]] virtual auto ui() -> UiSubstrate& = 0;
+ // ---- File watching (config / asset hot-reload) ----
+ // Watch `path` for content changes. `on_change()` is invoked on the
+ // event-loop thread, COALESCED/debounced (one save = one call even though an
+ // editor emits several inotify events per save), whenever the file is
+ // written. EDITOR-SAFE: editors save by writing a temp file then renaming it
+ // over the target (the inode changes), so the kernel watches the containing
+ // DIRECTORY for the basename — it also fires when the file is CREATED if it
+ // does not exist yet. The callback is ERROR-ISOLATED to YOUR extension: a
+ // throw out of it disables your extension (same boundary as a hook/getter),
+ // never the session. RE-ENTRANCY-SAFE: a callback may create or destroy
+ // watches (including its own). The watch lives until the returned FileWatch
+ // handle is destroyed/reset — hold it as a member of your extension.
+ //
+ // Works regardless of UNBOX_DEV (config hot-reload is a user feature); the
+ // kernel creates the single shared inotify watcher lazily on the first
+ // watch. `path` should be the path you want to track (resolve it yourself if
+ // relative — the kernel watches it verbatim). A backend with no event loop
+ // yields an inactive handle (active() == false).
+ [[nodiscard]] auto watch_file(std::string_view path, std::function<void()> on_change)
+ -> FileWatch {
+ return register_file_watch(std::string(path), std::move(on_change));
+ }
+
// ---- Kernel event catalogue ----
// Subscribe through these to react to kernel-owned input/output. Each
// returns an Event/Filter you subscribe to with YOUR extension id (the
@@ -304,6 +331,12 @@ protected:
// Bind an extension-exported hook to the isolation registry (see adopt()).
virtual void adopt_hook(detail::HookBase& hook) = 0;
+ // Non-template file-watch core (the watch_file shim above injects YOUR id
+ // for error isolation). Registers on the kernel's shared inotify watcher.
+ [[nodiscard]] virtual auto register_file_watch(std::string path,
+ std::function<void()> on_change)
+ -> FileWatch = 0;
+
// The kernel-owned, session-wide surface->tree association store (shared by
// ALL extensions; the host_surface/scene_tree_for shims above route here).
[[nodiscard]] virtual auto surface_store() -> detail::PointerAssoc& = 0;
diff --git a/packages/kernel/include/unbox/kernel/watch.hpp b/packages/kernel/include/unbox/kernel/watch.hpp
new file mode 100644
index 0000000..70588a6
--- /dev/null
+++ b/packages/kernel/include/unbox/kernel/watch.hpp
@@ -0,0 +1,86 @@
+#pragma once
+
+#include <cstdint>
+
+// A typed, RAII file-watch primitive. The kernel owns ONE inotify instance on
+// the wl_event_loop (created lazily on the first watch); each FileWatch is a
+// move-only handle to one watched path, mirroring Subscription /
+// SurfaceRegistration. Destroying (or reset()/move-out) the handle stops the
+// watch. The watched file's on_change callback fires on the event-loop thread,
+// coalesced (one save = one callback), and is error-isolated to the extension
+// that registered it (a throw disables THAT extension, never the session).
+//
+// See Host::watch_file() for the registration entry point and the full
+// editor-save / not-yet-existing-file semantics.
+//
+// Single wl_event_loop thread throughout; no internal locking.
+
+namespace unbox::kernel {
+
+namespace detail {
+
+// The registry a FileWatch unregisters from. The kernel's FileWatcher
+// implements this; the handle holds a borrow + a token (like PointerAssoc's
+// token defense) so a stale handle can never tear down a reused slot. Abstract
+// so watch.hpp stays free of inotify/wl internals.
+class WatchRegistry {
+public:
+ using Token = std::uint64_t;
+ static constexpr Token invalid_token = 0;
+
+ virtual ~WatchRegistry() = default;
+ // Stop the watch identified by `token`. Idempotent / no-op if already gone.
+ virtual void remove_watch(Token token) noexcept = 0;
+
+protected:
+ WatchRegistry() = default;
+};
+
+} // namespace detail
+
+// A live file watch. Move-only RAII: destruction / reset() / move-out stops
+// watching. Hold it as a member of the entity that owns the watch (e.g. an
+// extension), so the watch's lifetime equals that entity's. A default-
+// constructed handle is inactive.
+class FileWatch {
+public:
+ FileWatch() = default;
+ FileWatch(detail::WatchRegistry* registry, detail::WatchRegistry::Token token)
+ : registry_(registry), token_(token) {}
+
+ FileWatch(FileWatch&& other) noexcept : registry_(other.registry_), token_(other.token_) {
+ other.registry_ = nullptr;
+ other.token_ = detail::WatchRegistry::invalid_token;
+ }
+ auto operator=(FileWatch&& other) noexcept -> FileWatch& {
+ if (this != &other) {
+ reset();
+ registry_ = other.registry_;
+ token_ = other.token_;
+ other.registry_ = nullptr;
+ other.token_ = detail::WatchRegistry::invalid_token;
+ }
+ return *this;
+ }
+ FileWatch(const FileWatch&) = delete;
+ auto operator=(const FileWatch&) -> FileWatch& = delete;
+
+ ~FileWatch() { reset(); }
+
+ // Stop watching early. Idempotent.
+ void reset() noexcept {
+ if (registry_ != nullptr) {
+ registry_->remove_watch(token_);
+ registry_ = nullptr;
+ token_ = detail::WatchRegistry::invalid_token;
+ }
+ }
+
+ [[nodiscard]] auto active() const noexcept -> bool { return registry_ != nullptr; }
+
+private:
+ detail::WatchRegistry* registry_ = nullptr;
+ detail::WatchRegistry::Token token_ = detail::WatchRegistry::invalid_token;
+};
+
+} // namespace unbox::kernel
diff --git a/packages/kernel/kernel.md b/packages/kernel/kernel.md
index 0bb8868..32f2a0d 100644
--- a/packages/kernel/kernel.md
+++ b/packages/kernel/kernel.md
@@ -138,6 +138,18 @@ spike retired into this — same GL bridge mechanics, now per-surface + real):
- **`Server::ui_*` probes are test instrumentation only** (frame_count,
orientation, fence_sync_active, touch override, element width) — replaced the
spike's `ui_spike_*`. Extensions drive the substrate via `Host::ui()`.
+- **The substrate does NOT own an inotify fd.** `UiSurfaceSpec::rml_path` asset
+ hot-reload (UNBOX_DEV-gated) is registered on the kernel's ONE shared
+ `FileWatcher` (`src/file_watcher.{hpp,cpp}`) — the same machinery that backs the
+ public `Host::watch_file`/`FileWatch` service (config hot-reload, UNGATED). Each
+ file-backed `Surface` holds a `FileWatch` whose callback flags it in
+ `pending_reloads`, applied (coalesced) at the next `tick_all` on the GL context.
+ ONE inotify instance per session, created lazily on the first watch (asset OR
+ watch_file). Only the *decision* to watch UI assets is UNBOX_DEV-gated; the
+ watcher infra is always available. Watches are dir-watches (editor temp+rename
+ safe) matched by basename; teardown order: extensions' FileWatch members →
+ substrate (surface FileWatches) → `Server::Impl::watcher.reset()` (removes the
+ wl_event_loop source while the loop is still alive) → display destroy.
Shared GL/EGL/dmabuf lessons (carried from the spike, still load-bearing):
diff --git a/packages/kernel/meson.build b/packages/kernel/meson.build
index a275793..0a25088 100644
--- a/packages/kernel/meson.build
+++ b/packages/kernel/meson.build
@@ -55,6 +55,7 @@ kernel_lib = static_library(
'src/kernel.cpp',
'src/server.cpp',
'src/input.cpp',
+ 'src/file_watcher.cpp',
'src/ui_substrate.cpp',
'src/rmlui_renderer_gl3.cpp',
# Listing the generated header as a source forces codegen before any kernel
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();
diff --git a/packages/kernel/tests/test_kernel.cpp b/packages/kernel/tests/test_kernel.cpp
index 22065b6..cc6d04e 100644
--- a/packages/kernel/tests/test_kernel.cpp
+++ b/packages/kernel/tests/test_kernel.cpp
@@ -1578,6 +1578,174 @@ TEST_CASE("substrate: a malformed hot-reload is isolated; old doc kept; recovers
}
// ============================================================================
+// slice-10 / watch_file SERVICE. The inotify-on-the-wl_event_loop machinery is
+// now a typed RAII kernel service (Host::watch_file -> FileWatch) backed by ONE
+// session inotify instance (shared with the substrate's asset hot-reload). Works
+// regardless of UNBOX_DEV. These run on the headless backend (no GL needed); we
+// pump the wl_event_loop in-test and use real temp files.
+// ============================================================================
+
+namespace {
+
+using unbox::kernel::FileWatch;
+
+// An extension that registers a watch_file on the path it is constructed with
+// and counts callbacks. Holds the FileWatch as a member (RAII).
+class WatchTestExtension : public unbox::kernel::Extension {
+public:
+ explicit WatchTestExtension(std::string path, bool throw_on_change = false)
+ : path_(std::move(path)), throw_(throw_on_change) {}
+ auto manifest() const -> const Manifest& override { return manifest_; }
+ void activate(Host& host) override {
+ watch_ = host.watch_file(path_, [this] {
+ ++hits;
+ if (throw_) {
+ throw std::runtime_error("watch callback boom");
+ }
+ });
+ }
+ [[nodiscard]] auto watch_active() const -> bool { return watch_.active(); }
+ void drop_watch() { watch_.reset(); }
+ int hits = 0;
+
+private:
+ std::string path_;
+ bool throw_;
+ Manifest manifest_{"watch-test", Tier::standard, {}};
+ FileWatch watch_;
+};
+
+// Pump the loop until `pred` is true or `max_turns` dispatches elapse.
+template <typename Pred>
+void pump_until(unbox::kernel::Server& s, Pred pred, int max_turns = 100) {
+ for (int i = 0; i < max_turns && !pred(); ++i) {
+ s.dispatch(20);
+ }
+}
+
+auto temp_path(const char* tag) -> std::filesystem::path {
+ auto dir = std::filesystem::temp_directory_path() / "unbox-kernel-tests";
+ std::error_code ec;
+ std::filesystem::create_directories(dir, ec);
+ return dir / (std::string("watch-") + tag + "-" + std::to_string(::getpid()) + ".txt");
+}
+
+} // namespace
+
+TEST_CASE("watch_file: fires on write, coalesced to one callback") {
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "pixman", 1); // no GL needed: this is the bare watcher
+
+ const auto path = temp_path("write");
+ std::error_code ec;
+ std::filesystem::remove(path, ec);
+ REQUIRE(write_file(path, "v1")); // exists before the watch
+
+ auto server = unbox::kernel::Server::create({});
+ auto* ext = new WatchTestExtension(path.string());
+ server->install(std::unique_ptr<unbox::kernel::Extension>(ext));
+ server->activate_extensions();
+ REQUIRE(ext->watch_active());
+
+ pump(*server, 3); // settle
+ CHECK(ext->hits == 0); // no write yet
+
+ REQUIRE(write_file(path, "v2")); // one save
+ pump_until(*server, [&] { return ext->hits >= 1; });
+ CHECK(ext->hits == 1); // fired, coalesced to once
+
+ // A second save fires again (still one per save).
+ REQUIRE(write_file(path, "v3"));
+ pump_until(*server, [&] { return ext->hits >= 2; });
+ CHECK(ext->hits == 2);
+
+ std::filesystem::remove(path, ec);
+}
+
+TEST_CASE("watch_file: fires when a not-yet-existing file is CREATED") {
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "pixman", 1);
+
+ const auto path = temp_path("create");
+ std::error_code ec;
+ std::filesystem::remove(path, ec); // ensure it does NOT exist
+
+ auto server = unbox::kernel::Server::create({});
+ auto* ext = new WatchTestExtension(path.string());
+ server->install(std::unique_ptr<unbox::kernel::Extension>(ext));
+ server->activate_extensions();
+ REQUIRE(ext->watch_active()); // watch armed on the (existing) parent dir
+
+ pump(*server, 3);
+ CHECK(ext->hits == 0);
+
+ REQUIRE(write_file(path, "born")); // create the file
+ pump_until(*server, [&] { return ext->hits >= 1; });
+ CHECK(ext->hits >= 1); // fired on CREATE
+
+ std::filesystem::remove(path, ec);
+}
+
+TEST_CASE("watch_file: RAII — after the handle is destroyed, no more callbacks") {
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "pixman", 1);
+
+ const auto path = temp_path("raii");
+ std::error_code ec;
+ std::filesystem::remove(path, ec);
+ REQUIRE(write_file(path, "v1"));
+
+ auto server = unbox::kernel::Server::create({});
+ auto* ext = new WatchTestExtension(path.string());
+ server->install(std::unique_ptr<unbox::kernel::Extension>(ext));
+ server->activate_extensions();
+
+ REQUIRE(write_file(path, "v2"));
+ pump_until(*server, [&] { return ext->hits >= 1; });
+ CHECK(ext->hits == 1);
+
+ // Drop the watch; a further write must NOT call back.
+ ext->drop_watch();
+ CHECK_FALSE(ext->watch_active());
+ REQUIRE(write_file(path, "v3"));
+ pump(*server, 10); // give inotify ample time
+ CHECK(ext->hits == 1); // unchanged
+
+ std::filesystem::remove(path, ec);
+}
+
+TEST_CASE("watch_file: a throwing on_change is isolated; the session survives") {
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "pixman", 1);
+
+ const auto path = temp_path("throw");
+ std::error_code ec;
+ std::filesystem::remove(path, ec);
+ REQUIRE(write_file(path, "v1"));
+
+ auto server = unbox::kernel::Server::create({});
+ auto* ext = new WatchTestExtension(path.string(), /*throw_on_change=*/true);
+ server->install(std::unique_ptr<unbox::kernel::Extension>(ext));
+ server->activate_extensions();
+
+ // The throwing callback must be caught at the watcher boundary: dispatch
+ // returns cleanly (no exception escapes the loop) and the server keeps
+ // running. (Its extension is disabled by the same isolation path as a
+ // throwing hook/getter; the session is unharmed.)
+ REQUIRE(write_file(path, "v2"));
+ bool dispatched_ok = true;
+ for (int i = 0; i < 100 && ext->hits == 0; ++i) {
+ dispatched_ok = server->dispatch(20) && dispatched_ok;
+ }
+ CHECK(ext->hits >= 1); // the callback ran (and threw)
+ CHECK(dispatched_ok); // the loop dispatched cleanly across the throw
+ // Session still alive: a further dispatch still succeeds.
+ CHECK(server->dispatch(5));
+
+ std::filesystem::remove(path, ec);
+}
+
+// ============================================================================
// VT-switch escape hatch — PURE CORE (no wlroots): keysym -> VT number. The
// glue (input.cpp) calls wlr_session_change_vt on a hit and consumes; this
// helper decides the hit. Ctrl+Alt+Fn arrives as XF86Switch_VT_1..12.