summaryrefslogtreecommitdiff
path: root/src/log_tail.c
diff options
context:
space:
mode:
authorauric <auric@japegames.com>2026-02-22 22:11:01 -0600
committerauric <auric@japegames.com>2026-02-22 22:11:01 -0600
commit7ff4624e67a6452f77d330c84b2ce6aee900b638 (patch)
tree5305f03bace310b008aec89c5209eb52a9e700f0 /src/log_tail.c
parent551c8aece8f30f7495b8340e36fbabd5f49e4705 (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'src/log_tail.c')
-rw-r--r--src/log_tail.c457
1 files changed, 366 insertions, 91 deletions
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 <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
+#include <dirent.h>
+#include <fnmatch.h>
#include <sys/epoll.h>
#include <sys/inotify.h>
+#include <sys/stat.h>
#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;
}