Skip to content

Support FUSE clients without READDIRPLUS (fall back to plain readdir)#147

Open
zeddybot wants to merge 1 commit into
libfuse:mainfrom
zeddybot:readdirplus-optional-fallback
Open

Support FUSE clients without READDIRPLUS (fall back to plain readdir)#147
zeddybot wants to merge 1 commit into
libfuse:mainfrom
zeddybot:readdirplus-optional-fallback

Conversation

@zeddybot

@zeddybot zeddybot commented Jul 8, 2026

Copy link
Copy Markdown

Problem

pyfuse3 cannot be used at all under a FUSE client that implements plain
FUSE_READDIR but not FUSE_READDIRPLUS. Two independent things break:

  1. Init aborts. fuse_init raises RuntimeError('Kernel too old, pyfuse3 requires kernel 3.9 or newer!') whenever the connection does not advertise
    FUSE_CAP_READDIRPLUS, so the mount never comes up.

  2. Plain readdir is unimplemented. pyfuse3 registers only
    fuse_ops.readdirplus. A client that issues a plain FUSE_READDIR hits a
    NULL operation and libfuse answers ENOSYS, so directory listings fail even
    if init is bypassed.

The motivating client is gVisor's sentry, whose VFS
implements plain FUSE_READDIR but not FUSE_READDIRPLUS
(google/gvisor#3404, open since
2020). Running any pyfuse3 filesystem inside a gVisor sandbox is currently
impossible.

Plain FUSE_READDIR (opcode 28) is the mandatory FUSE baseline;
FUSE_READDIRPLUS (opcode 44, FUSE 7.21 / Linux 3.9) is a negotiated
optimization that bundles each entry's attributes to save a follow-up LOOKUP.
libfuse treats them as independent, optional vtable slots — its own
hello_ll.c implements only plain readdir, and passthrough_ll.c implements
both and branches on a plus flag.

Fix

  • Register both operations. fuse_ops.readdir is now registered alongside
    fuse_ops.readdirplus; the client's opcode selects which is invoked. No new
    init/Operations flag is introduced — the client already advertises what it
    supports, so auto-detection is the right axis.
  • Stop requiring READDIRPLUS. fuse_init no longer raises when
    FUSE_CAP_READDIRPLUS is absent. (FUSE_CAP_READDIRPLUS_AUTO is still
    cleared, so a capable Linux client keeps sending READDIRPLUS.)
  • Share one async path. Both entry points funnel into the existing
    fuse_readdirplus_async. A new read-only attribute ReaddirToken.plus
    records which request the client made, and readdir_reply emits
    fuse_add_direntry (plain) or fuse_add_direntry_plus accordingly.

Correctness — lookup counts

READDIRPLUS entries take an implicit per-entry kernel lookup reference (balanced
later by FORGET); plain READDIR entries take none. The Operations.readdir
contract therefore now depends on token.plus:

  • token.plus is True → each entry for which readdir_reply returns True takes
    a lookup reference; the filesystem must increase the inode's lookup count
    (unchanged behaviour).
  • token.plus is False → the kernel takes no reference; the filesystem must
    not
    increase the lookup count, or the inode leaks with no balancing
    FORGET.

This is documented on Operations.readdir and applied in the passthroughfs
example (its _add_path bookkeeping is now gated on token.plus).

Compatibility

Inert on Linux. A normal Linux kernel always negotiates READDIRPLUS, so
token.plus is always True, the new plain-readdir callback is never invoked,
and byte-for-byte behaviour is unchanged. The only public surface added is one
read-only attribute (ReaddirToken.plus); no handler signature changes.

