PROBLEM: FUSE_NOTIFY_INVAL_ENTRY leaves stale negative dentry after c9ba789dad15

Артем Лабазов <[email protected]>
Newsgroups dev.linux.lists.regressions,org.kernel.vger.linux-fsdevel,org.kernel.vger.linux-kernel
Message-ID <CAFbF8N7++zopZuEcsKRxBV_sgOGCbzCY0hOyMw1SiGAtuzGhyQ@mail.gmail.com>
Hi,

I am reporting a FUSE/VFS regression introduced by:

  c9ba789dad15ba65662bba17595c0aeaa0cfcf1c
  VFS: introduce start_creating_noperm() and start_removing_noperm()

After a FUSE filesystem returns a cached negative lookup for a name, then
later makes that name visible and sends FUSE_NOTIFY_INVAL_ENTRY for it,
readdir() can list the entry while stat()/open() on the same path still
returns ENOENT.

Bisect result:

  good: v6.17
  bad:  v6.19

  first bad commit:
    c9ba789dad15ba65662bba17595c0aeaa0cfcf1c
    VFS: introduce start_creating_noperm() and start_removing_noperm()

Adjacent commits:

  bd6ede8a06e89ca5a94a8b51cea792705d1b8ca2 GOOD
  c9ba789dad15ba65662bba17595c0aeaa0cfcf1c BAD

The bad commit changes fs/fuse/dir.c:fuse_reverse_inval_entry() to use
start_removing_noperm().

I attached a minimal libfuse3 reproducer. It does:

  1. lookup("config") returns ino=0 with non-zero entry_timeout, causing
     the kernel to cache a negative dentry.
  2. The filesystem makes "config" visible.
  3. It calls fuse_lowlevel_notify_inval_entry(root, "config").
  4. The test checks whether readdir lists "config" and stat("config")
     succeeds.

Build:

  cc -O2 -Wall -Wextra repro.c -o repro $(pkg-config --cflags --libs
fuse3) -lpthread

Run:

  mkdir -p mnt
  ./repro ./mnt

Observed results:

  Linux 6.17.0:
    notify_inval_entry rc=0
    listed=1 stat=OK
    GOOD
    exit code 0

  Linux 6.19.0:
    notify_inval_entry rc=-2
    listed=1 stat=No such file or directory
    BAD
    exit code 1

  Linux 7.1.0-rc5-00426-g670b77dfebe7:
    notify_inval_entry rc=-2
    listed=1 stat=No such file or directory
    BAD
    exit code 1

#regzbot introduced: c9ba789dad15ba65662bba17595c0aeaa0cfcf1c

Assisted-by: OpenCode:openai/gpt-5.5

Thanks,
Artem
repro.c (application/octet-stream, 4.3 KB)
// SPDX-License-Identifier: GPL-2.0
// Reproducer for c9ba789dad15: cached negative FUSE dentry survives
// FUSE_NOTIFY_INVAL_ENTRY.  Exit: 0=good, 1=bad, 2=error.
// Build: cc -O2 -Wall -Wextra repro.c -o repro $(pkg-config --cflags --libs fuse3) -lpthread
#define FUSE_USE_VERSION 34
#include <dirent.h>
#include <errno.h>
#include <fuse_lowlevel.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>

enum { ROOT = 1, CONFIG = 2 };
static struct fuse_session *se;
static volatile int visible;

static void attr(struct stat *st, fuse_ino_t ino)
{
  memset(st, 0, sizeof(*st));
  st->st_ino = ino;
  st->st_mode = S_IFDIR | 0755;
  st->st_nlink = 2;
  st->st_uid = getuid();
  st->st_gid = getgid();
}

static void entry(fuse_req_t req, fuse_ino_t ino, double t)
{
  struct fuse_entry_param e = {.ino = ino, .generation = 1, .entry_timeout = t,
                               .attr_timeout = t};
  if (ino)
    attr(&e.attr, ino);
  fuse_reply_entry(req, &e);
}

static void lookup(fuse_req_t req, fuse_ino_t parent, const char *name)
{
  if (parent != ROOT) {
    fuse_reply_err(req, ENOENT);
    return;
  }
  if (!strcmp(name, "config"))
    return entry(req, visible ? CONFIG : 0, 600.0);
  fuse_reply_err(req, ENOENT);
}

