From b43be6a3083da224904189a3a746d76ffffb0094 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 25 Apr 2026 21:00:38 +0200 Subject: [PATCH] feat(cpp/core): add file_watcher_cpp_core (inotify Linux / RDCW Win) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watcher de archivos no bloqueante con backend nativo por plataforma: - Linux: inotify_init1(IN_NONBLOCK | IN_CLOEXEC), inotify_add_watch con mascara MODIFY|CREATE|DELETE|CLOSE_WRITE|MOVED_*. Drain en cada poll(). - Windows: ReadDirectoryChangesW overlapped + GetOverlappedResult no bloqueante. Para vigilar un archivo, registra el directorio padre y filtra por nombre exacto en el poll(). - Otros: stub — poll() devuelve vacio y last_error() reporta no soportado. API en namespace fn:: con tipos opacos (FileWatcher PIMPL). Errores via last_error() en vez de excepciones. Documentadas las limitaciones (limite de inotify watches en Linux, granularidad directorio-level en Windows, no recursividad). Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/functions/core/file_watcher.cpp | 283 ++++++++++++++++++++++++++++ cpp/functions/core/file_watcher.h | 41 ++++ cpp/functions/core/file_watcher.md | 93 +++++++++ 3 files changed, 417 insertions(+) create mode 100644 cpp/functions/core/file_watcher.cpp create mode 100644 cpp/functions/core/file_watcher.h create mode 100644 cpp/functions/core/file_watcher.md diff --git a/cpp/functions/core/file_watcher.cpp b/cpp/functions/core/file_watcher.cpp new file mode 100644 index 00000000..a115f707 --- /dev/null +++ b/cpp/functions/core/file_watcher.cpp @@ -0,0 +1,283 @@ +#include "file_watcher.h" + +#include +#include +#include +#include + +#if defined(__linux__) + #include + #include + #include + #include + #include +#elif defined(_WIN32) + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #include +#endif + +namespace fn { + +#if defined(__linux__) + +struct FileWatcher { + int fd = -1; + std::unordered_map wd_to_path; // inotify wd -> path + std::string last_err; +}; + +FileWatcher* file_watcher_create() { + auto* w = new FileWatcher(); + w->fd = inotify_init1(IN_NONBLOCK | IN_CLOEXEC); + if (w->fd < 0) { + w->last_err = std::string("inotify_init1: ") + std::strerror(errno); + } + return w; +} + +void file_watcher_destroy(FileWatcher* w) { + if (!w) return; + if (w->fd >= 0) ::close(w->fd); + delete w; +} + +bool file_watcher_add(FileWatcher* w, const char* path) { + if (!w || w->fd < 0 || !path) return false; + const uint32_t mask = IN_MODIFY | IN_CREATE | IN_DELETE + | IN_CLOSE_WRITE | IN_MOVED_FROM | IN_MOVED_TO + | IN_DELETE_SELF | IN_MOVE_SELF; + int wd = inotify_add_watch(w->fd, path, mask); + if (wd < 0) { + w->last_err = std::string("inotify_add_watch(") + path + "): " + std::strerror(errno); + return false; + } + w->wd_to_path[wd] = path; + w->last_err.clear(); + return true; +} + +std::vector file_watcher_poll(FileWatcher* w) { + std::vector out; + if (!w || w->fd < 0) return out; + + // Drain todos los eventos pendientes sin bloquear. + char buf[4096] __attribute__((aligned(__alignof__(struct inotify_event)))); + while (true) { + ssize_t n = ::read(w->fd, buf, sizeof(buf)); + if (n <= 0) { + if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK) { + w->last_err = std::string("inotify read: ") + std::strerror(errno); + } + break; + } + for (char* p = buf; p < buf + n; ) { + auto* ev = reinterpret_cast(p); + FileEvent fe; + auto it = w->wd_to_path.find(ev->wd); + std::string base = (it != w->wd_to_path.end()) ? it->second : std::string(); + if (ev->len > 0) { + if (!base.empty() && base.back() != '/') base += '/'; + base += ev->name; + } + fe.path = base; + + if (ev->mask & (IN_CREATE | IN_MOVED_TO)) { + fe.kind = FileEvent::Created; + out.push_back(fe); + } + if (ev->mask & (IN_DELETE | IN_DELETE_SELF | IN_MOVED_FROM | IN_MOVE_SELF)) { + fe.kind = FileEvent::Deleted; + out.push_back(fe); + } + if (ev->mask & (IN_MODIFY | IN_CLOSE_WRITE)) { + fe.kind = FileEvent::Modified; + out.push_back(fe); + } + p += sizeof(struct inotify_event) + ev->len; + } + } + return out; +} + +const char* file_watcher_last_error(const FileWatcher* w) { + return w ? w->last_err.c_str() : ""; +} + +#elif defined(_WIN32) + +// Una entry por directorio vigilado: un OVERLAPPED + buffer + handle. +// Para "vigilar un archivo" en Windows registramos su directorio padre y +// filtramos por nombre en el poll(). +struct WinWatch { + HANDLE dir = INVALID_HANDLE_VALUE; + OVERLAPPED ovl = {}; + std::vector buf; + std::string dir_path; // directorio absoluto vigilado + std::string filter_name; // si !empty: solo emitir eventos cuyo path == dir_path/filter_name + bool pending = false; +}; + +struct FileWatcher { + std::vector watches; + std::string last_err; +}; + +static void start_read(WinWatch* ww) { + if (ww->dir == INVALID_HANDLE_VALUE) return; + DWORD bytes = 0; + BOOL ok = ::ReadDirectoryChangesW( + ww->dir, + ww->buf.data(), + (DWORD)ww->buf.size(), + FALSE, // bWatchSubtree + FILE_NOTIFY_CHANGE_FILE_NAME | + FILE_NOTIFY_CHANGE_DIR_NAME | + FILE_NOTIFY_CHANGE_LAST_WRITE| + FILE_NOTIFY_CHANGE_SIZE | + FILE_NOTIFY_CHANGE_CREATION, + &bytes, + &ww->ovl, + NULL); + ww->pending = (ok != 0); +} + +FileWatcher* file_watcher_create() { + return new FileWatcher(); +} + +void file_watcher_destroy(FileWatcher* w) { + if (!w) return; + for (auto* ww : w->watches) { + if (ww->dir != INVALID_HANDLE_VALUE) ::CloseHandle(ww->dir); + if (ww->ovl.hEvent) ::CloseHandle(ww->ovl.hEvent); + delete ww; + } + delete w; +} + +static std::string dirname_of(const std::string& path) { + size_t pos = path.find_last_of("\\/"); + if (pos == std::string::npos) return "."; + return path.substr(0, pos); +} +static std::string basename_of(const std::string& path) { + size_t pos = path.find_last_of("\\/"); + if (pos == std::string::npos) return path; + return path.substr(pos + 1); +} + +bool file_watcher_add(FileWatcher* w, const char* path) { + if (!w || !path) return false; + DWORD attrs = ::GetFileAttributesA(path); + if (attrs == INVALID_FILE_ATTRIBUTES) { + w->last_err = std::string("GetFileAttributes(") + path + "): not found"; + return false; + } + std::string dir, filter; + if (attrs & FILE_ATTRIBUTE_DIRECTORY) { + dir = path; + } else { + dir = dirname_of(path); + filter = basename_of(path); + } + HANDLE h = ::CreateFileA( + dir.c_str(), + FILE_LIST_DIRECTORY, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED, + NULL); + if (h == INVALID_HANDLE_VALUE) { + w->last_err = std::string("CreateFileA(") + dir + "): " + std::to_string(::GetLastError()); + return false; + } + auto* ww = new WinWatch(); + ww->dir = h; + ww->buf.resize(8192); + ww->ovl.hEvent = ::CreateEventA(NULL, TRUE, FALSE, NULL); + ww->dir_path = dir; + ww->filter_name = filter; + start_read(ww); + w->watches.push_back(ww); + w->last_err.clear(); + return true; +} + +static std::string narrow_w(const wchar_t* wstr, size_t wlen) { + if (wlen == 0) return {}; + int n = ::WideCharToMultiByte(CP_UTF8, 0, wstr, (int)wlen, NULL, 0, NULL, NULL); + std::string out(n, 0); + ::WideCharToMultiByte(CP_UTF8, 0, wstr, (int)wlen, out.data(), n, NULL, NULL); + return out; +} + +std::vector file_watcher_poll(FileWatcher* w) { + std::vector out; + if (!w) return out; + for (auto* ww : w->watches) { + if (!ww->pending) { start_read(ww); continue; } + DWORD bytes = 0; + BOOL ok = ::GetOverlappedResult(ww->dir, &ww->ovl, &bytes, FALSE); + if (!ok) { + // ERROR_IO_INCOMPLETE => sin novedades; ignorar + DWORD err = ::GetLastError(); + if (err != ERROR_IO_INCOMPLETE) { + w->last_err = std::string("GetOverlappedResult: ") + std::to_string(err); + ww->pending = false; + } + continue; + } + ww->pending = false; + ::ResetEvent(ww->ovl.hEvent); + + const uint8_t* p = ww->buf.data(); + const uint8_t* end = p + bytes; + while (p < end) { + auto* fni = reinterpret_cast(p); + std::string name = narrow_w(fni->FileName, fni->FileNameLength / sizeof(wchar_t)); + std::string full = ww->dir_path + "\\" + name; + + bool match = ww->filter_name.empty() || ww->filter_name == name; + if (match) { + FileEvent fe; + fe.path = full; + switch (fni->Action) { + case FILE_ACTION_ADDED: + case FILE_ACTION_RENAMED_NEW_NAME: + fe.kind = FileEvent::Created; out.push_back(fe); break; + case FILE_ACTION_REMOVED: + case FILE_ACTION_RENAMED_OLD_NAME: + fe.kind = FileEvent::Deleted; out.push_back(fe); break; + case FILE_ACTION_MODIFIED: + fe.kind = FileEvent::Modified; out.push_back(fe); break; + default: break; + } + } + if (fni->NextEntryOffset == 0) break; + p += fni->NextEntryOffset; + } + start_read(ww); + } + return out; +} + +const char* file_watcher_last_error(const FileWatcher* w) { + return w ? w->last_err.c_str() : ""; +} + +#else // Other platforms — stub + +struct FileWatcher { std::string last_err = "file_watcher: platform not supported"; }; + +FileWatcher* file_watcher_create() { return new FileWatcher(); } +void file_watcher_destroy(FileWatcher* w) { delete w; } +bool file_watcher_add(FileWatcher*, const char*) { return false; } +std::vector file_watcher_poll(FileWatcher*) { return {}; } +const char* file_watcher_last_error(const FileWatcher* w) { return w ? w->last_err.c_str() : ""; } + +#endif + +} // namespace fn diff --git a/cpp/functions/core/file_watcher.h b/cpp/functions/core/file_watcher.h new file mode 100644 index 00000000..fcfa817f --- /dev/null +++ b/cpp/functions/core/file_watcher.h @@ -0,0 +1,41 @@ +#pragma once + +// file_watcher — watcher cross-platform de archivos/directorios (impure I/O). +// +// Linux: inotify (mascara IN_MODIFY | IN_CREATE | IN_DELETE | IN_CLOSE_WRITE | IN_MOVED_*) +// Windows: ReadDirectoryChangesW (overlapped, no bloqueante en poll()) +// Otros: stub (poll() devuelve siempre vacio) +// +// API no bloqueante: poll() drena los eventos disponibles desde la ultima llamada. + +#include +#include + +namespace fn { + +struct FileWatcher; // PIMPL + +struct FileEvent { + enum Kind { Modified, Created, Deleted }; + std::string path; + Kind kind; +}; + +// Crea un watcher vacio. El caller llama destroy. +FileWatcher* file_watcher_create(); + +// Libera el watcher (cierra fd / handles). Acepta nullptr. +void file_watcher_destroy(FileWatcher* w); + +// Registra un path (archivo o directorio). Devuelve false si no existe o no se pudo +// anadir el watch (ej: en Linux, limite de inotify alcanzado). Tras false, llamar +// file_watcher_last_error() para obtener detalles. +bool file_watcher_add(FileWatcher* w, const char* path); + +// Devuelve los eventos acumulados desde la ultima llamada. No bloqueante. +std::vector file_watcher_poll(FileWatcher* w); + +// Devuelve el mensaje del ultimo error (vacio si no hay). +const char* file_watcher_last_error(const FileWatcher* w); + +} // namespace fn diff --git a/cpp/functions/core/file_watcher.md b/cpp/functions/core/file_watcher.md new file mode 100644 index 00000000..11deb877 --- /dev/null +++ b/cpp/functions/core/file_watcher.md @@ -0,0 +1,93 @@ +--- +name: file_watcher +kind: function +lang: cpp +domain: core +version: "1.0.0" +purity: impure +signature: "fn::FileWatcher* fn::file_watcher_create(); void fn::file_watcher_destroy(fn::FileWatcher*); bool fn::file_watcher_add(fn::FileWatcher*, const char* path); std::vector fn::file_watcher_poll(fn::FileWatcher*); const char* fn::file_watcher_last_error(const fn::FileWatcher*)" +description: "Watcher de archivos/directorios cross-platform (Linux inotify, Windows ReadDirectoryChangesW). API no bloqueante: registra paths con add() y consulta eventos con poll(). Cada poll() drena todos los eventos pendientes desde la llamada anterior." +tags: [filesystem, watcher, inotify, file_events, io] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [unistd, sys/inotify, windows] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/core/file_watcher.cpp" +params: [] +output: "FileWatcher opaco con cola interna de eventos. poll() devuelve std::vector con {path, kind in {Modified, Created, Deleted}}. Errores acumulados en last_error()." +--- + +# file_watcher + +Watcher de archivos no bloqueante con backend nativo por plataforma. Pareja natural de `text_editor_cpp_core` para el ciclo "edita -> guarda -> recompila" sin polling de timestamps. + +## API + +```cpp +namespace fn { + struct FileWatcher; + struct FileEvent { + enum Kind { Modified, Created, Deleted }; + std::string path; + Kind kind; + }; + + FileWatcher* file_watcher_create(); + void file_watcher_destroy(FileWatcher*); + bool file_watcher_add(FileWatcher*, const char* path); // file or dir + std::vector file_watcher_poll(FileWatcher*); // non-blocking, drain + const char* file_watcher_last_error(const FileWatcher*); +} +``` + +## Ejemplo + +```cpp +#include "core/file_watcher.h" + +auto* fw = fn::file_watcher_create(); +fn::file_watcher_add(fw, "/tmp/shader.glsl"); + +while (running) { + for (auto& ev : fn::file_watcher_poll(fw)) { + switch (ev.kind) { + case fn::FileEvent::Modified: reload(ev.path); break; + case fn::FileEvent::Created: std::printf("created: %s\n", ev.path.c_str()); break; + case fn::FileEvent::Deleted: std::printf("deleted: %s\n", ev.path.c_str()); break; + } + } + sleep_ms(16); +} + +fn::file_watcher_destroy(fw); +``` + +## Backends + +| Plataforma | Mecanismo | Notas | +|-----------|-----------|-------| +| Linux | `inotify_init1(IN_NONBLOCK)` + `inotify_add_watch` | mascara: MODIFY \| CREATE \| DELETE \| CLOSE_WRITE \| MOVED_* | +| Windows | `ReadDirectoryChangesW` overlapped + `GetOverlappedResult` no bloqueante | Para vigilar un archivo, registra el directorio padre y filtra por nombre | +| Otros | Stub — `poll()` devuelve vector vacio | `last_error()` indica "platform not supported" | + +## Limites y avisos + +- **inotify watch limit (Linux)**: por defecto `fs.inotify.max_user_watches = 8192`. Si lo superas, `add()` devuelve false y `last_error()` reporta `No space left on device`. Subirlo con: + ```bash + sudo sysctl fs.inotify.max_user_watches=524288 + ``` +- **Windows directorio-level**: cuando registras un archivo, internamente se vigila el directorio padre y se filtra por nombre exacto en el poll. Eventos de archivos hermanos se descartan. +- **No es recursivo** — `add()` registra el path dado, no su subarbol. Para vigilar un arbol, llama `add()` por cada subdirectorio (TODO si hace falta). +- **Editor "modify" coalescing**: editores como vim escriben usando un swap + rename, lo que produce CREATE + DELETE + MOVED_TO en vez de MODIFY puro. La mascara cubre MOVED_TO para que el evento llegue como `Created` (semantica "ahora hay un archivo nuevo en esa ruta") — el caller deduplica si lo necesita. + +## Decisiones de diseño + +- **PIMPL**: el header no expone `inotify_event` ni `OVERLAPPED`. `FileWatcher` es opaco. +- **No bloqueante**: el caller hace polling desde su loop principal (tipico ImGui ~60Hz). No threads, no callbacks. Mantenimiento bajo. +- **Errores como string**: no exception throwing. `add()` devuelve `bool` y `last_error()` da contexto. `error_type: stderr_string` en frontmatter. +- **Sin coalescing implicito**: el watcher emite todo lo que recibe del kernel. La app decide si dedup eventos cercanos en el tiempo.