Linux virtualization list
 help / color / mirror / Atom feed
From: Aaron Paterson <apaterson@pm.me>
To: Miklos Szeredi <miklos@szeredi.hu>,
	Stefan Hajnoczi <stefanha@redhat.com>,
	Vivek Goyal <vgoyal@redhat.com>,
	German Maglione <gmaglione@redhat.com>,
	Shuah Khan <shuah@kernel.org>
Cc: "Eugenio Pérez" <eperezma@redhat.com>,
	fuse-devel@lists.linux.dev, linux-fsdevel@vger.kernel.org,
	linux-kselftest@vger.kernel.org, virtualization@lists.linux.dev,
	linux-kernel@vger.kernel.org, "Aaron Paterson" <apaterson@pm.me>
Subject: [PATCH 4/5] selftests/fuse: cover a request refused for a live nodeid
Date: Thu, 27 Aug 2026 23:38:02 +0000	[thread overview]
Message-ID: <15e12ddd18fa9bfecdb454391e6684664d8da653.1787873791.git.apaterson@pm.me> (raw)
In-Reply-To: <cover.1787873791.git.apaterson@pm.me>

Add a test that mounts a libfuse3 server which can be told to refuse a
request for an inode the client still holds a reference to, and check
that the caller recovers instead of being told the file is gone.

The server answers ENOENT on demand for each request the fix converts,
FUSE_OPEN, FUSE_GETATTR, FUSE_SETATTR, FUSE_READLINK and FUSE_STATFS,
while continuing to serve every other request for the same inode, which
is how a server that releases an inode too early behaves. open(), stat(),
chmod(), readlink() and statfs() are then expected to succeed, having
resolved the name again under LOOKUP_REVAL, and the request counters
confirm the retry happened rather than the answer being served from
cache.

Each of those requests carries a nodeid, so an ENOENT answering one of
them describes a handle rather than a name. FUSE_LOOKUP is the exception
and is covered the other way round: a name the server does not have must
still report ENOENT, since there the refusal is the answer.

Signed-off-by: Aaron Paterson <apaterson@pm.me>
---
 .../selftests/filesystems/fuse/.gitignore     |   1 +
 .../selftests/filesystems/fuse/Makefile       |   4 +
 .../filesystems/fuse/fuse_estale_test.c       | 450 ++++++++++++++++++
 3 files changed, 455 insertions(+)
 create mode 100644 tools/testing/selftests/filesystems/fuse/fuse_estale_test.c

diff --git a/tools/testing/selftests/filesystems/fuse/.gitignore b/tools/testing/selftests/filesystems/fuse/.gitignore
index 25c779065806..9cb3048128d5 100644
--- a/tools/testing/selftests/filesystems/fuse/.gitignore
+++ b/tools/testing/selftests/filesystems/fuse/.gitignore
@@ -1,5 +1,6 @@
 # SPDX-License-Identifier: GPL-2.0-only
 fuse_acl_cache_test
 fuse_mnt
+fuse_estale_test
 fusectl_test
 write_extend_eof_test
diff --git a/tools/testing/selftests/filesystems/fuse/Makefile b/tools/testing/selftests/filesystems/fuse/Makefile
index 1ea87008ef9e..f564cb37b3a5 100644
--- a/tools/testing/selftests/filesystems/fuse/Makefile
+++ b/tools/testing/selftests/filesystems/fuse/Makefile
@@ -11,6 +11,7 @@ FUSE3_CFLAGS := $(shell pkg-config fuse3 --cflags 2>/dev/null)
 FUSE3_LDLIBS := $(shell pkg-config fuse3 --libs 2>/dev/null)
 ifneq ($(FUSE3_CFLAGS),)
 TEST_GEN_PROGS += fuse_acl_cache_test
+TEST_GEN_PROGS += fuse_estale_test
 endif
 
 include ../../lib.mk
@@ -32,3 +33,6 @@ $(OUTPUT)/fuse_mnt: LDLIBS += $(VAR_LDLIBS)
 
 $(OUTPUT)/fuse_acl_cache_test: CFLAGS += $(FUSE3_CFLAGS)
 $(OUTPUT)/fuse_acl_cache_test: LDLIBS += $(FUSE3_LDLIBS)
