Skip to content

cache: reopen remote directory handle on cache-miss readdir#375

Open
paulojeronimo wants to merge 1 commit into
libfuse:masterfrom
paulojeronimo:fix/readdir-stale-handle-after-invalidate
Open

cache: reopen remote directory handle on cache-miss readdir#375
paulojeronimo wants to merge 1 commit into
libfuse:masterfrom
paulojeronimo:fix/readdir-stale-handle-after-invalidate

Conversation

@paulojeronimo

Copy link
Copy Markdown

Problem

After any write through an sshfs mount (create, delete, rename), a subsequent directory listing (ls, readdir() in general) on the affected directory can come back empty, even though the remote directory and its contents are completely intact. Only the listing is wrong - stat()/open()/read() on a specific, known file path inside the "empty" directory still work and return correct data. Unmounting and remounting always restores a correct listing until the next write.

Reproduction:

sshfs user@host:/remote/dir /mnt/point
ls -l /mnt/point        # correct listing
touch /mnt/point/x && rm /mnt/point/x
ls -l /mnt/point        # now shows "total 0", even though /remote/dir is untouched

Confirmed via an sshfs -f -d protocol trace: a single OPENDIR request was reused to serve multiple ls invocations across a touch+rm cycle, with no CLOSEDIR/reopen in between. The post-write READDIR request came back as an immediate STATUS (EOF) instead of a NAME reply, because the remote SFTP directory stream behind that handle had already been read to EOF by the earlier listing.

Root cause

cache_readdir() in cache.c invalidates the directory cache correctly on write (cache_invalidate_dir()), but its refill path reuses the handle unconditionally, regardless of whether it got there through a valid cache hit or a cache miss:

cfi = (struct file_handle*) fi->fh;
if (cfi->is_open)
    fi->fh = cfi->fs_fh;          // reused even when the cache was just invalidated
else {
    if (cache.next_oper->opendir) {
        err = cache.next_oper->opendir(path, fi);
        if (err)
            return err;
    }
    cfi->is_open = 1;
    cfi->fs_fh = fi->fh;
}

The remote SFTP directory stream is positional/stateful. Once a file_handle has been read to EOF by an earlier cache_readdir() call, reusing it for a later "live" read (triggered by cache invalidation from a write) just returns EOF again - the directory looks empty regardless of what actually changed on the server.

This is not a recent regression: the same logic is present in the current 3.7.6 release and in master as of this writing (the only post-3.7.6 changes to cache.c are unrelated: filling stat info from cached readdir results, and a memleak fix in readlink).

Related to #211 ("Seeking in directory stream has no effect"), which describes the same user-visible symptom (directory appears empty after create/delete cycles through mc) and was closed as a duplicate in 2020. It does not appear to have ever been fixed - the handle-reuse-without-reopen behavior it points at is still in cache_readdir() today.

Fix

When falling through to a live read because the cache was missing or invalidated, close the existing remote handle (if any) and open a fresh one before reading, instead of reusing a stream that may already be exhausted:

cfi = (struct file_handle*) fi->fh;
if (cfi->is_open) {
    /* We only reach here when the cached listing was missing or had
     * expired/been invalidated (e.g. by a create/delete/rename
     * through this mount) - the fast path above already returned
     * for a valid cache hit. The remote directory stream behind
     * fs_fh is positional: once it has been read to EOF it will
     * keep returning EOF, even though the actual directory contents
     * may have changed. Close and reopen so the upcoming read
     * actually reflects the current directory. */
    if (cache.next_oper->releasedir) {
        fi->fh = cfi->fs_fh;
        cache.next_oper->releasedir(path, fi);
    }
    cfi->is_open = 0;
}
if (!cfi->is_open) {
    if (cache.next_oper->opendir) {
        err = cache.next_oper->opendir(path, fi);
        if (err)
            return err;
    }
    cfi->is_open = 1;
    cfi->fs_fh = fi->fh;
}

The existing valid-cache-hit fast path (serving node->dir directly) returns earlier in the function and is unaffected - the extra releasedir+opendir round trip only happens on the cache-miss/refresh path, which is exactly the case that was broken. No new mount option, no behavior change when the cache is warm.

Testing

  • Built with meson setup build && ninja -C build against macFUSE/glib2/openssh (macOS).
  • Reproduced the bug against a real SFTP server (a jailed sftp-only account) with the stock build: ls, ls again (cache hit, correct), touch+rm, ls again (empty - bug reproduced).
  • Same sequence with the patched build, several times, including consecutive write/list cycles: every listing came back correct.
  • Verified via debug trace (-f -d) that the fix path is actually exercised: after each write-triggered invalidation, the log now shows CLOSE on the directory handle followed by a fresh OPENDIR, where the unpatched binary showed the old handle reused with no CLOSE at all.
  • Installed the patched binary as the system's sshfs (macOS/macFUSE) and confirmed the fix holds on a real, long-lived mount used for day-to-day file sharing.

cache_readdir() reused the already-open remote directory handle
whenever the cached listing was missing or invalidated, instead of
just when it was still valid. The SFTP directory stream is positional:
once read to EOF, rereading the same handle after a local
create/delete/rename returns EOF again, making the directory look
empty even though its contents changed on the server.

Close and reopen the handle before the live read whenever we fall
through the cache-hit fast path, so the read reflects the current
directory instead of a stale, exhausted stream.
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.

1 participant