From 66b3724bc8fafa41278a2a5f973d7af2a76b1933 Mon Sep 17 00:00:00 2001 From: Yike Ye Date: Mon, 29 Jun 2026 18:57:23 +0800 Subject: [PATCH 1/4] fix: replace assert(offset==0) with early return in readdir On macOS with macFUSE 5.3.x (FSKit backend) and macOS 27 Golden Gate beta, Finder/Spotlight may call readdir with a non-zero offset even when fuse_fill_dir is always called with offset=0 (old-style readdir). This triggers the assert and aborts the sshfs process. Replace assert(offset == 0) with if (offset != 0) return 0 in: - sftp_readdir_async() in sshfs.c - sftp_readdir_sync() in sshfs.c - cache_readdir() in cache.c Since all entries are returned in the first (offset=0) call, returning 0 on subsequent calls is semantically correct and prevents the crash. Fixes: https://github.com/libfuse/sshfs/issues/338 Tested: macOS 27 Golden Gate beta 2, macFUSE 5.3.2 --- cache.c | 8 +++++++- sshfs.c | 22 +++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/cache.c b/cache.c index 4277163e..7d7a9e01 100644 --- a/cache.c +++ b/cache.c @@ -386,7 +386,13 @@ static int cache_readdir(const char *path, void *buf, fuse_fill_dir_t filler, struct node *node; struct cache_dirent **cdent; - assert(offset == 0); +#ifndef __APPLE__ + assert(offset == 0); /* keep for non-macOS; see sshfs.c sftp_readdir_async */ +#endif + /* On macOS with macFUSE 5.3.x / FSKit, may be called with offset != 0. + * All entries were already returned in the first (offset=0) call. */ + if (offset != 0) + return 0; pthread_mutex_lock(&cache.lock); node = cache_lookup(path); diff --git a/sshfs.c b/sshfs.c index c97f66d4..0b23365c 100644 --- a/sshfs.c +++ b/sshfs.c @@ -2318,7 +2318,21 @@ static int sftp_readdir_async(struct conn *conn, struct buffer *handle, int done = 0; - assert(offset == 0); + /* + * sshfs uses old-style readdir (filler always called with offset=0), + * so FUSE should return all entries in one call (offset=0). + * On macOS with macFUSE 5.3.x / FSKit backend, Finder or Spotlight + * may call readdir again with a non-zero offset even in old-style mode. + * When that happens, all entries were already returned in the first + * call, so we can safely return 0 (no more entries) instead of crashing. + * See: https://github.com/libfuse/sshfs/issues/338 + */ +#ifndef __APPLE__ + assert(offset == 0); +#endif + if (offset != 0) + return 0; + while (!done || outstanding) { struct request *req; struct buffer name; @@ -2390,7 +2404,13 @@ static int sftp_readdir_sync(struct conn *conn, struct buffer *handle, void *buf, off_t offset, fuse_fill_dir_t filler) { int err; + /* See comment in sftp_readdir_async for why offset != 0 is handled + * this way instead of asserting. */ +#ifndef __APPLE__ assert(offset == 0); +#endif + if (offset != 0) + return 0; do { struct buffer name; err = sftp_request(conn, SSH_FXP_READDIR, handle, SSH_FXP_NAME, &name); From 36514a12f831065a9ab9f98e2572c924a7f58f95 Mon Sep 17 00:00:00 2001 From: Yike Ye Date: Mon, 6 Jul 2026 00:50:58 +0800 Subject: [PATCH 2/4] fix: snapshot whole directory in readdir so entries stop vanishing The previous fix (66b3724) replaced assert(offset==0) with `return 0`. That stopped the crash but not the root cause, as dmik reported in macfuse/macfuse#1131: under heavy concurrent access the directory contents still vanish. sshfs uses old-style readdir over a single-pass SFTP directory handle. When macFUSE 5.3.x's FSKit backend re-issues readdir on the same handle (non-zero offset, or an offset==0 rewind), the already-exhausted handle yields nothing, so readdir returns an empty listing. The directory cache (cache.c, 20s) then caches that empty result, so the directory appears empty for ~20s until the cache expires -- exactly the "contents vanish then reappear" behaviour that was reported. Fix: snapshot the entire directory into memory at the first readdir and serve every subsequent readdir call (any offset, including rewinds) from that snapshot. This makes readdir idempotent -- the kernel can re-issue it with any offset and always gets the complete listing -- so the cache never stores a partial/empty result. Tested on a real remote mount (HPC over ProxyJump) under heavy VS Code load, with logging added to the cache layer: readdir returns the full listing every time, zero empty results, directory no longer disappears. Refs: https://github.com/libfuse/sshfs/issues/338 Co-Authored-By: Claude Opus 4.8 --- sshfs.c | 108 ++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 70 insertions(+), 38 deletions(-) diff --git a/sshfs.c b/sshfs.c index 0b23365c..d8bb50dc 100644 --- a/sshfs.c +++ b/sshfs.c @@ -228,9 +228,25 @@ struct buffer { size_t size; }; +struct dir_entry { + char *name; + struct stat stbuf; +}; + struct dir_handle { struct buffer buf; struct conn *conn; + /* + * Cached snapshot of the whole directory listing. sshfs uses + * old-style readdir and the underlying SFTP directory handle is + * single-pass, so we read the entire directory once and serve every + * readdir call (with any offset, including offset==0 rewinds issued + * by macFUSE) from this snapshot. See: + * https://github.com/libfuse/sshfs/issues/338 + */ + GPtrArray *entries; /* array of struct dir_entry * */ + int read_done; /* directory has been read from the server */ + int read_err; /* error from reading the directory, if any */ }; struct list_head { @@ -958,8 +974,7 @@ static int buf_get_statvfs(struct buffer *buf, struct statvfs *stbuf) return 0; } -static int buf_get_entries(struct buffer *buf, void *dbuf, - fuse_fill_dir_t filler) +static int buf_get_entries(struct buffer *buf, GPtrArray *entries) { uint32_t count; unsigned i; @@ -978,11 +993,16 @@ static int buf_get_entries(struct buffer *buf, void *dbuf, free(longname); err = buf_get_attrs(buf, &stbuf, NULL); if (!err) { + struct dir_entry *entry; if (sshfs.follow_symlinks && S_ISLNK(stbuf.st_mode)) { stbuf.st_mode = 0; } - filler(dbuf, name, &stbuf, 0, 0); + entry = g_new(struct dir_entry, 1); + entry->name = name; + entry->stbuf = stbuf; + g_ptr_array_add(entries, entry); + name = NULL; /* ownership passed to entry */ } } free(name); @@ -2309,7 +2329,7 @@ static int sshfs_req_pending(struct request *req) } static int sftp_readdir_async(struct conn *conn, struct buffer *handle, - void *buf, off_t offset, fuse_fill_dir_t filler) + GPtrArray *entries) { int err = 0; int outstanding = 0; @@ -2318,21 +2338,6 @@ static int sftp_readdir_async(struct conn *conn, struct buffer *handle, int done = 0; - /* - * sshfs uses old-style readdir (filler always called with offset=0), - * so FUSE should return all entries in one call (offset=0). - * On macOS with macFUSE 5.3.x / FSKit backend, Finder or Spotlight - * may call readdir again with a non-zero offset even in old-style mode. - * When that happens, all entries were already returned in the first - * call, so we can safely return 0 (no more entries) instead of crashing. - * See: https://github.com/libfuse/sshfs/issues/338 - */ -#ifndef __APPLE__ - assert(offset == 0); -#endif - if (offset != 0) - return 0; - while (!done || outstanding) { struct request *req; struct buffer name; @@ -2383,7 +2388,7 @@ static int sftp_readdir_async(struct conn *conn, struct buffer *handle, done = 1; } if (!done) { - err = buf_get_entries(&name, buf, filler); + err = buf_get_entries(&name, entries); buf_free(&name); /* increase number of outstanding requests */ @@ -2401,21 +2406,14 @@ static int sftp_readdir_async(struct conn *conn, struct buffer *handle, } static int sftp_readdir_sync(struct conn *conn, struct buffer *handle, - void *buf, off_t offset, fuse_fill_dir_t filler) + GPtrArray *entries) { int err; - /* See comment in sftp_readdir_async for why offset != 0 is handled - * this way instead of asserting. */ -#ifndef __APPLE__ - assert(offset == 0); -#endif - if (offset != 0) - return 0; do { struct buffer name; err = sftp_request(conn, SSH_FXP_READDIR, handle, SSH_FXP_NAME, &name); if (!err) { - err = buf_get_entries(&name, buf, filler); + err = buf_get_entries(&name, entries); buf_free(&name); } } while (!err); @@ -2454,24 +2452,56 @@ static int sshfs_opendir(const char *path, struct fuse_file_info *fi) return err; } +static void free_dir_entry(gpointer data) +{ + struct dir_entry *entry = data; + free(entry->name); + g_free(entry); +} + static int sshfs_readdir(const char *path, void *dbuf, fuse_fill_dir_t filler, off_t offset, struct fuse_file_info *fi, enum fuse_readdir_flags flags) { - (void) path; (void) flags; - int err; + (void) path; (void) flags; (void) offset; + unsigned i; struct dir_handle *handle; handle = (struct dir_handle*) fi->fh; - if (sshfs.sync_readdir) - err = sftp_readdir_sync(handle->conn, &handle->buf, dbuf, - offset, filler); - else - err = sftp_readdir_async(handle->conn, &handle->buf, dbuf, - offset, filler); + /* + * Read the whole directory into a cached snapshot on the first call. + * The SFTP directory handle is single-pass, so this snapshot is what + * lets us serve repeated readdir calls (any offset, including + * offset==0 rewinds from macFUSE) without the directory contents + * vanishing. See: https://github.com/libfuse/sshfs/issues/338 + */ + if (!handle->read_done) { + handle->entries = g_ptr_array_new_with_free_func(free_dir_entry); + if (sshfs.sync_readdir) + handle->read_err = sftp_readdir_sync(handle->conn, + &handle->buf, + handle->entries); + else + handle->read_err = sftp_readdir_async(handle->conn, + &handle->buf, + handle->entries); + handle->read_done = 1; + } - return err; + if (handle->read_err) + return handle->read_err; + + /* + * Old-style readdir: hand FUSE the complete listing with offset 0 and + * let it do the offset-based slicing itself. + */ + for (i = 0; i < handle->entries->len; i++) { + struct dir_entry *entry = g_ptr_array_index(handle->entries, i); + filler(dbuf, entry->name, &entry->stbuf, 0, 0); + } + + return 0; } static int sshfs_releasedir(const char *path, struct fuse_file_info *fi) @@ -2486,6 +2516,8 @@ static int sshfs_releasedir(const char *path, struct fuse_file_info *fi) handle->conn->dir_count--; pthread_mutex_unlock(&sshfs.lock); buf_free(&handle->buf); + if (handle->entries) + g_ptr_array_free(handle->entries, TRUE); g_free(handle); return err; } From 30bf21b091a70ba4ce772544816c36483497f0b7 Mon Sep 17 00:00:00 2001 From: Yike Ye Date: Tue, 7 Jul 2026 11:56:39 +0800 Subject: [PATCH 3/4] fix: refresh directory snapshot on each new enumeration The previous commit snapshotted the directory once per opendir and served every readdir from that snapshot. That stopped the vanishing listings, but froze the snapshot for the whole opendir session: when an application holds a directory open and re-reads it (e.g. VS Code watching a project), newly created files never appeared and the cached stat sizes went stale -- dmik reported files looking truncated as the stale size served by readdir fought the live size from getattr. Make the snapshot per-enumeration instead of per-opendir: on offset == 0 (a fresh listing or a rewinddir) reopen a fresh SFTP handle and re-read the directory from the server, so newly created files and updated attributes show up; offset != 0 continues to be served from the snapshot taken at offset 0 so the listing never comes back empty on an already-exhausted handle. The directory path is stored in the handle so reopening works even when readdir is called with a null path. Verified on localhost with a process holding a single directory handle open: newly created files appear and file sizes update within the same open session, while concurrent listings stay complete and stable. Refs: https://github.com/libfuse/sshfs/issues/338 Co-Authored-By: Claude Opus 4.8 --- sshfs.c | 102 +++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 76 insertions(+), 26 deletions(-) diff --git a/sshfs.c b/sshfs.c index d8bb50dc..e4c9f320 100644 --- a/sshfs.c +++ b/sshfs.c @@ -237,16 +237,20 @@ struct dir_handle { struct buffer buf; struct conn *conn; /* - * Cached snapshot of the whole directory listing. sshfs uses - * old-style readdir and the underlying SFTP directory handle is - * single-pass, so we read the entire directory once and serve every - * readdir call (with any offset, including offset==0 rewinds issued - * by macFUSE) from this snapshot. See: + * Snapshot of the *current* enumeration. sshfs uses old-style readdir + * over a single-pass SFTP directory handle. Each new enumeration + * (offset == 0, e.g. a fresh listing or a rewinddir) re-reads the + * directory from the server so newly created files and updated + * attributes show up; a continuation (offset != 0) is served from this + * snapshot so the listing never comes back empty when the handle has + * already been exhausted. See: * https://github.com/libfuse/sshfs/issues/338 */ GPtrArray *entries; /* array of struct dir_entry * */ - int read_done; /* directory has been read from the server */ - int read_err; /* error from reading the directory, if any */ + char *path; /* directory path, for reopening the handle */ + int read_err; /* error from the last read, if any */ + int has_handle; /* buf holds an open SFTP dir handle to close */ + int buf_exhausted; /* the SFTP handle has been read to EOF */ }; struct list_head { @@ -2445,6 +2449,8 @@ static int sshfs_opendir(const char *path, struct fuse_file_info *fi) handle->conn = conn; handle->conn->dir_count++; pthread_mutex_unlock(&sshfs.lock); + handle->has_handle = 1; + handle->path = g_strdup(path); fi->fh = (unsigned long) handle; } else g_free(handle); @@ -2459,34 +2465,73 @@ static void free_dir_entry(gpointer data) g_free(entry); } +/* + * Reopen the SFTP directory handle. SFTP has no rewind, so re-reading a + * directory from the start means closing the exhausted handle and opening a + * fresh one. + */ +static int sshfs_reopen_dir(struct dir_handle *handle) +{ + struct buffer buf; + int err; + + if (handle->has_handle) { + sftp_request(handle->conn, SSH_FXP_CLOSE, &handle->buf, 0, NULL); + /* free + reset to a clean state so sftp_request can refill it */ + buf_clear(&handle->buf); + handle->has_handle = 0; + } + buf_init(&buf, 0); + buf_add_path(&buf, handle->path); + err = sftp_request(handle->conn, SSH_FXP_OPENDIR, &buf, + SSH_FXP_HANDLE, &handle->buf); + buf_free(&buf); + if (!err) { + buf_finish(&handle->buf); + handle->has_handle = 1; + handle->buf_exhausted = 0; + } + return err; +} + static int sshfs_readdir(const char *path, void *dbuf, fuse_fill_dir_t filler, off_t offset, struct fuse_file_info *fi, enum fuse_readdir_flags flags) { - (void) path; (void) flags; (void) offset; + (void) path; (void) flags; unsigned i; struct dir_handle *handle; handle = (struct dir_handle*) fi->fh; /* - * Read the whole directory into a cached snapshot on the first call. - * The SFTP directory handle is single-pass, so this snapshot is what - * lets us serve repeated readdir calls (any offset, including - * offset==0 rewinds from macFUSE) without the directory contents - * vanishing. See: https://github.com/libfuse/sshfs/issues/338 + * offset == 0 starts a fresh enumeration (a new listing or a + * rewinddir), so re-read the directory from the server to pick up newly + * created files and updated attributes. The SFTP handle is single-pass: + * once read to EOF, reusing it would read nothing and the directory + * would appear empty, so we reopen a fresh handle first. offset != 0 is + * a continuation of the current enumeration and is served from the + * snapshot taken at offset 0. + * See: https://github.com/libfuse/sshfs/issues/338 */ - if (!handle->read_done) { - handle->entries = g_ptr_array_new_with_free_func(free_dir_entry); - if (sshfs.sync_readdir) - handle->read_err = sftp_readdir_sync(handle->conn, - &handle->buf, - handle->entries); - else - handle->read_err = sftp_readdir_async(handle->conn, - &handle->buf, - handle->entries); - handle->read_done = 1; + if (offset == 0 || handle->entries == NULL) { + if (handle->buf_exhausted) + handle->read_err = sshfs_reopen_dir(handle); + if (!handle->read_err) { + if (handle->entries) + g_ptr_array_free(handle->entries, TRUE); + handle->entries = + g_ptr_array_new_with_free_func(free_dir_entry); + if (sshfs.sync_readdir) + handle->read_err = sftp_readdir_sync( + handle->conn, &handle->buf, + handle->entries); + else + handle->read_err = sftp_readdir_async( + handle->conn, &handle->buf, + handle->entries); + handle->buf_exhausted = 1; + } } if (handle->read_err) @@ -2511,13 +2556,18 @@ static int sshfs_releasedir(const char *path, struct fuse_file_info *fi) struct dir_handle *handle; handle = (struct dir_handle*) fi->fh; - err = sftp_request(handle->conn, SSH_FXP_CLOSE, &handle->buf, 0, NULL); + err = 0; + if (handle->has_handle) { + err = sftp_request(handle->conn, SSH_FXP_CLOSE, &handle->buf, + 0, NULL); + buf_free(&handle->buf); + } pthread_mutex_lock(&sshfs.lock); handle->conn->dir_count--; pthread_mutex_unlock(&sshfs.lock); - buf_free(&handle->buf); if (handle->entries) g_ptr_array_free(handle->entries, TRUE); + g_free(handle->path); g_free(handle); return err; } From c14a6e7c887feb0422f93648088bfdc5dc91f6e5 Mon Sep 17 00:00:00 2001 From: Yike Ye Date: Wed, 8 Jul 2026 14:39:55 +0800 Subject: [PATCH 4/4] fix: make cache_readdir ignore the offset instead of returning nothing cache_readdir still had the pattern rejected in PR #371: an assert(offset == 0) on non-Apple platforms and an early "return 0" on macOS. As bfleischer pointed out, being called with a non-zero offset is valid under the FUSE contract on every platform: the two readdir modes are internal to libfuse, so the kernel or FSKit may legitimately start an enumeration at a saved cookie. A mode-1 filesystem must then still hand the complete listing to the filler with offset 0 (libfuse caches the entries and does the slicing); returning nothing is interpreted as an empty directory and makes entries silently vanish. Drop the assert and the early return, ignore the offset entirely, and always request a fresh full enumeration from the underlying filesystem when the cache is stale. This also removes the platform ifdef, since the correct behaviour is identical on Linux and macOS. Refs: https://github.com/libfuse/sshfs/issues/338 Refs: https://github.com/libfuse/sshfs/pull/371#issuecomment-4905152422 Co-Authored-By: Claude Fable 5 --- cache.c | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/cache.c b/cache.c index 7d7a9e01..9771d1d7 100644 --- a/cache.c +++ b/cache.c @@ -8,7 +8,6 @@ #include "cache.h" #include -#include #include #include #include @@ -386,13 +385,18 @@ static int cache_readdir(const char *path, void *buf, fuse_fill_dir_t filler, struct node *node; struct cache_dirent **cdent; -#ifndef __APPLE__ - assert(offset == 0); /* keep for non-macOS; see sshfs.c sftp_readdir_async */ -#endif - /* On macOS with macFUSE 5.3.x / FSKit, may be called with offset != 0. - * All entries were already returned in the first (offset=0) call. */ - if (offset != 0) - return 0; + /* + * sshfs uses mode-1 readdir: the offset parameter is ignored and the + * complete listing is always handed to the filler with offset 0; + * libfuse caches the entries and does the offset-based slicing + * itself. Being called with a non-zero offset is valid (e.g. FSKit + * on macOS resuming an enumeration from a saved cookie with a fresh + * directory handle) and must still produce the full listing -- + * returning nothing here would be interpreted as an empty directory + * and make entries silently vanish. + * See: https://github.com/libfuse/sshfs/issues/338 + */ + (void) offset; pthread_mutex_lock(&cache.lock); node = cache_lookup(path); @@ -427,7 +431,8 @@ static int cache_readdir(const char *path, void *buf, fuse_fill_dir_t filler, ch.dir = g_ptr_array_new(); g_ptr_array_set_free_func(ch.dir, free_cache_dirent); ch.wrctr = cache_get_write_ctr(); - err = cache.next_oper->readdir(path, &ch, cache_dirfill, offset, fi, flags); + /* Always request a fresh, complete enumeration to (re)fill the cache */ + err = cache.next_oper->readdir(path, &ch, cache_dirfill, 0, fi, flags); g_ptr_array_add(ch.dir, NULL); dir = ch.dir; if (!err) {