Replace the O(N) scan of watched names on each directory inotify event
with a Hash_table keyed by (parent_wd, basename), addressing the
long-standing FIXME in tail_forever_inotify.

Keep ownership consistent with wd_to_name: parent_by_name is created
only for Follow_name and returned to the caller, so fallback paths can
return without local hash cleanup.

Matching cost for N directory events that each compare against N names
(illustrative host timings of the scan vs hash lookup alone):

  N=1000:  ~0.018s -> ~0.000s
  N=5000:  ~0.39s  -> ~0.000s
  N=10000: ~1.5s   -> ~0.000s

End-to-end recreate storms remain largely file-system bound on this
host; the hash removes the quadratic name-matching CPU from that path.

* src/tail.c (parent_name_hasher, parent_name_comparator): New.
(tail_forever_inotify): Build and use parent_by_name; return it to the
caller.
(main): Free parent_ht when non-NULL.
* tests/tail/inotify-parent-hash.sh: Require notification mode; cover
multiple names in one directory and the same basename in two
directories.
* tests/local.mk: Reference the new test.
* NEWS: Mention the improvement.

Signed-off-by: Iván Ezequiel Rodriguez <[email protected]>
---
 NEWS                              |   4 ++
 src/tail.c                        |  84 +++++++++++++++++-----
 tests/local.mk                    |   1 +
 tests/tail/inotify-parent-hash.sh | 116 ++++++++++++++++++++++++++++++
 4 files changed, 188 insertions(+), 17 deletions(-)
 create mode 100755 tests/tail/inotify-parent-hash.sh

diff --git a/NEWS b/NEWS
index 17a6333d3..2e3f1dbd6 100644
--- a/NEWS
+++ b/NEWS
@@ -95,6 +95,10 @@ GNU coreutils NEWS                                    -*- 
outline -*-
 
 ** Improvements
 
+  'tail -F' now matches directory inotify events with a hash keyed by
+  (parent watch descriptor, basename), avoiding an O(N) scan per event when
+  following many files by name.
+
   When built with the configure option '--with-wtmpdb', invocations of
   'who /var/log/wtmp' and 'users /var/log/wtmp' use the wtmpdb database
   instead of the file /var/log/wtmp.  This makes them Y2038-safe.
diff --git a/src/tail.c b/src/tail.c
index 2ca27e835..efeb8bfb4 100644
--- a/src/tail.c
+++ b/src/tail.c
@@ -1428,6 +1428,30 @@ wd_comparator (const void *e1, const void *e2)
   return spec1->wd == spec2->wd;
 }
 
+/* Hash Follow_name watches by parent directory wd + basename so directory
+   inotify events can be matched in expected O(1) instead of scanning all
+   files.  */
+static size_t
+parent_name_hasher (const void *entry, size_t tabsize)
+{
+  const struct File_spec *spec = entry;
+  char const *name = spec->name + spec->basename_start;
+  size_t value = spec->parent_wd;
+  for (unsigned char c; (c = *name); name++)
+    value = value * 31 + c;
+  return value % tabsize;
+}
+
+static bool
+parent_name_comparator (const void *e1, const void *e2)
+{
+  const struct File_spec *a = e1;
+  const struct File_spec *b = e2;
+  return (a->parent_wd == b->parent_wd
+          && streq (a->name + a->basename_start,
+                    b->name + b->basename_start));
+}
+
 /* Output (new) data for FSPEC->fd.
    PREV_FSPEC records the last File_spec for which we output.  */
 static void