static void getattr(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info *fi)
{
  (void)fi;
  if (ino == ROOT || ino == CONFIG) {
    struct stat st;
    attr(&st, ino);
    fuse_reply_attr(req, &st, 600.0);
    return;
  }
  fuse_reply_err(req, ENOENT);
}

static void add(fuse_req_t req, char **p, size_t *sz, const char *name,
                fuse_ino_t ino)
{
  struct stat st;
  attr(&st, ino);
  size_t old = *sz;
  *sz += fuse_add_direntry(req, NULL, 0, name, NULL, 0);
  *p = realloc(*p, *sz);
  fuse_add_direntry(req, *p + old, *sz - old, name, &st, *sz);
}

static void readdir_cb(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off,
                       struct fuse_file_info *fi)
{
  (void)fi;
  if (ino != ROOT) {
    fuse_reply_err(req, ENOTDIR);
    return;
  }
  char *buf = NULL;
  size_t sz = 0;
  add(req, &buf, &sz, ".", ROOT);
  add(req, &buf, &sz, "..", ROOT);
  if (visible)
    add(req, &buf, &sz, "config", CONFIG);
  if (off < (off_t)sz)
    fuse_reply_buf(req, buf + off, sz - off < size ? sz - off : size);
  else
    fuse_reply_buf(req, NULL, 0);
  free(buf);
}

static void opendir_cb(fuse_req_t req, fuse_ino_t ino,
                       struct fuse_file_info *fi)
{
  ino == ROOT ? fuse_reply_open(req, fi) : fuse_reply_err(req, ENOTDIR);
}

static const struct fuse_lowlevel_ops ops = {
    .lookup = lookup,
    .getattr = getattr,
    .opendir = opendir_cb,
    .readdir = readdir_cb,
};

static void *loop(void *arg) {
  fuse_session_loop(arg);
  return NULL;
}

static int listed(const char *mnt)
{
  DIR *d = opendir(mnt);
  if (!d)
    return 0;
  int seen = 0;
  struct dirent *de;
  while ((de = readdir(d)))
    seen |= !strcmp(de->d_name, "config");
  closedir(d);
  return seen;
}

static int user(const char *mnt)
{
  char c[4096];
  struct stat st;
  snprintf(c, sizeof(c), "%s/config", mnt);
  if (stat(c, &st) == 0 || errno != ENOENT)
    return fprintf(stderr, "expected initial ENOENT\n"), 2;
  if (listed(mnt))
    return fprintf(stderr, "config listed too early\n"), 2;
  visible = 1;
  int rc =
      fuse_lowlevel_notify_inval_entry(se, ROOT, "config", strlen("config"));
  fprintf(stderr, "notify_inval_entry rc=%d\n", rc);
  usleep(100000); // let notify reach the kernel
  errno = 0;
  int seen = listed(mnt), ok = !stat(c, &st), err = errno;
  fprintf(stderr, "listed=%d stat=%s\n", seen, ok ? "OK" : strerror(err));
  if (seen && !ok && err == ENOENT)
    return puts("BAD"), 1;
  return puts("GOOD"), 0;
}

int main(int argc, char **argv)
{
  if (argc != 2)
    return fprintf(stderr, "usage: %s <mountpoint>\n", argv[0]), 2;
  struct fuse_args args = FUSE_ARGS_INIT(0, NULL);
  fuse_opt_add_arg(&args, argv[0]);
  fuse_opt_add_arg(&args, "-o");
  fuse_opt_add_arg(&args, "auto_unmount,fsname=invalrepro");
  se = fuse_session_new(&args, &ops, sizeof(ops), NULL);
  if (!se || fuse_session_mount(se, argv[1]))
    return perror("fuse mount"), 2;
  pthread_t lt;
  pthread_create(&lt, NULL, loop, se);
  int ret = user(argv[1]);
  fuse_session_exit(se);
  fuse_session_unmount(se);
  pthread_cancel(lt);
  pthread_join(lt, NULL);
  fuse_session_destroy(se);
  return ret;
}
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.