Re: [BUG] fanotify: destroy/add race leaves a mark on a detached connector
From: Daehyeon Ko
Date: Wed Sep 02 2026 - 17:56:58 EST
Hi Amir,
I was able to make the handle-reopen sequence reliable on ext4 and drafted
an LTP test for it.
The test keeps 120 auxiliary inode marks on the victim to widen the interval
after the first FAN_DELETE_SELF, pins the unlink and handle-reopen workers to
different CPUs, and has the reopen worker read the event queue nonblocking
before calling open_by_handle_at().
I ran the exact final test for 100 iterations in each of two fresh 2-vCPU
KASAN boots per kernel:
- unmodified v7.2: 200/200 iterations reopened the unlinked inode, and
closing the reopened fd generated a second FAN_DELETE_SELF;
- v7.2 with your patch: 200/200 iterations returned ESTALE from
open_by_handle_at(), with no duplicate FAN_DELETE_SELF.
The test is based on LTP master commit
12724413534a6d4160ff9694ba6f09daa4ccb6bd. The attached patch adds
fanotify26 and the corresponding build and runtest entries.
Thanks,
Daehyeon
From 17e8b45856b012f4a97b5f37b66cf27fd80527cf Mon Sep 17 00:00:00 2001
From: Daehyeon Ko <4ncienth@gmail.com>
Date: Wed, 2 Sep 2026 19:13:59 +0900
Subject: [PATCH] fanotify: test duplicate FAN_DELETE_SELF after handle reopen
Add a regression test for reopening an unlinked inode by file handle after FAN_DELETE_SELF has already been reported.
Use an ext4 filesystem, keep inode marks on the victim to widen the live-inode window, and place the unlink and handle-reopen workers on separate CPUs. On affected kernels, open_by_handle_at() creates a disconnected alias and closing it generates a second FAN_DELETE_SELF event. With the fix, open_by_handle_at() returns ESTALE and no duplicate event is observed.
Signed-off-by: Daehyeon Ko <4ncienth@gmail.com>
---
runtest/syscalls | 1 +
testcases/kernel/syscalls/fanotify/Makefile | 2 +-
.../kernel/syscalls/fanotify/fanotify26.c | 333 ++++++++++++++++++
3 files changed, 335 insertions(+), 1 deletion(-)
create mode 100644 testcases/kernel/syscalls/fanotify/fanotify26.c
diff --git a/runtest/syscalls b/runtest/syscalls
index 737c63e31..7cac78b34 100644
--- a/runtest/syscalls
+++ b/runtest/syscalls
@@ -689,6 +689,7 @@ fanotify22 fanotify22
fanotify23 fanotify23
fanotify24 fanotify24
fanotify25 fanotify25
+fanotify26 fanotify26
ioperm01 ioperm01
ioperm02 ioperm02
diff --git a/testcases/kernel/syscalls/fanotify/Makefile b/testcases/kernel/syscalls/fanotify/Makefile
index b20bb50e9..6f14a9b4f 100644
--- a/testcases/kernel/syscalls/fanotify/Makefile
+++ b/testcases/kernel/syscalls/fanotify/Makefile
@@ -2,7 +2,7 @@
# Copyright (c) Jan Kara <jack@suse.cz>, 2013
top_srcdir ?= ../../../..
-fanotify11 fanotify21: CFLAGS+=-pthread
+fanotify11 fanotify21 fanotify26: CFLAGS+=-pthread
include $(top_srcdir)/include/mk/testcases.mk
include $(top_srcdir)/include/mk/generic_leaf_target.mk
diff --git a/testcases/kernel/syscalls/fanotify/fanotify26.c b/testcases/kernel/syscalls/fanotify/fanotify26.c
new file mode 100644
index 000000000..2d1f4d263
--- /dev/null
+++ b/testcases/kernel/syscalls/fanotify/fanotify26.c
@@ -0,0 +1,333 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ * Copyright (c) 2026 Daehyeon Ko <4ncienth@gmail.com>
+ */
+
+/*\
+ * Verify that an unlinked inode cannot be reopened by file handle after
+ * FAN_DELETE_SELF has been reported.
+ *
+ * Keep several inode marks on the victim to widen the interval between the
+ * first FAN_DELETE_SELF event and the final inode put. A worker waiting for
+ * the event tries to reopen the inode by handle during that interval.
+ *
+ * Before the fix, open_by_handle_at(2) can instantiate a new disconnected
+ * dentry for the unlinked inode. Closing that file generates a second
+ * FAN_DELETE_SELF event. After the fix, open_by_handle_at(2) fails with
+ * ESTALE and only one FAN_DELETE_SELF event is generated.
+ */
+
+#define _GNU_SOURCE
+#include "config.h"
+
+#include <errno.h>
+#include <poll.h>
+#include <pthread.h>
+#include <sched.h>
+#include <string.h>
+#include <sys/resource.h>
+#include <time.h>
+#include "tst_test.h"
+#include "tst_safe_pthread.h"
+#include "lapi/name_to_handle_at.h"
+
+#ifdef HAVE_SYS_FANOTIFY_H
+#include "fanotify.h"
+
+#define MOUNT_PATH "mntpoint"
+#define TEST_FILE MOUNT_PATH "/testfile"
+#define PIN_GROUPS 120
+#define EVENT_BUF_LEN 4096
+#define EVENT_TIMEOUT_MS 5000
+
+#if defined(HAVE_NAME_TO_HANDLE_AT)
+static int fanotify_fd = -1;
+static int mount_fd = -1;
+static int pin_fds[PIN_GROUPS];
+static struct fanotify_fid_t victim_fid;
+static pthread_barrier_t barrier;
+static cpu_set_t original_mask;
+static cpu_set_t unlink_mask;
+static cpu_set_t reopen_mask;
+static int affinity_changed;
+
+struct worker_result {
+ int event_status;
+ int event_errno;
+ int reopened_fd;
+ int reopen_errno;
+ int affinity_errno;
+};
+
+static int event_matches_victim(struct fanotify_event_metadata *metadata)
+{
+ struct fanotify_event_info_fid *info;
+ struct file_handle *handle;
+
+ if (!(metadata->mask & FAN_DELETE_SELF))
+ return 0;
+
+ info = get_event_info_fid(metadata);
+ if (!info)
+ return 0;
+
+ handle = (struct file_handle *)info->handle;
+ if (handle->handle_type != victim_fid.handle.handle_type ||
+ handle->handle_bytes != victim_fid.handle.handle_bytes)
+ return 0;
+
+ return !memcmp(handle->f_handle, victim_fid.handle.f_handle,
+ handle->handle_bytes);
+}
+
+static int find_victim_event(char *buf, int len)
+{
+ struct fanotify_event_metadata *metadata;
+
+ for (metadata = (struct fanotify_event_metadata *)buf;
+ FAN_EVENT_OK(metadata, len);
+ metadata = FAN_EVENT_NEXT(metadata, len)) {
+ if (event_matches_victim(metadata))
+ return 1;
+ }
+
+ return 0;
+}
+
+static int read_victim_event(int timeout_ms, int *saved_errno)
+{
+ char buf[EVENT_BUF_LEN];
+ struct pollfd pfd = {
+ .fd = fanotify_fd,
+ .events = POLLIN,
+ };
+ int len, ret;
+
+ ret = poll(&pfd, 1, timeout_ms);
+ if (ret <= 0) {
+ *saved_errno = ret ? errno : ETIMEDOUT;
+ return -1;
+ }
+
+ len = read(fanotify_fd, buf, sizeof(buf));
+ if (len < 0) {
+ *saved_errno = errno;
+ return -1;
+ }
+
+ if (find_victim_event(buf, len))
+ return 1;
+
+ *saved_errno = ENOMSG;
+ return -1;
+}
+
+static int spin_read_victim_event(int timeout_ms, int *saved_errno)
+{
+ struct timespec start, now;
+ char buf[EVENT_BUF_LEN];
+ long elapsed_ms;
+ int len;
+
+ if (clock_gettime(CLOCK_MONOTONIC, &start)) {
+ *saved_errno = errno;
+ return -1;
+ }
+
+ do {
+ len = read(fanotify_fd, buf, sizeof(buf));
+ if (len > 0) {
+ if (find_victim_event(buf, len))
+ return 1;
+ } else if (len < 0 && errno != EAGAIN) {
+ *saved_errno = errno;
+ return -1;
+ }
+
+ if (clock_gettime(CLOCK_MONOTONIC, &now)) {
+ *saved_errno = errno;
+ return -1;
+ }
+ elapsed_ms = (now.tv_sec - start.tv_sec) * 1000 +
+ (now.tv_nsec - start.tv_nsec) / 1000000;
+ } while (elapsed_ms < timeout_ms);
+
+ *saved_errno = ETIMEDOUT;
+ return -1;
+}
+
+static void *reopen_after_delete(void *arg)
+{
+ struct worker_result *result = arg;
+
+ result->affinity_errno = pthread_setaffinity_np(pthread_self(),
+ sizeof(reopen_mask),
+ &reopen_mask);
+ pthread_barrier_wait(&barrier);
+ if (result->affinity_errno)
+ return NULL;
+
+ result->event_status = spin_read_victim_event(EVENT_TIMEOUT_MS,
+ &result->event_errno);
+ if (result->event_status < 0)
+ return NULL;
+
+ result->reopened_fd = open_by_handle_at(mount_fd, &victim_fid.handle,
+ O_RDONLY | O_CLOEXEC);
+ result->reopen_errno = errno;
+ return NULL;
+}
+
+static void setup(void)
+{
+ unsigned int i, cpus = 0;
+
+ for (i = 0; i < PIN_GROUPS; i++)
+ pin_fds[i] = -1;
+
+ REQUIRE_FANOTIFY_EVENTS_SUPPORTED_ON_FS(FAN_REPORT_FID,
+ FAN_MARK_FILESYSTEM, FAN_DELETE_SELF, MOUNT_PATH);
+
+ fanotify_fd = SAFE_FANOTIFY_INIT(FAN_CLASS_NOTIF | FAN_REPORT_FID |
+ FAN_NONBLOCK | FAN_CLOEXEC,
+ O_RDONLY | O_LARGEFILE);
+ SAFE_FANOTIFY_MARK(fanotify_fd, FAN_MARK_ADD | FAN_MARK_FILESYSTEM,
+ FAN_DELETE_SELF, AT_FDCWD, MOUNT_PATH);
+ mount_fd = SAFE_OPEN(MOUNT_PATH, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+
+ if (sched_getaffinity(0, sizeof(original_mask), &original_mask))
+ tst_brk(TBROK | TERRNO, "sched_getaffinity() failed");
+
+ CPU_ZERO(&unlink_mask);
+ CPU_ZERO(&reopen_mask);
+ for (i = 0; i < CPU_SETSIZE && cpus < 2; i++) {
+ if (!CPU_ISSET(i, &original_mask))
+ continue;
+ if (!cpus++)
+ CPU_SET(i, &unlink_mask);
+ else
+ CPU_SET(i, &reopen_mask);
+ }
+ if (cpus < 2)
+ tst_brk(TCONF, "Test needs two CPUs in its affinity mask");
+
+ for (i = 0; i < PIN_GROUPS; i++)
+ pin_fds[i] = SAFE_FANOTIFY_INIT(FAN_CLASS_NOTIF | FAN_CLOEXEC,
+ O_RDONLY | O_LARGEFILE);
+}
+
+static void cleanup(void)
+{
+ unsigned int i;
+
+ if (affinity_changed)
+ sched_setaffinity(0, sizeof(original_mask), &original_mask);
+
+ if (mount_fd >= 0)
+ SAFE_CLOSE(mount_fd);
+ if (fanotify_fd >= 0)
+ SAFE_CLOSE(fanotify_fd);
+
+ for (i = 0; i < PIN_GROUPS; i++) {
+ if (pin_fds[i] >= 0)
+ SAFE_CLOSE(pin_fds[i]);
+ }
+}
+
+static void run(void)
+{
+ struct worker_result result = {
+ .reopened_fd = -1,
+ };
+ pthread_t worker;
+ unsigned int i;
+ int second_event, second_errno = 0;
+
+ SAFE_TOUCH(TEST_FILE, 0600, NULL);
+ memset(&victim_fid, 0, sizeof(victim_fid));
+ if (fanotify_save_fid(TEST_FILE, &victim_fid) == AT_HANDLE_FID)
+ tst_brk(TCONF, "Filesystem provides only non-decodable file handles");
+
+ for (i = 0; i < PIN_GROUPS; i++)
+ SAFE_FANOTIFY_MARK(pin_fds[i], FAN_MARK_ADD, FAN_MODIFY,
+ AT_FDCWD, TEST_FILE);
+
+ if (sched_setaffinity(0, sizeof(unlink_mask), &unlink_mask))
+ tst_brk(TBROK | TERRNO, "sched_setaffinity() failed");
+ affinity_changed = 1;
+
+ SAFE_PTHREAD_BARRIER_INIT(&barrier, NULL, 2);
+ SAFE_PTHREAD_CREATE(&worker, NULL, reopen_after_delete, &result);
+ SAFE_PTHREAD_BARRIER_WAIT(&barrier);
+ SAFE_UNLINK(TEST_FILE);
+ SAFE_PTHREAD_JOIN(worker, NULL);
+ SAFE_PTHREAD_BARRIER_DESTROY(&barrier);
+ if (sched_setaffinity(0, sizeof(original_mask), &original_mask))
+ tst_brk(TBROK | TERRNO, "Failed to restore CPU affinity");
+ affinity_changed = 0;
+
+ if (result.affinity_errno)
+ tst_brk(TBROK, "pthread_setaffinity_np() failed: %s",
+ tst_strerrno(result.affinity_errno));
+
+ if (result.event_status < 0) {
+ tst_brk(TBROK, "Failed to read first FAN_DELETE_SELF event: %s",
+ tst_strerrno(result.event_errno));
+ }
+
+ if (result.reopened_fd < 0) {
+ if (result.reopen_errno != ESTALE) {
+ tst_res(TFAIL, "open_by_handle_at() failed with %s, expected ESTALE",
+ tst_strerrno(result.reopen_errno));
+ return;
+ }
+
+ second_event = read_victim_event(100, &second_errno);
+ if (second_event > 0) {
+ tst_res(TFAIL, "Received duplicate FAN_DELETE_SELF event");
+ return;
+ }
+ if (second_errno != ETIMEDOUT) {
+ tst_brk(TBROK, "Failed while checking for a duplicate event: %s",
+ tst_strerrno(second_errno));
+ }
+
+ tst_res(TPASS, "open_by_handle_at() failed with ESTALE after FAN_DELETE_SELF");
+ return;
+ }
+
+ SAFE_CLOSE(result.reopened_fd);
+ second_event = read_victim_event(EVENT_TIMEOUT_MS, &second_errno);
+ if (second_event > 0) {
+ tst_res(TFAIL, "open_by_handle_at() reopened an unlinked inode and close generated duplicate FAN_DELETE_SELF");
+ return;
+ }
+
+ tst_res(TFAIL, "open_by_handle_at() reopened an unlinked inode, but no duplicate event was read: %s",
+ tst_strerrno(second_errno));
+}
+
+static struct tst_test test = {
+ .test_all = run,
+ .setup = setup,
+ .cleanup = cleanup,
+ .needs_root = 1,
+ .min_cpus = 2,
+ .mount_device = 1,
+ .mntpoint = MOUNT_PATH,
+ .filesystems = (struct tst_fs[]) {
+ {.type = "ext4"},
+ {}
+ },
+ .ulimit = (const struct tst_ulimit_val[]) {
+ {RLIMIT_NOFILE, 256},
+ {}
+ },
+};
+
+#else
+ TST_TEST_TCONF("System does not have required name_to_handle_at() support");
+#endif
+#else
+ TST_TEST_TCONF("System does not have required fanotify support");
+#endif
base-commit: 12724413534a6d4160ff9694ba6f09daa4ccb6bd
--
2.55.0