feat(cpp/core): add file_watcher_cpp_core (inotify Linux / RDCW Win)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
#include "file_watcher.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
|
||||
#if defined(__linux__)
|
||||
#include <sys/inotify.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <poll.h>
|
||||
#elif defined(_WIN32)
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
namespace fn {
|
||||
|
||||
#if defined(__linux__)
|
||||
|
||||
struct FileWatcher {
|
||||
int fd = -1;
|
||||
std::unordered_map<int, std::string> 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<FileEvent> file_watcher_poll(FileWatcher* w) {
|
||||
std::vector<FileEvent> 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<struct inotify_event*>(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<uint8_t> 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<WinWatch*> 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<FileEvent> file_watcher_poll(FileWatcher* w) {
|
||||
std::vector<FileEvent> 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<const FILE_NOTIFY_INFORMATION*>(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<FileEvent> file_watcher_poll(FileWatcher*) { return {}; }
|
||||
const char* file_watcher_last_error(const FileWatcher* w) { return w ? w->last_err.c_str() : ""; }
|
||||
|
||||
#endif
|
||||
|
||||
} // namespace fn
|
||||
@@ -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 <string>
|
||||
#include <vector>
|
||||
|
||||
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<FileEvent> 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
|
||||
@@ -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::FileEvent> 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<FileEvent> 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<FileEvent> 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.
|
||||
Reference in New Issue
Block a user