diff options
| author | Adam Malczewski <[email protected]> | 2026-06-13 23:23:15 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-13 23:23:15 +0900 |
| commit | 35e5d32901c9a35700d3d8b046971dafc9bed5fe (patch) | |
| tree | f6fbee5ef84c0d78d7d8f15f44bdc21d177f063b | |
| parent | 37ff3e1762187198c6d38eebb20ea37c2c937c96 (diff) | |
| download | unbox-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.hpp | 33 | ||||
| -rw-r--r-- | packages/kernel/include/unbox/kernel/watch.hpp | 86 | ||||
| -rw-r--r-- | packages/kernel/kernel.md | 12 | ||||
| -rw-r--r-- | packages/kernel/meson.build | 1 | ||||
| -rw-r--r-- | packages/kernel/src/file_watcher.cpp | 196 | ||||
| -rw-r--r-- | packages/kernel/src/file_watcher.hpp | 87 | ||||
| -rw-r--r-- | packages/kernel/src/server.cpp | 29 | ||||
| -rw-r--r-- | packages/kernel/src/server_impl.hpp | 19 | ||||
| -rw-r--r-- | packages/kernel/src/ui_substrate.cpp | 192 | ||||
| -rw-r--r-- | packages/kernel/src/ui_substrate.hpp | 13 | ||||
| -rw-r--r-- | packages/kernel/tests/test_kernel.cpp | 168 |
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. |