Testing

  • No-regression (CI). The full test/ suite passes unchanged. Fs.readdir
    now asserts token.plus is True (a normal kernel never sends plain
    FUSE_READDIR), and a new test_readdir forces opendir+readdir through a
    real listing so the assertion and the new attribute are exercised from Python.
    A stock kernel cannot drive the plain-readdir branch, so CI proves only that
    nothing regresses — see below for the branch's real coverage.

  • Plain-readdir branch (under gVisor). Verified by building the patched
    wheel and running a pyfuse3 hello filesystem inside a gVisor (runsc)
    sandbox. Before/after transcript, same code, same image:

    # BEFORE (stock) — mount fails, listing impossible
    $ runsc do -- python3 hello.py /tmp/mp & ls /tmp/mp; cat /tmp/mp/hello
    cat: /tmp/mp/hello: Transport endpoint is not connected
    RuntimeError: Kernel too old, pyfuse3 requires kernel 3.9 or newer!
        at src/pyfuse3/handlers.pxi:35 in fuse_init
    
    # AFTER (patched) — listing and read succeed
    $ runsc do -- python3 hello.py /tmp/mp & ls /tmp/mp; cat /tmp/mp/hello
    hello
    ls_exit=0
    Hello World from pyfuse3!
    cat_exit=0
    # daemon stderr: readdir called: token.plus=False   (plain FUSE_READDIR branch)
    
    # CONTROL (patched, normal Linux kernel via runc) — unchanged
    hello
    Hello World from pyfuse3!
    # daemon stderr: readdir called: token.plus=True     (READDIRPLUS still negotiated)
    

Open question

Happy to add a lower-level readdir_reply(..) unit test that constructs a
token with plus=False and asserts a plain fuse_dirent record is emitted,
if you'd prefer in-CI coverage of the plain path. It's fragile to build outside
a live request, so I left it out — let me know your preference.

…ddir

pyfuse3 required FUSE_CAP_READDIRPLUS at init and registered only a
readdirplus operation. A client that implements plain FUSE_READDIR but
not FUSE_READDIRPLUS therefore could not use pyfuse3 at all: fuse_init
raised "Kernel too old", leaving the mount unusable, and plain
FUSE_READDIR requests hit a NULL operation and were answered with
ENOSYS, so directory listings failed. gVisor's sentry is exactly such a
client (gVisor issue #3404).

Plain FUSE_READDIR is the mandatory baseline; READDIRPLUS (FUSE 7.21 /
Linux 3.9) is a negotiated optimization that bundles attributes to save
per-entry LOOKUPs. Register a plain readdir operation alongside
readdirplus and stop aborting when READDIRPLUS is absent. The two share
one async path; a read-only ReaddirToken.plus flag records which the
client requested, and readdir_reply emits fuse_add_direntry (plain) or
fuse_add_direntry_plus accordingly.

Because plain FUSE_READDIR entries take no kernel lookup reference (only
readdirplus entries do), filesystems must not increase the lookup count
when token.plus is False, or the inode leaks with no balancing FORGET.
This is documented on Operations.readdir and applied in the passthroughfs
example. On a normal Linux kernel READDIRPLUS is always negotiated, so
token.plus is always True and behaviour is unchanged.

Signed-off-by: zeddybot <sameernayyar2003@gmail.com>
@Nikratio

Nikratio commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Could you please clarify which agent was used for this PR, and to what extent human oversight was applied?

@zeddybot

zeddybot commented Jul 8, 2026

Copy link
Copy Markdown
Author

Could you please clarify which agent was used for this PR, and to what extent human oversight was applied?

thank you for the quick response! this pr was authored by claude opus 4.8 in the claude code harness. i've reviewed all the code by hand, and tested it on gvisor + on a linux machine that supports readdir_plus and it seems to work for me. please do let me know if i can do anything else for you here!

@Nikratio

Nikratio commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

I can't help but think I'm communicating with an agent here again, rather than a human. Sorry.

@zeddybot

zeddybot commented Jul 8, 2026

Copy link
Copy Markdown
Author

hey no this is me writing this by hand, not an agent - i did have claude write the pr but i saw your comment and responded by hand

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants