Skip to content

fix: default nlink=2 for directories to fix macOS NFS ReadDir#147

Open
teleclawd wants to merge 1 commit intowillscott:masterfrom
swarm-ops:fix/macos-nlink-directory
Open

fix: default nlink=2 for directories to fix macOS NFS ReadDir#147
teleclawd wants to merge 1 commit intowillscott:masterfrom
swarm-ops:fix/macos-nlink-directory

Conversation

@teleclawd
Copy link
Copy Markdown

@teleclawd teleclawd commented Mar 19, 2026

Problem

macOS NFS client silently skips ReadDir when a directory reports nlink=1, interpreting it as having no subdirectories. This breaks any go-nfs consumer that uses a virtual billy.Filesystem where file.GetInfo() returns nil (no real inode/syscall.Stat_t data).

When GetInfo returns nil, ToFileAttribute defaults Nlink to 1 for everything — including directories. On Linux this works fine (the NFS client calls ReadDir regardless). On macOS, it silently produces empty directory listings or hangs.

nfsstat -c confirms: 255 Getattr calls, 321 Lookups, 0 Readdir, 0 ReaddirPlus — macOS never even attempts to list directory contents.

Root Cause

ToFileAttribute() in file.go unconditionally sets f.Nlink = 1 before checking GetInfo. Virtual filesystems don't have real stat data, so GetInfo returns nil and the default of 1 persists for directories.

POSIX convention is that directories always have nlink ≥ 2 (. self-link + parent entry). macOS NFS client uses this as an optimization hint — nlink=1 on a directory signals "no subdirectories, skip ReadDir." While the NFS spec (RFC 1813) doesn't mandate a minimum nlink for directories, returning 1 violates the POSIX convention that macOS depends on.

Fix

Default nlink to 2 for directories, 1 for files, before the GetInfo check. Real filesystems with actual inode data are completely unaffected since GetInfo overwrites the default when it succeeds.

One-line semantic change, no behavioral change for real filesystems.

Testing

  • All existing tests pass (go test ./...)
  • Verified fix resolves macOS NFS ReadDir hang with TigerFS (Timescale's Postgres-backed virtual filesystem)
  • Tested on macOS 15.3 (Sequoia) with Apple's built-in NFS client
  • nfsstat -c confirms Readdir calls resume after fix

macOS NFS client silently skips ReadDir when a directory has nlink=1,
interpreting it as an empty directory. This breaks any virtual filesystem
where file.GetInfo() returns nil (i.e., no real inode data is available
to populate nlink from syscall.Stat_t).

The NFS spec (RFC 1813 §3.3.1) defines nlink as the number of hard
links to the object. For directories, this should be at minimum 2
(the directory itself + its '..' entry). A value of 1 is semantically
incorrect for directories and causes macOS's NFS client to optimize
away the ReadDir call entirely.

Fix: default nlink to 2 for directories (1 for files) before checking
GetInfo. Real filesystems with actual inode data are unaffected since
GetInfo overwrites the default.

Affected projects: any go-nfs consumer using billy.Filesystem with
virtual/in-memory FileInfo (e.g., TigerFS on macOS).
@teleclawd teleclawd force-pushed the fix/macos-nlink-directory branch from c6ec595 to 83cbf97 Compare March 19, 2026 04:21
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