@@ -1476,10 +1500,17 @@ check_fspec (struct File_spec *fspec, struct File_spec 
**prev_fspec)
 
 /* Attempt to tail N_FILES files forever, or until killed.
    Check modifications using the inotify events system.
-   Exit if finished or on fatal error; return to revert to polling.  */
+   Exit if finished or on fatal error; return to revert to polling.
+
+   On return (to revert to polling), *WD_TO_NAMEP and *PARENT_BY_NAMEP
+   hold hash tables the caller must free.  *PARENT_BY_NAMEP is non-NULL
+   only when follow_mode == Follow_name; otherwise it is set to NULL.
+   Like WD_TO_NAME, PARENT_BY_NAME is owned by the caller so return
+   paths need not free it (and must not call hash_free (NULL)).  */
 static void
 tail_forever_inotify (int wd, struct File_spec *f, int n_files,
-                      double sleep_interval, Hash_table **wd_to_namep)
+                      double sleep_interval, Hash_table **wd_to_namep,
+                      Hash_table **parent_by_namep)
 {
 # if TAIL_TEST_SLEEP
   /* Delay between open() and inotify_add_watch()
@@ -1491,6 +1522,9 @@ tail_forever_inotify (int wd, struct File_spec *f, int 
n_files,
   /* Map an inotify watch descriptor to the name of the file it's watching.  */
   Hash_table *wd_to_name;
 
+  /* Map (parent_wd, basename) -> File_spec for Follow_name directory events.  
*/
+  Hash_table *parent_by_name = NULL;
+
   bool found_watchable_file = false;
   bool tailed_but_unwatchable = false;
   bool found_unwatchable_dir = false;
@@ -1500,12 +1534,23 @@ tail_forever_inotify (int wd, struct File_spec *f, int 
n_files,
   char *evbuf;
   idx_t evbuf_off = 0;
 
+  *parent_by_namep = NULL;
+
   wd_to_name = hash_initialize (n_files, NULL, wd_hasher, wd_comparator,
                                 NULL);
   if (! wd_to_name)
     xalloc_die ();
   *wd_to_namep = wd_to_name;
 
+  if (follow_mode == Follow_name)
+    {
+      parent_by_name = hash_initialize (n_files, NULL, parent_name_hasher,
+                                        parent_name_comparator, NULL);
+      if (! parent_by_name)
+        xalloc_die ();
+      *parent_by_namep = parent_by_name;
+    }
+
   /* The events mask used with inotify on files (not directories).  */
   uint32_t inotify_wd_mask = IN_MODIFY;
   /* TODO: Perhaps monitor these events in Follow_descriptor mode also,
@@ -1555,6 +1600,9 @@ tail_forever_inotify (int wd, struct File_spec *f, int 
n_files,
                      of the inotify API will still be diagnosed.  */
                   break;
                 }
+
+              if (hash_insert (parent_by_name, &(f[i])) == NULL)
+                xalloc_die ();
             }
 
           f[i].wd = inotify_add_watch (wd, f[i].name, inotify_wd_mask);