+
+$(OUTPUT)/fuse_estale_test: CFLAGS += $(FUSE3_CFLAGS)
+$(OUTPUT)/fuse_estale_test: LDLIBS += $(FUSE3_LDLIBS)
diff --git a/tools/testing/selftests/filesystems/fuse/fuse_estale_test.c b/tools/testing/selftests/filesystems/fuse/fuse_estale_test.c
new file mode 100644
index 000000000000..82b842d3a5f7
--- /dev/null
+++ b/tools/testing/selftests/filesystems/fuse/fuse_estale_test.c
@@ -0,0 +1,450 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Test: a request refused for an inode the client still holds a reference to
+ *
+ * FUSE_OPEN, FUSE_GETATTR, FUSE_SETATTR, FUSE_READLINK and FUSE_STATFS carry a
+ * nodeid rather than a path.  The client only sends them for an inode it has
+ * already looked up and holds a reference to, and a server owes the client that
+ * inode until it is sent FUSE_FORGET.  A server that lets the inode go early,
+ * as one backing a shared directory does when the name is renamed over, answers
+ * with ENOENT.
+ *
+ * On an unfixed kernel that ENOENT is passed out unchanged.  The path walk has
+ * no reason to doubt it and the caller is told a file is missing when it never
+ * stopped existing.  Callers that read a missing file as an empty one act on
+ * the emptiness.
+ *
+ * Fixed (fs/fuse/file.c and fs/fuse/dir.c): ENOENT becomes ESTALE, which
+ * describes the handle rather than the name.  filename_lookup() and
+ * do_filp_open() already retry with LOOKUP_REVAL on ESTALE, so the name is
+ * resolved again and the inode it refers to now is used.  A name that has
+ * genuinely gone away fails the retried lookup, so ENOENT still reaches a
+ * caller that deserves it.
+ *
+ * Only requests reachable through a path walk are covered, because the retry
+ * is what makes ESTALE useful and the walk is what performs it.  An operation
+ * on a descriptor already open has no equivalent recovery.
+ *
+ * Test outline:
+ *  1. Mount a minimal FUSE fs holding one file.
+ *  2. The server refuses the first request of the kind under test and allows
+ *     every one after it, standing in for a server that released the inode and
+ *     has since resolved the name again.
+ *  3. openat() the file.
+ *     Buggy:  ENOENT reaches the caller, one open was asked for.  FAIL.
+ *     Fixed:  the walk retries, the second open is allowed, the descriptor is
+ *             returned, two opens were asked for.  PASS.
+ *  4. stat(), chmod(), readlink() and statfs() by name, which are the same
+ *     recovery through FUSE_GETATTR, FUSE_SETATTR, FUSE_READLINK and
+ *     FUSE_STATFS.  Each is reached through a path walk, which is what makes
+ *     the retry available.
+ *  5. Open a name the server does not have at all.
+ *     Both:   ENOENT, because the lookup fails rather than the open, and a
+ *             file that is absent must still look absent.
+ */
+
+#define _GNU_SOURCE
+#define FUSE_USE_VERSION 34
+
+#include <errno.h>
+#include <fcntl.h>
+#include <fuse_lowlevel.h>
+#include <linux/limits.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <stdbool.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/statvfs.h>
+#include <sys/vfs.h>
+#include <unistd.h>
+
+#include "../../kselftest_harness.h"
+
+#define FILE_NAME	"held"
+#define LINK_NAME	"held-link"
+#define ABSENT_NAME	"no-such-file"
+#define FILE_INO	2
+#define LINK_INO	3
+#define CONTENTS	"present\n"
+#define LINK_TARGET	FILE_NAME
+
+/*
+ * Which request the server refuses, and how many times.  Shared with the
+ * daemon thread; one test runs at a time, so plain ints.
+ *
+ * Every one of these names an inode by nodeid rather than by name, so a
+ * refusal of any of them is describing a handle rather than a missing file.
+ * FUSE_LOOKUP is deliberately absent: it carries a name, so its ENOENT is an
+ * answer rather than a fault, and absent_name_still_reports_absent covers it.
+ */
+enum refuse_what {
+	REFUSE_NOTHING,
+	REFUSE_OPEN,
+	REFUSE_GETATTR,
+	REFUSE_SETATTR,
+	REFUSE_READLINK,
+	REFUSE_STATFS,
+};
+
+static struct {
+	enum refuse_what what;
+	int refusals_left;
+	int opens_seen;
+	int getattrs_seen;
+	int setattrs_seen;
+	int readlinks_seen;
+	int statfss_seen;
+} g_ds;
+
+/* True once, for the request under test, and then never again. */
+static bool refuse_now(enum refuse_what what)
+{
+	if (g_ds.what != what || g_ds.refusals_left <= 0)
+		return false;
+	g_ds.refusals_left--;
+	return true;
+}
+
+static void fill_attr(fuse_ino_t ino, struct stat *st)
+{
+	memset(st, 0, sizeof(*st));
+	st->st_ino = ino;
+	/*
+	 * Owned by whoever runs the test, so that chmod() is a request the
+	 * kernel will carry through to the server rather than refuse itself.
+	 */
+	st->st_uid = getuid();
+	st->st_gid = getgid();
+	if (ino == FUSE_ROOT_ID) {
+		st->st_mode = S_IFDIR | 0755;
+		st->st_nlink = 2;
+	} else if (ino == LINK_INO) {
+		st->st_mode = S_IFLNK | 0777;
+		st->st_nlink = 1;
+		st->st_size = sizeof(LINK_TARGET) - 1;
+	} else {
+		st->st_mode = S_IFREG | 0644;
+		st->st_nlink = 1;
+		st->st_size = sizeof(CONTENTS) - 1;
+	}
+}
+
+static void t_lookup(fuse_req_t req, fuse_ino_t parent, const char *name)
+{
+	struct fuse_entry_param e;
+	fuse_ino_t ino;
+
+	if (parent != FUSE_ROOT_ID)
+		ino = 0;
+	else if (!strcmp(name, FILE_NAME))
+		ino = FILE_INO;
+	else if (!strcmp(name, LINK_NAME))
+		ino = LINK_INO;
+	else
+		ino = 0;
+
+	if (!ino) {
+		fuse_reply_err(req, ENOENT);
+		return;
+	}
+
+	memset(&e, 0, sizeof(e));
+	e.ino = ino;
+	e.attr_timeout = 0;
+	e.entry_timeout = 0;
+	fill_attr(ino, &e.attr);
+	fuse_reply_entry(req, &e);
+}
+
+static void t_getattr(fuse_req_t req, fuse_ino_t ino,
+		      struct fuse_file_info *fi)
+{
+	struct stat st;
+
+	(void)fi;
+	/* The root is left alone; refusing it would break the mount itself. */
+	if (ino == FILE_INO) {
+		g_ds.getattrs_seen++;
+		if (refuse_now(REFUSE_GETATTR)) {
+			fuse_reply_err(req, ENOENT);
+			return;
+		}
+	}
+	fill_attr(ino, &st);
+	fuse_reply_attr(req, &st, 0);
+}
+
+static void t_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info *fi)
+{
+	if (ino != FILE_INO) {
+		fuse_reply_err(req, ENOENT);
+		return;
+	}
+
+	g_ds.opens_seen++;
+	if (refuse_now(REFUSE_OPEN)) {
+		/*
+		 * The inode is gone as far as this server is concerned, even
+		 * though the client is holding a reference to it and asked by
+		 * nodeid rather than by name.
+		 */
+		fuse_reply_err(req, ENOENT);
+		return;
+	}
+	fuse_reply_open(req, fi);
+}
+
+static void t_read(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off,
+		   struct fuse_file_info *fi)
+{
+	size_t len = sizeof(CONTENTS) - 1;
+
+	(void)fi;
+	if (ino != FILE_INO) {
+		fuse_reply_err(req, ENOENT);
+		return;
+	}
+	if ((size_t)off >= len) {
+		fuse_reply_buf(req, NULL, 0);
+		return;
+	}
+	if (off + size > len)
+		size = len - off;
+	fuse_reply_buf(req, CONTENTS + off, size);
+}
+
+static void t_setattr(fuse_req_t req, fuse_ino_t ino, struct stat *attr,
+		      int to_set, struct fuse_file_info *fi)
+{
+	struct stat st;
+
+	(void)attr;
+	(void)to_set;
+	(void)fi;
+	if (ino == FILE_INO) {
+		g_ds.setattrs_seen++;
+		if (refuse_now(REFUSE_SETATTR)) {
+			fuse_reply_err(req, ENOENT);
+			return;
+		}
+	}
+	fill_attr(ino, &st);
+	fuse_reply_attr(req, &st, 0);
+}
+
+static void t_readlink(fuse_req_t req, fuse_ino_t ino)
+{
+	if (ino != LINK_INO) {
+		fuse_reply_err(req, EINVAL);
+		return;
+	}
+
+	g_ds.readlinks_seen++;
+	if (refuse_now(REFUSE_READLINK)) {
+		fuse_reply_err(req, ENOENT);
+		return;
+	}
+	fuse_reply_readlink(req, LINK_TARGET);
+}
+
+static void t_statfs(fuse_req_t req, fuse_ino_t ino)
+{
+	struct statvfs sfs;
+
+	(void)ino;
+	g_ds.statfss_seen++;
+	if (refuse_now(REFUSE_STATFS)) {
+		fuse_reply_err(req, ENOENT);
+		return;
+	}
+
+	memset(&sfs, 0, sizeof(sfs));
+	sfs.f_bsize = 512;
+	sfs.f_frsize = 512;
+	sfs.f_namemax = NAME_MAX;
+	fuse_reply_statfs(req, &sfs);
+}
+
+static const struct fuse_lowlevel_ops fs_ops = {
+	.lookup		= t_lookup,
+	.getattr	= t_getattr,
+	.setattr	= t_setattr,
+	.readlink	= t_readlink,
+	.statfs		= t_statfs,
+	.open		= t_open,
+	.read		= t_read,
+};
+
+static void *run_daemon(void *arg)
+{
+	fuse_session_loop((struct fuse_session *)arg);
+	return NULL;
+}
+
+/* ---- kselftest harness --------------------------------------------------- */
+
+FIXTURE(open_estale) {
+	struct fuse_session *se;
+	char                 mountpoint[PATH_MAX];
+	char                 file_path[PATH_MAX];
+	char                 link_path[PATH_MAX];
+	char                 absent_path[PATH_MAX];
+	pthread_t            thread;
+};
+
+FIXTURE_SETUP(open_estale)
+{
+	char *fuse_argv[] = { "fuse_estale_test", NULL };
+	struct fuse_args args = FUSE_ARGS_INIT(1, fuse_argv);
+
+	memset(&g_ds, 0, sizeof(g_ds));
+	g_ds.what = REFUSE_NOTHING;
+	g_ds.refusals_left = 1;
+
+	strcpy(self->mountpoint, "/tmp/open_estale_test_XXXXXX");
+	if (!mkdtemp(self->mountpoint))
+		SKIP(return, "mkdtemp: %s", strerror(errno));
+
+	snprintf(self->file_path, sizeof(self->file_path),
+		 "%s/" FILE_NAME, self->mountpoint);
+	snprintf(self->link_path, sizeof(self->link_path),
+		 "%s/" LINK_NAME, self->mountpoint);
+	snprintf(self->absent_path, sizeof(self->absent_path),
+		 "%s/" ABSENT_NAME, self->mountpoint);
+
+	self->se = fuse_session_new(&args, &fs_ops, sizeof(fs_ops), NULL);
+	if (!self->se) {
+		rmdir(self->mountpoint);
+		SKIP(return, "fuse_session_new failed");
+	}
+
+	if (fuse_session_mount(self->se, self->mountpoint)) {
+		fuse_session_destroy(self->se);
+		rmdir(self->mountpoint);
+		SKIP(return, "fuse_session_mount failed (no fusermount3 or no privileges)");
+	}
+
+	if (pthread_create(&self->thread, NULL, run_daemon, self->se)) {
+		fuse_session_unmount(self->se);
+		fuse_session_destroy(self->se);
+		rmdir(self->mountpoint);
+		SKIP(return, "pthread_create: %s", strerror(errno));
+	}
+
+	fuse_opt_free_args(&args);
+}
+
+FIXTURE_TEARDOWN(open_estale)
+{
+	fuse_session_exit(self->se);
+	fuse_session_unmount(self->se);
+	pthread_join(self->thread, NULL);
+	fuse_session_destroy(self->se);
+	rmdir(self->mountpoint);
+}
+
+TEST_F(open_estale, refused_open_is_retried)
+{
+	int fd;
+
+	g_ds.what = REFUSE_OPEN;
+
+	fd = open(self->file_path, O_RDONLY);
+
+	/*
+	 * The refusal describes a handle the server should have honoured, so
+	 * the walk is entitled to resolve the name again and open what it
+	 * refers to now. Reporting the file missing instead ends the walk.
+	 */
+	ASSERT_GE(fd, 0) {
+		TH_LOG("open failed with %s after %d open request(s)",
+		       strerror(errno), g_ds.opens_seen);
+	}
+	EXPECT_EQ(2, g_ds.opens_seen);
+	close(fd);
+}
+
+TEST_F(open_estale, refused_getattr_on_path_is_retried)
+{
+	struct stat st;
+
+	g_ds.what = REFUSE_GETATTR;
+
+	/*
+	 * Reached by name, so the walk can resolve it again and ask a second
+	 * time, the same recovery the open gets.
+	 */
+	ASSERT_EQ(0, stat(self->file_path, &st)) {
+		TH_LOG("stat failed with %s after %d getattr request(s)",
+		       strerror(errno), g_ds.getattrs_seen);
+	}
+	EXPECT_GT(g_ds.getattrs_seen, 1);
+}
+
+TEST_F(open_estale, refused_setattr_on_path_is_retried)
+{
+	g_ds.what = REFUSE_SETATTR;
+
+	/*
+	 * chmod() reaches the inode by name, so the same retry applies: the
+	 * refusal describes a handle and the walk may resolve the name again.
+	 */
+	ASSERT_EQ(0, chmod(self->file_path, 0600)) {
+		TH_LOG("chmod failed with %s after %d setattr request(s)",
+		       strerror(errno), g_ds.setattrs_seen);
+	}
+	EXPECT_GT(g_ds.setattrs_seen, 1);
+}
+
+TEST_F(open_estale, refused_readlink_on_path_is_retried)
+{
+	char buf[PATH_MAX];
+	ssize_t n;
+
+	g_ds.what = REFUSE_READLINK;
+
+	n = readlink(self->link_path, buf, sizeof(buf) - 1);
+	ASSERT_GE(n, 0) {
+		TH_LOG("readlink failed with %s after %d readlink request(s)",
+		       strerror(errno), g_ds.readlinks_seen);
+	}
+	buf[n] = '\0';
+	EXPECT_STREQ(LINK_TARGET, buf);
+	EXPECT_GT(g_ds.readlinks_seen, 1);
+}
+
+TEST_F(open_estale, refused_statfs_on_path_is_retried)
+{
+	struct statfs sfs;
+
+	g_ds.what = REFUSE_STATFS;
+
+	/*
+	 * statfs() describes the mount rather than the file, but it is still
+	 * reached through a path walk, so a refusal that names a handle is
+	 * retried the same way.
+	 */
+	ASSERT_EQ(0, statfs(self->file_path, &sfs)) {
+		TH_LOG("statfs failed with %s after %d statfs request(s)",
+		       strerror(errno), g_ds.statfss_seen);
+	}
+	EXPECT_GT(g_ds.statfss_seen, 1);
+}
+
+TEST_F(open_estale, absent_name_still_reports_absent)
+{
+	int fd;
+
+	/*
+	 * Here it is the lookup that fails rather than the open, so nothing is
+	 * being described as stale and the caller must still be told the name
+	 * is not there.
+	 */
+	fd = open(self->absent_path, O_RDONLY);
+	ASSERT_LT(fd, 0);
+	EXPECT_EQ(ENOENT, errno);
+}
+
+TEST_HARNESS_MAIN
-- 
2.55.0.553.g4ad8c266be



  parent reply	other threads:[~2026-08-27 23:38 UTC|newest]

Thread overview: 7+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-08-27 23:37 [PATCH 0/5] fuse: report a request refused for a live nodeid as stale Aaron Paterson
2026-08-27 23:37 ` [PATCH 1/5] selftests/fuse: ignore the built acl cache test Aaron Paterson
2026-08-27 23:37 ` [PATCH 2/5] selftests/fuse: name the libfuse3 flags for the library Aaron Paterson
2026-08-27 23:37 ` [PATCH 3/5] fuse: report a request refused for a live nodeid as stale Aaron Paterson
2026-08-27 23:38 ` Aaron Paterson [this message]
2026-08-27 23:38 ` [PATCH 5/5] virtiofs: " Aaron Paterson
2026-08-28  2:02 ` [PATCH 0/5] fuse: report a request refused for a live nodeid Aaron Paterson

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=15e12ddd18fa9bfecdb454391e6684664d8da653.1787873791.git.apaterson@pm.me \
    --to=apaterson@pm.me \
    --cc=eperezma@redhat.com \
    --cc=fuse-devel@lists.linux.dev \
    --cc=gmaglione@redhat.com \
    --cc=linux-fsdevel@vger.kernel.org \
    --cc=linux-kernel@vger.kernel.org \
    --cc=linux-kselftest@vger.kernel.org \
    --cc=miklos@szeredi.hu \
    --cc=shuah@kernel.org \
    --cc=stefanha@redhat.com \
    --cc=vgoyal@redhat.com \
    --cc=virtualization@lists.linux.dev \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox