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:
2026-04-25 21:00:38 +02:00
parent 3ad63f79dc
commit b43be6a308
3 changed files with 417 additions and 0 deletions
+283
View File
@@ -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
+41
View File
@@ -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
+93
View File
@@ -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.