@@ -1733,29 +1781,27 @@ tail_forever_inotify (int wd, struct File_spec *f, int 
n_files,
 
       if (ev->len) /* event on ev->name in watched directory.  */
         {
-          int j;
-          for (j = 0; j < n_files; j++)
-            {
-              /* With N=hundreds of frequently-changing files, this O(N^2)
-                 process might be a problem.  FIXME: use a hash table?  */
-              if (f[j].parent_wd == ev->wd
-                  && streq (ev->name, f[j].name + f[j].basename_start))
-                break;
-            }
+          struct File_spec key;
+          if (! parent_by_name)
+            continue;
+
+          /* Probe key: basename is the whole string at KEY.NAME.  */
+          key.parent_wd = ev->wd;
+          key.name = ev->name;
+          key.basename_start = 0;
+          fspec = hash_lookup (parent_by_name, &key);
 
           /* It is not a watched file.  */
-          if (j == n_files)
+          if (! fspec)
             continue;
 
-          fspec = &(f[j]);
-
           int new_wd = -1;
           bool deleting = !! (ev->mask & IN_DELETE);
 
           if (! deleting)
             {
               /* Adding the same inode again will look up any existing wd.  */
-              new_wd = inotify_add_watch (wd, f[j].name, inotify_wd_mask);
+              new_wd = inotify_add_watch (wd, fspec->name, inotify_wd_mask);
             }
 
           if (! deleting && new_wd < 0)
@@ -1768,7 +1814,7 @@ tail_forever_inotify (int wd, struct File_spec *f, int 
n_files,
               else
                 {
                   /* Can get ENOENT for a dangling symlink for example.  */
-                  error (0, errno, _("cannot watch %s"), quoteaf (f[j].name));
+                  error (0, errno, _("cannot watch %s"), quoteaf 
(fspec->name));
                 }
               /* We'll continue below after removing the existing watch.  */
             }
@@ -2517,8 +2563,12 @@ main (int argc, char **argv)
                 write_error ();
 
               Hash_table *ht;
-              tail_forever_inotify (wd, F, n_files, sleep_interval, &ht);
+              Hash_table *parent_ht = NULL;
+              tail_forever_inotify (wd, F, n_files, sleep_interval,
+                                    &ht, &parent_ht);
               hash_free (ht);
+              if (parent_ht)
+                hash_free (parent_ht);
               close (wd);
               errno = 0;
             }
diff --git a/tests/local.mk b/tests/local.mk
index 33abb9d72..7ad6b852d 100644
--- a/tests/local.mk
+++ b/tests/local.mk
@@ -193,6 +193,7 @@ all_tests =                                 \
   tests/tail/basic-seek.sh                     \
   tests/tail/inotify-hash-abuse.sh             \
   tests/tail/inotify-hash-abuse2.sh            \
+  tests/tail/inotify-parent-hash.sh            \
   tests/tail/F-vs-missing.sh                   \
   tests/tail/F-vs-rename.sh                    \
   tests/tail/F-headers.sh                      \
diff --git a/tests/tail/inotify-parent-hash.sh 
b/tests/tail/inotify-parent-hash.sh
new file mode 100755
index 000000000..0f430dd84
--- /dev/null
+++ b/tests/tail/inotify-parent-hash.sh
@@ -0,0 +1,116 @@
+#!/bin/sh
+# Exercise Follow_name directory-event lookup keyed by (parent_wd, basename).
+# Covers several names in one directory and the same basename in two dirs.
+
+# Copyright (C) 2026 Free Software Foundation, Inc.
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+. "${srcdir=.}/tests/init.sh"; path_prepend_ ./src
+print_ver_ tail
+require_inotify_supported_
+
+check_tail_output()
+{
+  local delay="$1"
+  grep "$tail_re" out > /dev/null ||
+    { sleep $delay; return 1; }
+}
+
+cleanup_() { kill $pid 2>/dev/null && wait $pid; }
+
+cleanup_fail()
+{
+  cat out
+  warn_ "$1"
+  cleanup_
+  fail=1
+}
+
+# Ensure this run actually uses inotify (not a silent fallback to polling).
+assert_notification_mode_()
+{
+  inotify_failed_re='inotify (resources exhausted|cannot be used)'
+  grep -E "$inotify_failed_re" out &&
+    skip_ "inotify can't be used"
+  tail_re='using notification mode' retry_delay_ check_tail_output .1 7 ||
+    cleanup_fail 'tail did not use notification mode'
+}
+
+# --- Several basenames in the same parent directory. ---
+mkdir d || framework_failure_
+touch d/a d/b d/c || framework_failure_
+
+rm -f out
+timeout 60 tail --debug -qF d/a d/b d/c > out 2>&1 & pid=$!
+
+assert_notification_mode_
+
+echo a1 > d/a || framework_failure_
+tail_re='^a1$' retry_delay_ check_tail_output .1 7 ||
+  cleanup_fail 'missing a1'
+
+echo b1 > d/b || framework_failure_
+tail_re='^b1$' retry_delay_ check_tail_output .1 7 ||
+  cleanup_fail 'missing b1'
+
+echo c1 > d/c || framework_failure_
+tail_re='^c1$' retry_delay_ check_tail_output .1 7 ||
+  cleanup_fail 'missing c1'
+
+# Recreate each name so directory CREATE events must resolve the right
+# File_spec via (parent_wd, basename), not a linear scan of the wrong peer.
+rm -f d/a d/b d/c || framework_failure_
+echo a2 > d/a || framework_failure_
+tail_re='^a2$' retry_delay_ check_tail_output .1 7 ||
+  cleanup_fail 'missing a2'
+echo b2 > d/b || framework_failure_
+tail_re='^b2$' retry_delay_ check_tail_output .1 7 ||
+  cleanup_fail 'missing b2'
+echo c2 > d/c || framework_failure_
+tail_re='^c2$' retry_delay_ check_tail_output .1 7 ||
+  cleanup_fail 'missing c2'
+
+kill -0 $pid || fail=1
+cleanup_
+
+# --- Same basename in two different parent directories. ---
+mkdir e1 e2 || framework_failure_
+touch e1/log e2/log || framework_failure_
+
+rm -f out
+timeout 60 tail --debug -qF e1/log e2/log > out 2>&1 & pid=$!
+
+assert_notification_mode_
+
+echo e1x > e1/log || framework_failure_
+tail_re='^e1x$' retry_delay_ check_tail_output .1 7 ||
+  cleanup_fail 'missing e1x'
+
+echo e2x > e2/log || framework_failure_
+tail_re='^e2x$' retry_delay_ check_tail_output .1 7 ||
+  cleanup_fail 'missing e2x'
+
+rm -f e1/log e2/log || framework_failure_
+echo e1y > e1/log || framework_failure_
+tail_re='^e1y$' retry_delay_ check_tail_output .1 7 ||
+  cleanup_fail 'missing e1y'
+echo e2y > e2/log || framework_failure_
+tail_re='^e2y$' retry_delay_ check_tail_output .1 7 ||
+  cleanup_fail 'missing e2y'
+
+kill -0 $pid || fail=1
+cleanup_
+
+Exit $fail
-- 
2.43.0




Reply via email to