From 7ff4624e67a6452f77d330c84b2ce6aee900b638 Mon Sep 17 00:00:00 2001 From: auric Date: Sun, 22 Feb 2026 22:11:01 -0600 Subject: log_tail: add log_dir + log_pattern directory-watch mode Valve games (TF2, GMod) rotate into a new timestamped log file on every map change. The existing fixed-path inotify watch goes stale after the first rotation. This adds a directory-watch mode that auto-switches to the newest matching file whenever one appears. New YAML fields (mutually exclusive with logs:): log_dir: directory to watch for new log files log_pattern: fnmatch(3) glob for filenames; default "*" Changes: - umbrella.h: add log_dir[MAX_PATH] and log_dir_pattern[MAX_PATH] to Unit - log_tail.c: extend LogWatch with dir_wd/dir_path/pattern fields; add log_tail_drain, log_tail_scan_dir, log_tail_switch_file, log_tail_open_fixed_watch, log_tail_open_dir_watch, log_tail_reopen_fixed, log_tail_handle_rotation_dir; refactor log_tail_init, log_tail_handle, log_tail_cleanup - unit.c: parse log_dir and log_pattern YAML keys; warn and drop logs: if both are set on the same unit - AGENTS.md, README.md: document both log-tail modes Co-Authored-By: Claude Sonnet 4.6 --- src/log_tail.c | 457 +++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 366 insertions(+), 91 deletions(-) (limited to 'src/log_tail.c') diff --git a/src/log_tail.c b/src/log_tail.c index f943bbe..912c573 100644 --- a/src/log_tail.c +++ b/src/log_tail.c @@ -4,86 +4,357 @@ #include "unit.h" #include "log.h" +#include #include #include #include #include #include #include +#include +#include #include #include +#include #define MAX_LOG_WATCHES (MAX_UNITS * 4) typedef struct { int inotify_fd; - int watch_wd; - int log_fd; + int watch_wd; /* file watch descriptor, or -1 if no current file */ + int log_fd; /* log file fd, or -1 if no current file */ Unit *unit; char path[MAX_PATH]; + /* directory-mode fields (all -1 / "" when not in dir mode) */ + int dir_wd; /* directory watch descriptor, or -1 */ + char dir_path[MAX_PATH]; /* watched directory */ + char pattern[MAX_PATH]; /* fnmatch pattern copy */ } LogWatch; static LogWatch watches[MAX_LOG_WATCHES]; static int watch_count = 0; -void log_tail_init(void) { +/* Drain any available data from w->log_fd into ring + clients. */ +static void log_tail_drain(LogWatch *w) +{ + assert(w != NULL); + assert(w->unit != NULL); + if (w->log_fd < 0) return; + + char buf[RING_BUF_LINE_MAX]; + ssize_t n; + while ((n = read(w->log_fd, buf, sizeof(buf) - 1)) > 0) { + buf[n] = '\0'; + int nf = (int)n; + filter_apply(w->unit, buf, sizeof(buf), &nf); + if (nf <= 0) continue; + ring_push(w->unit->output, buf, nf); + client_broadcast_output(w->unit->name, buf, 0); + } +} + +/* + * Find the most-recently-modified file in dir matching pattern. + * Writes filename (not full path) into out_name[out_size]. + * Returns 0 on success, -1 if nothing found. + */ +static int log_tail_scan_dir(const char *dir, const char *pattern, + char *out_name, int out_size) +{ + assert(dir != NULL); + assert(pattern != NULL); + + DIR *d = opendir(dir); + if (!d) { + log_warn("log_tail: opendir %s: %s", dir, strerror(errno)); + return -1; + } + + struct dirent *ent; + char best_name[MAX_PATH] = {0}; + time_t best_sec = -1; + long best_nsec = -1; + + while ((ent = readdir(d)) != NULL) { + if (ent->d_name[0] == '.') continue; + if (fnmatch(pattern, ent->d_name, 0) != 0) continue; + + char full[MAX_PATH]; + int r = snprintf(full, sizeof(full), "%s/%s", dir, ent->d_name); + if (r < 0 || r >= (int)sizeof(full)) continue; + + struct stat st; + if (stat(full, &st) != 0) continue; + + if (best_sec < 0 || + st.st_mtim.tv_sec > best_sec || + (st.st_mtim.tv_sec == best_sec && + st.st_mtim.tv_nsec > best_nsec)) { + best_sec = st.st_mtim.tv_sec; + best_nsec = st.st_mtim.tv_nsec; + strncpy(best_name, ent->d_name, sizeof(best_name) - 1); + best_name[sizeof(best_name) - 1] = '\0'; + } + } + closedir(d); + + if (!best_name[0]) return -1; + strncpy(out_name, best_name, out_size - 1); + out_name[out_size - 1] = '\0'; + return 0; +} + +/* + * Drain old file, close its watch descriptor, open new_fname (relative to + * w->dir_path), and add a new file watch descriptor on the same inotify_fd. + * seek_end=1 at init (skip existing content), seek_end=0 for new files. + * Returns 0 on success, -1 on error. + */ +static int log_tail_switch_file(LogWatch *w, const char *new_fname, int seek_end) +{ + assert(w != NULL); + assert(new_fname != NULL); + + log_tail_drain(w); + + if (w->watch_wd >= 0) { + inotify_rm_watch(w->inotify_fd, w->watch_wd); + w->watch_wd = -1; + } + if (w->log_fd >= 0) { + close(w->log_fd); + w->log_fd = -1; + } + + char full[MAX_PATH]; + int r = snprintf(full, sizeof(full), "%s/%s", w->dir_path, new_fname); + if (r < 0 || r >= (int)sizeof(full)) { + log_warn("log_tail: path too long for %s/%s", w->dir_path, new_fname); + return -1; + } + + int fd = open(full, O_RDONLY | O_NONBLOCK | O_CLOEXEC); + if (fd < 0) { + log_warn("log_tail: cannot open %s: %s", full, strerror(errno)); + return -1; + } + if (seek_end) + lseek(fd, 0, SEEK_END); + + int wd = inotify_add_watch(w->inotify_fd, full, + IN_MODIFY | IN_MOVE_SELF | IN_DELETE_SELF); + if (wd < 0) { + log_warn("log_tail: inotify_add_watch %s: %s", full, strerror(errno)); + close(fd); + return -1; + } + + w->log_fd = fd; + w->watch_wd = wd; + strncpy(w->path, full, MAX_PATH - 1); + w->path[MAX_PATH - 1] = '\0'; + + log_info("log_tail: switched to %s for unit %s", full, w->unit->name); + return 0; +} + +/* Open a fixed-path log file and register it with inotify + epoll. */ +static void log_tail_open_fixed_watch(Unit *u, const char *path) +{ + assert(u != NULL); + assert(path != NULL); + + if (watch_count >= MAX_LOG_WATCHES) { + log_warn("log_tail: max watches reached, skipping %s", path); + return; + } + + char resolved[MAX_PATH]; + const char *open_path = path; + if (realpath(path, resolved) != NULL) + open_path = resolved; + + int log_fd = open(open_path, O_RDONLY | O_NONBLOCK | O_CLOEXEC); + if (log_fd < 0) { + log_warn("log_tail: cannot open %s: %s", open_path, strerror(errno)); + return; + } + lseek(log_fd, 0, SEEK_END); + + int ifd = inotify_init1(IN_NONBLOCK | IN_CLOEXEC); + if (ifd < 0) { + log_error("inotify_init1: %s", strerror(errno)); + close(log_fd); + return; + } + + int wd = inotify_add_watch(ifd, open_path, + IN_MODIFY | IN_MOVE_SELF | IN_DELETE_SELF); + if (wd < 0) { + log_warn("log_tail: inotify_add_watch %s: %s", + open_path, strerror(errno)); + close(ifd); + close(log_fd); + return; + } + + LogWatch *w = &watches[watch_count++]; + w->inotify_fd = ifd; + w->watch_wd = wd; + w->log_fd = log_fd; + w->unit = u; + w->dir_wd = -1; + w->dir_path[0] = '\0'; + w->pattern[0] = '\0'; + strncpy(w->path, open_path, MAX_PATH - 1); + w->path[MAX_PATH - 1] = '\0'; + + struct epoll_event ev; + ev.events = EPOLLIN; + ev.data.fd = ifd; + if (epoll_ctl(g.epoll_fd, EPOLL_CTL_ADD, ifd, &ev) < 0) + log_warn("log_tail: epoll_ctl ADD %s: %s", open_path, strerror(errno)); + + log_info("log_tail: watching %s for unit %s", open_path, u->name); +} + +/* Open a directory watch for a unit using log_dir mode. */ +static void log_tail_open_dir_watch(Unit *u) +{ + assert(u != NULL); + assert(u->log_dir[0] != '\0'); + + if (watch_count >= MAX_LOG_WATCHES) { + log_warn("log_tail: max watches reached, skipping dir %s", u->log_dir); + return; + } + + int ifd = inotify_init1(IN_NONBLOCK | IN_CLOEXEC); + if (ifd < 0) { + log_error("inotify_init1 for dir %s: %s", u->log_dir, strerror(errno)); + return; + } + + int dwd = inotify_add_watch(ifd, u->log_dir, IN_CREATE | IN_MOVED_TO); + if (dwd < 0) { + log_warn("log_tail: inotify_add_watch dir %s: %s", + u->log_dir, strerror(errno)); + close(ifd); + return; + } + + LogWatch *w = &watches[watch_count++]; + w->inotify_fd = ifd; + w->dir_wd = dwd; + w->watch_wd = -1; + w->log_fd = -1; + w->unit = u; + w->path[0] = '\0'; + strncpy(w->dir_path, u->log_dir, MAX_PATH - 1); + w->dir_path[MAX_PATH - 1] = '\0'; + strncpy(w->pattern, u->log_dir_pattern, MAX_PATH - 1); + w->pattern[MAX_PATH - 1] = '\0'; + + struct epoll_event ev; + ev.events = EPOLLIN; + ev.data.fd = ifd; + if (epoll_ctl(g.epoll_fd, EPOLL_CTL_ADD, ifd, &ev) < 0) + log_warn("log_tail: epoll_ctl ADD dir %s: %s", + u->log_dir, strerror(errno)); + + char best[MAX_PATH]; + if (log_tail_scan_dir(w->dir_path, w->pattern, best, sizeof(best)) == 0) + log_tail_switch_file(w, best, 1); + else + log_info("log_tail: no matching file yet in %s for unit %s", + u->log_dir, u->name); + + log_info("log_tail: watching dir %s (pattern %s) for unit %s", + u->log_dir, u->log_dir_pattern, u->name); +} + +/* Re-open a fixed-path watch after log rotation. */ +static void log_tail_reopen_fixed(LogWatch *w) +{ + assert(w != NULL); + assert(w->dir_wd < 0); + + if (w->watch_wd >= 0) { + inotify_rm_watch(w->inotify_fd, w->watch_wd); + w->watch_wd = -1; + } + if (w->log_fd >= 0) { + close(w->log_fd); + w->log_fd = -1; + } + + int log_fd = open(w->path, O_RDONLY | O_NONBLOCK | O_CLOEXEC); + if (log_fd < 0) { + log_warn("log_tail: cannot re-open %s after rotation: %s", + w->path, strerror(errno)); + return; + } + int wd = inotify_add_watch(w->inotify_fd, w->path, + IN_MODIFY | IN_MOVE_SELF | IN_DELETE_SELF); + if (wd < 0) { + log_warn("log_tail: inotify_add_watch after rotation %s: %s", + w->path, strerror(errno)); + close(log_fd); + return; + } + w->log_fd = log_fd; + w->watch_wd = wd; + log_info("log_tail: re-opened %s after rotation", w->path); +} + +/* Handle IN_MOVE_SELF / IN_DELETE_SELF on the current file in dir mode. */ +static void log_tail_handle_rotation_dir(LogWatch *w) +{ + assert(w != NULL); + assert(w->dir_wd >= 0); + + if (w->watch_wd >= 0) { + inotify_rm_watch(w->inotify_fd, w->watch_wd); + w->watch_wd = -1; + } + if (w->log_fd >= 0) { + close(w->log_fd); + w->log_fd = -1; + } + w->path[0] = '\0'; + + char best[MAX_PATH]; + if (log_tail_scan_dir(w->dir_path, w->pattern, best, sizeof(best)) == 0) + log_tail_switch_file(w, best, 0); +} + +void log_tail_init(void) +{ + assert(g.unit_count >= 0); + assert(g.unit_count <= MAX_UNITS); + watch_count = 0; log_info("log_tail_init: checking %d unit(s) for log paths", g.unit_count); for (int i = 0; i < g.unit_count; i++) { Unit *u = &g.units[i]; + if (u->log_dir[0]) { + if (!u->log_dir_pattern[0]) + strncpy(u->log_dir_pattern, "*", MAX_PATH - 1); + log_tail_open_dir_watch(u); + continue; + } log_info("log_tail_init: unit '%s' has %d log path(s)", u->name, u->log_count); - for (int j = 0; j < u->log_count; j++) { - const char *path = u->log_paths[j]; - if (!path[0]) continue; - /* Resolve symlinks so inotify watches the real file, not the link */ - char resolved[MAX_PATH]; - const char *open_path = path; - if (realpath(path, resolved) != NULL) - open_path = resolved; - int log_fd = open(open_path, O_RDONLY | O_NONBLOCK | O_CLOEXEC); - if (log_fd < 0) { - log_warn("log_tail: cannot open %s: %s", open_path, strerror(errno)); - continue; - } - lseek(log_fd, 0, SEEK_END); - int ifd = inotify_init1(IN_NONBLOCK | IN_CLOEXEC); - if (ifd < 0) { - log_error("inotify_init1: %s", strerror(errno)); - close(log_fd); - continue; - } - int wd = inotify_add_watch(ifd, open_path, - IN_MODIFY | IN_MOVE_SELF | IN_DELETE_SELF); - if (wd < 0) { - log_warn("log_tail: inotify_add_watch %s: %s", - open_path, strerror(errno)); - close(ifd); - close(log_fd); - continue; - } - if (watch_count >= MAX_LOG_WATCHES) { - log_warn("log_tail: max watches reached, skipping %s", open_path); - close(ifd); - close(log_fd); - continue; - } - watches[watch_count].inotify_fd = ifd; - watches[watch_count].watch_wd = wd; - watches[watch_count].log_fd = log_fd; - watches[watch_count].unit = u; - strncpy(watches[watch_count].path, open_path, MAX_PATH - 1); - watch_count++; - struct epoll_event ev; - ev.events = EPOLLIN; - ev.data.fd = ifd; - epoll_ctl(g.epoll_fd, EPOLL_CTL_ADD, ifd, &ev); - log_info("log_tail: watching %s for unit %s", open_path, u->name); - } + for (int j = 0; j < u->log_count; j++) + if (u->log_paths[j][0]) + log_tail_open_fixed_watch(u, u->log_paths[j]); } } -Unit *log_tail_fd_to_unit(int fd) { +Unit *log_tail_fd_to_unit(int fd) +{ + assert(fd >= 0); + assert(watch_count >= 0); for (int i = 0; i < watch_count; i++) { if (watches[i].inotify_fd == fd) return watches[i].unit; @@ -91,7 +362,10 @@ Unit *log_tail_fd_to_unit(int fd) { return NULL; } -static LogWatch *watch_for_inotify_fd(int fd) { +static LogWatch *watch_for_inotify_fd(int fd) +{ + assert(fd >= 0); + assert(watch_count >= 0); for (int i = 0; i < watch_count; i++) { if (watches[i].inotify_fd == fd) return &watches[i]; @@ -99,68 +373,69 @@ static LogWatch *watch_for_inotify_fd(int fd) { return NULL; } -void log_tail_handle(int inotify_fd) { +void log_tail_handle(int inotify_fd) +{ + assert(inotify_fd >= 0); + LogWatch *w = watch_for_inotify_fd(inotify_fd); if (!w) return; - /* Parse inotify events — detect log rotation (file moved or deleted) */ + assert(w->unit != NULL); + char evbuf[4096]; - int rotated = 0; + int rotated = 0; + int new_file = 0; + char new_fname[MAX_PATH] = {0}; ssize_t nread; + while ((nread = read(inotify_fd, evbuf, sizeof(evbuf))) > 0) { char *p = evbuf; while (p < evbuf + nread) { struct inotify_event *ie = (struct inotify_event *)p; if (ie->mask & (IN_MOVE_SELF | IN_DELETE_SELF)) rotated = 1; + if (w->dir_wd >= 0 && ie->wd == w->dir_wd && + (ie->mask & (IN_CREATE | IN_MOVED_TO)) && + ie->len > 0 && + fnmatch(w->pattern, ie->name, 0) == 0) { + new_file = 1; + strncpy(new_fname, ie->name, MAX_PATH - 1); + new_fname[MAX_PATH - 1] = '\0'; + } p += sizeof(struct inotify_event) + ie->len; } } - /* Drain any new data from the current log fd before re-opening */ - char buf[RING_BUF_LINE_MAX]; - ssize_t n; - while ((n = read(w->log_fd, buf, sizeof(buf) - 1)) > 0) { - buf[n] = '\0'; - int nf = (int)n; - filter_apply(w->unit, buf, sizeof(buf), &nf); - if (nf <= 0) continue; - ring_push(w->unit->output, buf, nf); - client_broadcast_output(w->unit->name, buf, 0); + log_tail_drain(w); + + if (new_file) { + /* Re-scan instead of using new_fname directly: handles the race + * where multiple files appear in one event batch — scan finds + * the definitively newest one. */ + char best[MAX_PATH]; + if (log_tail_scan_dir(w->dir_path, w->pattern, best, sizeof(best)) == 0) + log_tail_switch_file(w, best, 0); + return; } if (!rotated) return; - /* Log file was rotated — re-open the path to follow the new file */ - inotify_rm_watch(w->inotify_fd, w->watch_wd); - close(w->log_fd); - w->log_fd = -1; - - int log_fd = open(w->path, O_RDONLY | O_NONBLOCK | O_CLOEXEC); - if (log_fd < 0) { - log_warn("log_tail: cannot re-open %s after rotation: %s", - w->path, strerror(errno)); - return; - } - int wd = inotify_add_watch(w->inotify_fd, w->path, - IN_MODIFY | IN_MOVE_SELF | IN_DELETE_SELF); - if (wd < 0) { - log_warn("log_tail: inotify_add_watch after rotation %s: %s", - w->path, strerror(errno)); - close(log_fd); - return; - } - w->log_fd = log_fd; - w->watch_wd = wd; - log_info("log_tail: re-opened %s after rotation", w->path); + if (w->dir_wd >= 0) + log_tail_handle_rotation_dir(w); + else + log_tail_reopen_fixed(w); } -void log_tail_cleanup(void) { +void log_tail_cleanup(void) +{ + assert(watch_count >= 0); + assert(watch_count <= MAX_LOG_WATCHES); + for (int i = 0; i < watch_count; i++) { epoll_ctl(g.epoll_fd, EPOLL_CTL_DEL, watches[i].inotify_fd, NULL); - inotify_rm_watch(watches[i].inotify_fd, watches[i].watch_wd); - close(watches[i].inotify_fd); - close(watches[i].log_fd); + close(watches[i].inotify_fd); /* removes dir_wd and watch_wd */ + if (watches[i].log_fd >= 0) + close(watches[i].log_fd); } watch_count = 0; } -- cgit v1.2.3