Repository: tetratelabs/wazero
Pull request #1549 (2023-07-01 23:16:53)

fd_readdir: reverts to special cased dirent cache
This reverts to a design similar to, but more performant than 1.2.0+. Due to missing benchmarks, we accidentally regressed performance while attempting to clean the internal implementation of directory pagination.

For reasons described in #1529, the refactoring was going the wrong way anyway. This goes back to the better performing impl we had before, makes it perform even better, and backfills missing documentation.

```
goos: darwin
goarch: arm64
pkg: github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1
                                      │ before-refactor.txt │             before.txt             │              now.txt               │
                                      │       sec/op        │   sec/op     vs base               │   sec/op     vs base               │
_fdReaddir/embed.FS-12                          2.255µ ± 7%   2.879µ ± 6%  +27.70% (p=0.002 n=6)   1.554µ ± 4%  -31.09% (p=0.002 n=6)
_fdReaddir/embed.FS_-_two_calls-12              2.897µ ± 1%   3.606µ ± 3%  +24.50% (p=0.002 n=6)   2.224µ ± 3%  -23.22% (p=0.002 n=6)
_fdReaddir/os.DirFS-12                          42.74µ ± 1%   43.62µ ± 1%   +2.07% (p=0.002 n=6)   39.74µ ± 1%   -7.00% (p=0.002 n=6)
_fdReaddir/os.DirFS_-_two_calls-12              43.24µ ± 0%   44.15µ ± 2%   +2.12% (p=0.002 n=6)   40.76µ ± 0%   -5.72% (p=0.002 n=6)
_fdReaddir/sysfs.DirFS-12                       39.71µ ± 0%   41.33µ ± 0%   +4.08% (p=0.002 n=6)   39.64µ ± 1%        ~ (p=0.310 n=6)
_fdReaddir/sysfs.DirFS_-_two_calls-12           40.67µ ± 1%   41.57µ ± 2%   +2.21% (p=0.002 n=6)   40.71µ ± 1%        ~ (p=0.461 n=6)
geomean                                         16.40µ        18.03µ        +9.91%                 14.43µ       -12.02%

                                      │ before-refactor.txt │             before.txt              │              now.txt               │
                                      │        B/op         │     B/op      vs base               │     B/op      vs base              │
_fdReaddir/embed.FS-12                         1.250Ki ± 0%   2.344Ki ± 0%  +87.50% (p=0.002 n=6)   1.219Ki ± 0%  -2.50% (p=0.002 n=6)
_fdReaddir/embed.FS_-_two_calls-12             1.359Ki ± 0%   2.609Ki ± 0%  +91.95% (p=0.002 n=6)   1.328Ki ± 0%  -2.30% (p=0.002 n=6)
_fdReaddir/os.DirFS-12                         6.141Ki ± 0%   8.391Ki ± 0%  +36.64% (p=0.002 n=6)   6.078Ki ± 0%  -1.02% (p=0.002 n=6)
_fdReaddir/os.DirFS_-_two_calls-12             7.445Ki ± 0%   8.672Ki ± 0%  +16.47% (p=0.002 n=6)   7.383Ki ± 0%  -0.84% (p=0.002 n=6)
_fdReaddir/sysfs.DirFS-12                      5.906Ki ± 0%   8.211Ki ± 0%  +39.02% (p=0.002 n=6)   6.031Ki ± 0%  +2.12% (p=0.002 n=6)
_fdReaddir/sysfs.DirFS_-_two_calls-12          7.211Ki ± 0%   8.492Ki ± 0%  +17.77% (p=0.002 n=6)   7.336Ki ± 0%  +1.73% (p=0.002 n=6)
geomean                                        3.860Ki        5.606Ki       +45.22%                 3.842Ki       -0.48%

                                      │ before-refactor.txt │             before.txt             │              now.txt              │
                                      │      allocs/op      │  allocs/op   vs base               │ allocs/op   vs base               │
_fdReaddir/embed.FS-12                          13.000 ± 0%   19.000 ± 0%  +46.15% (p=0.002 n=6)   8.000 ± 0%  -38.46% (p=0.002 n=6)
_fdReaddir/embed.FS_-_two_calls-12               18.00 ± 0%    24.00 ± 0%  +33.33% (p=0.002 n=6)   13.00 ± 0%  -27.78% (p=0.002 n=6)
_fdReaddir/os.DirFS-12                           56.00 ± 0%    66.00 ± 0%  +17.86% (p=0.002 n=6)   52.00 ± 0%   -7.14% (p=0.002 n=6)
_fdReaddir/os.DirFS_-_two_calls-12               64.00 ± 0%    71.00 ± 0%  +10.94% (p=0.002 n=6)   60.00 ± 0%   -6.25% (p=0.002 n=6)
_fdReaddir/sysfs.DirFS-12                        51.00 ± 0%    62.00 ± 0%  +21.57% (p=0.002 n=6)   52.00 ± 0%   +1.96% (p=0.002 n=6)
_fdReaddir/sysfs.DirFS_-_two_calls-12            59.00 ± 0%    67.00 ± 0%  +13.56% (p=0.002 n=6)   60.00 ± 0%   +1.69% (p=0.002 n=6)
geomean                                          36.90         45.50       +23.32%                 31.69       -14.12%
```

codefromthecrypt: whoops we are missing a test that's in wasi-testsuite. I'll backfill

```
 right: `3`: expected three entries', src/bin/fd_readdir.rs:143:5
```
codefromthecrypt: re-ran the benchmark because the last commit, needed to pass wasi-testsuite, costs a little more memory. However, we're basically at the performance we had prior to refactoring, plus much better if using `fs.FS`.

Patch:
diff --git a/RATIONALE.md b/RATIONALE.md
index 27cc39f4..487fab5c 100644
--- a/RATIONALE.md
+++ b/RATIONALE.md
@@ -681,7 +681,61 @@ these to be easier to close.
 See https://github.com/WebAssembly/wasi-testsuite
 See https://github.com/golang/go/issues/58141
 
-### fd_pread: io.Seeker fallback when io.ReaderAt is not supported
+## Why is our `Readdir` function more like Go's `os.File` than POSIX `readdir`?
+
+At one point we attempted to move from a bulk `Readdir` function to something
+more like the POSIX `DIR` struct, exposing functions like `telldir`, `seekdir`
+and `readdir`. However, we chose the design more like `os.File.Readdir`,
+because it performs and fits wasip1 better.
+
+### wasip1/wasix
+
+`fd_readdir` in wasip1 (and so also wasix) is like `getdents` in Linux, not
+`readdir` in POSIX. `getdents` is more like Go's `os.File.Readdir`.
+
+We currently have an internal type `sys.DirentCache` which only is used by
+wasip1 or wasix. When `HostModuleBuilder` adds support for instantiation state,
+we could move this to the `wasi_snapshot_preview1` package. Meanwhile, all
+filesystem code is internal anyway, so this special-case is acceptable.
+
+### wasip2
+
+`directory-entry-stream` in wasi-filesystem preview2 is defined in component
+model, not an ABI, but in wasmtime it is a consuming iterator. A consuming
+iterator is easy to support with anything (like `Readdir(1)`), even if it is
+inefficient as you can neither bulk read nor skip. The implementation of the
+preview1 adapter (uses preview2) confirms this. They use a dirent cache similar
+in some ways to our `sysfs.DirentCache`. As there is no seek concept in
+preview2, they interpret the cookie as numeric and read on repeat entries when
+a cache wasn't available. Note: we currently do not skip-read like this as it
+risks buffering large directories, and no user has requested entries before the
+cache, yet.
+
+Regardless, wasip2 is not complete until the end of 2023. We can defer design
+discussion until after it is stable and after the reference impl wasmtime
+implements it.
+
+See
+ * https://github.com/WebAssembly/wasi-filesystem/blob/ef9fc87c07323a6827632edeb6a7388b31266c8e/example-world.md#directory_entry_stream
+ * https://github.com/bytecodealliance/wasmtime/blob/b741f7c79d72492d17ab8a29c8ffe4687715938e/crates/wasi/src/preview2/preview2/filesystem.rs#L286-L296
+ * https://github.com/bytecodealliance/preview2-prototyping/blob/e4c04bcfbd11c42c27c28984948d501a3e168121/crates/wasi-preview1-component-adapter/src/lib.rs#L2131-L2137
+ * https://github.com/bytecodealliance/preview2-prototyping/blob/e4c04bcfbd11c42c27c28984948d501a3e168121/crates/wasi-preview1-component-adapter/src/lib.rs#L936
+
+### wasip3
+
+`directory-entry-stream` is documented to change significantly in wasip3 moving
+from synchronous to synchronous streams. This is dramatically different than
+POSIX `readdir` which is synchronous.
+
+Regardless, wasip3 is not complete until after wasip2, which means 2024 or
+later. We can defer design discussion until after it is stable and after the
+reference impl wasmtime implements it.
+
+See
+ * https://github.com/WebAssembly/WASI/blob/ddfe3d1dda5d1473f37ecebc552ae20ce5fd319a/docs/WitInWasi.md#Streams
+ * https://docs.google.com/presentation/d/1MNVOZ8hdofO3tI0szg_i-Yoy0N2QPU2C--LzVuoGSlE/edit#slide=id.g1270ef7d5b6_0_662
+
+### How do we implement `Pread` with an `fs.File`?
 
 `ReadAt` is the Go equivalent to `pread`: it does not affect, and is not
 affected by, the underlying file offset. Unfortunately, `io.ReaderAt` is not
@@ -729,7 +783,7 @@ third `path_len` has ambiguous semantics.
   `path` offset for the exact length of `path_len`.
 
 Wasmer considers `path_len` to be the maximum length instead of the exact
-length  that should be written.
+length that should be written.
 See https://github.com/wasmerio/wasmer/blob/3463c51268ed551933392a4063bd4f8e7498b0f6/lib/wasi/src/syscalls/mod.rs#L764
 
 The semantics in wazero follows that of wasmtime.
@@ -738,41 +792,9 @@ See https://github.com/bytecodealliance/wasmtime/blob/2ca01ae9478f199337cf743a6a
 Their semantics match when `path_len` == the length of `path`, so in practice
 this difference won't matter match.
 
-## Why does fd_readdir include dot (".") and dot-dot ("..") entries?
-
-When reading a directory, dot (".") and dot-dot ("..") entries are problematic.
-yet these are returned for `wasi_snapshot_preview1.fd_readdir`. This was a
-change specifically made to pass wasi-testsuite, and has problems known since
-late 2019.
-
-### Why don't we pre-populate an inode for the dot-dot ("..") entry?
+## fd_readdir
 
-We only populate an inode for dot (".") because wasi-testsuite requires it, and
-we likely already have it (because we cache it). We could attempt to populate
-one for dot-dot (".."), but chose not to.
-
-Firstly, wasi-testsuite does not require the inode of dot-dot, possibly because
-the wasip2 adapter doesn't populate it (but we don't really know why).
-
-The only other reason to populate it would be to avoid wasi-libc's stat fanout
-when it is missing. However, the inode for dot-dot is not cached, and is also
-likely not possible to get on a pseudo file. Even if we could get it, we would
-have to use stat. In other words, pre-populating this would have the same cost
-as waiting for something to call stat instead.
-
-Fetching dot-dot's inode despite the above not only doesn't help wasi-libc, but
-it also hurts languages that don't use it, such as Go. These languages would
-pay a stat syscall penalty even if they don't need the inode. In fact, Go
-discards both dot entries!
-
-In summary, there are no significant upsides in attempting to pre-fetch
-dot-dot's inode, and there are downsides to doing it anyway.
-
-See https://github.com/WebAssembly/wasi-libc/pull/345
-See https://github.com/WebAssembly/wasi-testsuite/blob/main/tests/rust/src/bin/fd_readdir.rs#L108
-See https://github.com/bytecodealliance/preview2-prototyping/blob/e4c04bcfbd11c42c27c28984948d501a3e168121/crates/wasi-preview1-component-adapter/src/lib.rs#L1037
-
-## Why does "wasi_snapshot_preview1" require dot entries when POSIX does not?
+### Why does "wasi_snapshot_preview1" require dot entries when POSIX does not?
 
 In October 2019, WASI project knew requiring dot entries ("." and "..") was not
 documented in preview1, not required by POSIX and problematic to synthesize.
@@ -899,6 +921,34 @@ See https://pubs.opengroup.org/onlinepubs/9699919799/functions/readdir.html
 See https://github.com/golang/go/blob/go1.20/src/os/dir_unix.go#L108-L110
 See https://github.com/bytecodealliance/preview2-prototyping/blob/e4c04bcfbd11c42c27c28984948d501a3e168121/crates/wasi-preview1-component-adapter/src/lib.rs#L1026-L1041
 
+### Why don't we pre-populate an inode for the dot-dot ("..") entry?
+
+We only populate an inode for dot (".") because wasi-testsuite requires it, and
+we likely already have it (because we cache it). We could attempt to populate
+one for dot-dot (".."), but chose not to.
+
+Firstly, wasi-testsuite does not require the inode of dot-dot, possibly because
+the wasip2 adapter doesn't populate it (but we don't really know why).
+
+The only other reason to populate it would be to avoid wasi-libc's stat fanout
+when it is missing. However, the inode for dot-dot is not cached, and is also
+likely not possible to get on a pseudo file. Even if we could get it, we would
+have to use stat. In other words, pre-populating this would have the same cost
+as waiting for something to call stat instead.
+
+Fetching dot-dot's inode despite the above not only doesn't help wasi-libc, but
+it also hurts languages that don't use it, such as Go. These languages would
+pay a stat syscall penalty even if they don't need the inode. In fact, Go
+discards both dot entries!
+
+In summary, there are no significant upsides in attempting to pre-fetch
+dot-dot's inode, and there are downsides to doing it anyway.
+
+See
+ * https://github.com/WebAssembly/wasi-libc/pull/345
+ * https://github.com/WebAssembly/wasi-testsuite/blob/main/tests/rust/src/bin/fd_readdir.rs#L108
+ * https://github.com/bytecodealliance/preview2-prototyping/blob/e4c04bcfbd11c42c27c28984948d501a3e168121/crates/wasi-preview1-component-adapter/src/lib.rs#L1037
+
 ## sys.Walltime and Nanotime
 
 The `sys` package has two function types, `Walltime` and `Nanotime` for real
diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go
index 4f5f3fb6..946d0f09 100644
--- a/imports/wasi_snapshot_preview1/fs.go
+++ b/imports/wasi_snapshot_preview1/fs.go
@@ -829,10 +829,26 @@ func readv(mem api.Memory, iovs uint32, iovsCount uint32, reader func(buf []byte
 	return nread, 0
 }
 
-// fdReaddir is the WASI function named FdReaddirName which reads directory
-// entries from a directory.
+// fdReaddir is the WASI function named wasip1.FdReaddirName which reads
+// directory entries from a directory.  Special behaviors required by this
+// function are implemented in sys.DirentCache.
 //
 // See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#-fd_readdirfd-fd-buf-pointeru8-buf_len-size-cookie-dircookie---errno-size
+//
+// # Result (Errno)
+//
+// The return value is 0 except the following known error conditions:
+//   - syscall.ENOSYS: the implementation does not support this function.
+//   - syscall.EBADF: the file was closed or not a directory.
+//   - syscall.EFAULT: `buf` or `buf_len` point to an offset out of memory.
+//   - syscall.ENOENT: `cookie` was invalid.
+//   - syscall.EINVAL: `buf_len` was not large enough to write a dirent header.
+//
+// # End of Directory (EOF)
+//
+// More entries are available when `result.bufused` == `buf_len`. See
+// https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#fd_readdir
+// https://github.com/WebAssembly/wasi-libc/blob/659ff414560721b1660a19685110e484a081c3d4/libc-bottom-half/cloudlibc/src/libc/dirent/readdir.c#L44
 var fdReaddir = newHostFunc(
 	wasip1.FdReaddirName, fdReaddirFn,
 	[]wasm.ValueType{i32, i32, i32, i64, i32},
@@ -846,42 +862,70 @@ func fdReaddirFn(_ context.Context, mod api.Module, params []uint64) syscall.Err
 	fd := int32(params[0])
 	buf := uint32(params[1])
 	bufLen := uint32(params[2])
-	// We control the value of the cookie, and it should never be negative.
-	// However, we coerce it to signed to ensure the caller doesn't manipulate
-	// it in such a way that becomes negative.
-	cookie := int64(params[3])
+	cookie := params[3]
 	resultBufused := uint32(params[4])
 
-	// The bufLen must be enough to write a dirent. Otherwise, the caller can't
-	// read what the next cookie is.
+	// The bufLen must be enough to write a dirent header.
 	if bufLen < wasip1.DirentSize {
+		// This is a bug in the caller, as unless `buf_len` is large enough to
+		// write a dirent, it can't read the `d_namlen` from it.
 		return syscall.EINVAL
 	}
 
-	// Get or open the directory
-	dir, errno := openDir(fsc, fd)
+	// Get or open a dirent cache for this file descriptor.
+	dir, errno := direntCache(fsc, fd)
 	if errno != 0 {
 		return errno
 	}
 
-	// Validate the cookie and possibly sync the internal state to the one the cookie represents.
-	if errno = dir.Rewind(cookie); errno != 0 {
+	// First, determine the maximum directory entries that can be encoded as
+	// dirents. The total size is DirentSize(24) + nameSize, for each file.
+	// Since a zero-length file name is invalid, the minimum size entry is
+	// 25 (DirentSize + 1 character).
+	maxDirEntries := bufLen/wasip1.DirentSize + 1
+
+	// While unlikely maxDirEntries will fit into bufLen, add one more just in
+	// case, as we need to know if we hit the end of the directory or not to
+	// write the correct bufused (e.g. == bufLen unless EOF).
+	//	>> If less than the size of the read buffer, the end of the
+	//	>> directory has been reached.
+	maxDirEntries += 1
+
+	// Read up to max entries. The underlying implementation will cache these,
+	// starting at the current location, so that they can be re-read. This is
+	// important because even the first could end up larger than bufLen due to
+	// the size of its name.
+	dirents, errno := dir.Read(cookie, maxDirEntries)
+	if errno != 0 {
 		return errno
 	}
 
-	// Determine how many dirents we can write, excluding a potentially
-	// truncated entry.
-	dirents, bufused, direntCount, writeTruncatedEntry := maxDirents(dir, bufLen)
+	// Determine how many dirents we can write, including a potentially
+	// truncated last entry.
+	bufToWrite, direntCount, truncatedLen := maxDirents(dirents, bufLen)
 
 	// Now, write entries to the underlying buffer.
-	if bufused > 0 {
-		buf, ok := mem.Read(buf, bufused)
+	if bufToWrite > 0 {
+
+		// d_next is the index of the next file in the list, so it should
+		// always be one higher than the requested cookie.
+		d_next := cookie + 1
+		// ^^ yes this can overflow to negative, which means our implementation
+		// doesn't support writing greater than max int64 entries.
+
+		buf, ok := mem.Read(buf, bufToWrite)
 		if !ok {
 			return syscall.EFAULT
 		}
 
-		// Iterate again on dir, this time writing the entries.
-		writeDirents(dirents, direntCount, writeTruncatedEntry, buf, uint64(cookie+1))
+		writeDirents(buf, dirents, d_next, direntCount, truncatedLen)
+	}
+
+	// bufused == bufLen means more dirents exist, which is the case when one
+	// is truncated.
+	bufused := bufToWrite
+	if truncatedLen > 0 {
+		bufused = bufLen
 	}
 
 	if !mem.WriteUint32Le(resultBufused, bufused) {
@@ -892,33 +936,18 @@ func fdReaddirFn(_ context.Context, mod api.Module, params []uint64) syscall.Err
 
 const largestDirent = int64(math.MaxUint32 - wasip1.DirentSize)
 
-// maxDirents returns the maximum count and total entries that can fit in
-// maxLen bytes.
+// maxDirents returns the dirents to write.
 //
-// truncatedEntryLen is the amount of bytes past bufLen needed to write the
-// next entry. We have to return bufused == bufLen unless the directory is
-// exhausted.
-//
-// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#fd_readdir
-// See https://github.com/WebAssembly/wasi-libc/blob/659ff414560721b1660a19685110e484a081c3d4/libc-bottom-half/cloudlibc/src/libc/dirent/readdir.c#L44
-func maxDirents(dir *sys.Readdir, bufLen uint32) (dirents []fsapi.Dirent, bufused, direntCount uint32, writeTruncatedEntry bool) {
+// `bufToWrite` is the amount of memory needed to write direntCount, which
+// includes up to wasip1.DirentSize of a last truncated entry.
+func maxDirents(dirents []fsapi.Dirent, bufLen uint32) (bufToWrite uint32, direntCount int, truncatedLen uint32) {
 	lenRemaining := bufLen
-	for {
-		d, errno := dir.Peek()
-		if errno != 0 {
-			return
-		}
-		dirents = append(dirents, *d)
-
-		if lenRemaining < wasip1.DirentSize {
-			// We don't have enough space in bufLen for another struct,
-			// entry. A caller who wants more will retry.
-
-			// bufused == bufLen means more dirents exist, which is the case
-			// when the dirent is larger than bytes remaining.
-			bufused = bufLen
+	for i := range dirents {
+		if lenRemaining == 0 {
 			break
 		}
+		d := dirents[i]
+		direntCount++
 
 		// use int64 to guard against huge filenames
 		nameLen := int64(len(d.Name))
@@ -941,60 +970,51 @@ func maxDirents(dir *sys.Readdir, bufLen uint32) (dirents []fsapi.Dirent, bufuse
 			// we need to write DirentSize(24) + 4096 bytes to write the entry.
 			// In this case, we only write up to DirentSize(24) to allow the
 			// caller to resize.
-
-			// bufused == bufLen means more dirents exist, which is the case
-			// when the next entry is larger than bytes remaining.
-			bufused = bufLen
-
-			// We do have enough space to write the header, this value will be
-			// passed on to writeDirents to only write the header for this entry.
-			writeTruncatedEntry = true
+			if lenRemaining >= wasip1.DirentSize {
+				truncatedLen = wasip1.DirentSize
+			} else {
+				truncatedLen = lenRemaining
+			}
+			bufToWrite += truncatedLen
 			break
 		}
 
 		// This won't go negative because we checked entryLen <= lenRemaining.
 		lenRemaining -= entryLen
-		bufused += entryLen
-		direntCount++
-		_ = dir.Advance()
+		bufToWrite += entryLen
 	}
 	return
 }
 
 // writeDirents writes the directory entries to the buffer, which is pre-sized
-// based on maxDirents.	truncatedEntryLen means write one past entryCount,
-// without its name. See maxDirents for why
-func writeDirents(
-	dirents []fsapi.Dirent,
-	direntCount uint32,
-	writeTruncatedEntry bool,
-	buf []byte,
-	d_next uint64,
-) {
-	pos, i := uint32(0), uint32(0)
-	for ; i < direntCount; i++ {
+// based on maxDirents.	truncatedEntryLen means the last is written without its
+// name.
+func writeDirents(buf []byte, dirents []fsapi.Dirent, d_next uint64, direntCount int, truncatedLen uint32) {
+	pos := uint32(0)
+	skipNameI := -1
+
+	// If the last entry was truncated, we either skip it or write it without
+	// its name, depending on the length.
+	if truncatedLen > 0 {
+		if truncatedLen < wasip1.DirentSize {
+			direntCount-- // skip as too small to write the header.
+		} else {
+			skipNameI = direntCount - 1 // write the header, but not the name.
+		}
+	}
+
+	for i := 0; i < direntCount; i++ {
 		e := dirents[i]
 		nameLen := uint32(len(e.Name))
-
 		writeDirent(buf[pos:], d_next, e.Ino, nameLen, e.Type)
-		pos += wasip1.DirentSize
-
-		copy(buf[pos:], e.Name)
-		pos += nameLen
 		d_next++
-	}
+		pos += wasip1.DirentSize
 
-	if !writeTruncatedEntry {
-		return
+		if i != skipNameI {
+			copy(buf[pos:], e.Name)
+			pos += nameLen
+		}
 	}
-
-	// Write a dirent without its name
-	dirent := make([]byte, wasip1.DirentSize)
-	e := dirents[i]
-	writeDirent(dirent, d_next, e.Ino, uint32(len(e.Name)), e.Type)
-
-	// Potentially truncate it
-	copy(buf[pos:], dirent)
 }
 
 // writeDirent writes DirentSize bytes
@@ -1006,12 +1026,23 @@ func writeDirent(buf []byte, dNext uint64, ino uint64, dNamlen uint32, dType fs.
 	le.PutUint32(buf[20:], uint32(filetype)) //  d_type
 }
 
-// openDir lazy opens a sys.Readdir for the directory or returns an error.
-func openDir(fsc *sys.FSContext, fd int32) (*sys.Readdir, syscall.Errno) {
+// direntCache lazy opens a sys.DirentCache for this directory or returns an
+// error.
+func direntCache(fsc *sys.FSContext, fd int32) (*sys.DirentCache, syscall.Errno) {
 	if f, ok := fsc.LookupFile(fd); !ok {
 		return nil, syscall.EBADF
+	} else if dir, errno := f.DirentCache(); errno == 0 {
+		return dir, 0
+	} else if errno == syscall.ENOTDIR {
+		// fd_readdir docs don't indicate whether to return syscall.ENOTDIR or
+		// syscall.EBADF. It has been noticed that rust will crash on syscall.ENOTDIR,
+		// and POSIX C ref seems to not return this, so we don't either.
+		//
+		// See https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md#fd_readdir
+		// and https://en.wikibooks.org/wiki/C_Programming/POSIX_Reference/dirent.h
+		return nil, syscall.EBADF
 	} else {
-		return f.OpenDir(true)
+		return nil, errno
 	}
 }
 
diff --git a/internal/fsapi/file.go b/internal/fsapi/file.go
index ba454acd..aad1b53d 100644
--- a/internal/fsapi/file.go
+++ b/internal/fsapi/file.go
@@ -226,12 +226,11 @@ type File interface {
 	//
 	//   - This is like `Readdir` on os.File, but unlike `readdir` in POSIX.
 	//     See https://pubs.opengroup.org/onlinepubs/9699919799/functions/readdir.html
+	//   - Unlike os.File, there is no io.EOF returned on end-of-directory. To
+	//     read the directory completely, the caller must repeat until the
+	//     count read (`len(dirents)`) is less than `n`.
+	//   - See /RATIONALE.md for design notes.
 	Readdir(n int) (dirents []Dirent, errno syscall.Errno)
-	// ^-- TODO: consider being more like POSIX, for example, returning a
-	// closeable Dirent object that can iterate on demand. This would
-	// centralize sizing logic needed by wasi, particularly extra dirents
-	// stored in the sys.FileEntry type. It could possibly reduce the need to
-	// reopen the whole file.
 
 	// Write attempts to write all bytes in `p` to the file, and returns the
 	// count written even on error.
diff --git a/internal/sys/fs.go b/internal/sys/fs.go
index 1f1856bb..2622ba77 100644
--- a/internal/sys/fs.go
+++ b/internal/sys/fs.go
@@ -53,233 +53,200 @@ type FileEntry struct {
 	// File is always non-nil.
 	File fsapi.File
 
-	// openDir is nil until OpenDir was called.
-	openDir *Readdir
+	// direntCache is nil until DirentCache was called.
+	direntCache *DirentCache
 }
 
-// OpenDir lazy creates a directory stream for this file.
-//
-// # Parameters
-//
-// When `addDotEntries` is true, navigational entries "." and ".." precede
-// any other entries in the directory. Otherwise, they will be absent.
+// DirentCache gets or creates a DirentCache for this file or returns an error.
 //
 // # Errors
 //
-// # This returns the same errors as fsapi.File Readdir
+// A zero syscall.Errno is success. The below are expected otherwise:
+//   - syscall.ENOSYS: the implementation does not support this function.
+//   - syscall.EBADF: the dir was closed or not readable.
+//   - syscall.ENOTDIR: the file was not a directory.
 //
-// Notes:
-//   - In the future, this may be refactored to allow multiple open directory
-//     streams per file (likely in wasip>1).
-func (f *FileEntry) OpenDir(addDotEntries bool) (dir *Readdir, errno syscall.Errno) {
-	if dir = f.openDir; dir != nil {
-		return dir, 0
-	} else if dir, errno = newReaddirFromFileEntry(f, addDotEntries); errno != 0 {
-		// not a directory or error reading it.
-		return nil, errno
-	} else {
-		f.openDir = dir
+// # Notes
+//
+//   - See /RATIONALE.md for design notes.
+func (f *FileEntry) DirentCache() (*DirentCache, syscall.Errno) {
+	if dir := f.direntCache; dir != nil {
 		return dir, 0
 	}
-}
 
-const direntBufSize = 16
+	// Require the file to be a directory vs a late error on the same.
+	if isDir, errno := f.File.IsDir(); errno != 0 {
+		return nil, errno
+	} else if !isDir {
+		return nil, syscall.ENOTDIR
+	}
 
-// Readdir is the status of a prior fs.ReadDirFile call.
-type Readdir struct {
-	// cursor is the current position in the buffer.
-	cursor uint64
+	// Generate the dotEntries only once.
+	if dotEntries, errno := synthesizeDotEntries(f); errno != 0 {
+		return nil, errno
+	} else {
+		f.direntCache = &DirentCache{f: f.File, dotEntries: dotEntries}
+	}
 
-	// countRead is the total count of files read including Dirents.
-	//
-	// Notes:
-	//
-	// * countRead is the index of the next file in the list. This is
-	//   also the value that Cookie returns, so it should always be
-	//   higher or equal than the cookie given in Rewind.
-	//
-	// * this can overflow to negative, which means our implementation
-	//   doesn't support writing greater than max int64 entries.
-	//   countRead uint64
-	countRead uint64
+	return f.direntCache, 0
+}
 
-	// dirents is a fixed buffer of size direntBufSize. Notably,
-	// directory listing are not rewindable, so we keep entries around in case
-	// the caller mis-estimated their buffer and needs a few still cached.
+// DirentCache is a caching abstraction of fsapi.File Readdir.
+//
+// This is special-cased for "wasi_snapshot_preview1.fd_readdir", and may be
+// unneeded, or require changes, to support preview1 or preview2.
+//   - The position of the dirents are serialized as `d_next`. For reasons
+//     described below, any may need to be re-read. This accepts any positions
+//     in the cache, rather than track the position of the last dirent.
+//   - dot entries ("." and "..") must be returned. See /RATIONALE.md for why.
+//   - An fsapi.Dirent Name is variable length, it could exceed memory size and
+//     need to be re-read.
+//   - Multiple dirents may be returned. It is more efficient to read from the
+//     underlying file in bulk vs one-at-a-time.
+//
+// The last results returned by Read are cached, but entries before that
+// position are not. This support re-reading entries that couldn't fit into
+// memory without accidentally caching all entries in a large directory. This
+// approach is sometimes called a sliding window.
+type DirentCache struct {
+	// f is the underlying file
+	f fsapi.File
+
+	// dotEntries are the "." and ".." entries added when the directory is
+	// initialized.
+	dotEntries []fsapi.Dirent
+
+	// dirents are the potentially unread directory entries.
 	//
-	// Note: This is wasi-specific and needs to be refactored.
-	// In wasi preview1, dot and dot-dot entries are required to exist, but the
-	// reverse is true for preview2. More importantly, preview2 holds separate
-	// stateful dir-entry-streams per file.
+	// Internal detail: nil is different from zero length. Zero length is an
+	// exhausted directory (eof). nil means the re-read.
 	dirents []fsapi.Dirent
 
-	// dirInit seeks and reset the provider for dirents to the beginning
-	// and returns an initial batch (e.g. dot directories).
-	dirInit func() ([]fsapi.Dirent, syscall.Errno)
-
-	// dirReader fetches a new batch of direntBufSize elements.
-	dirReader func(n uint64) ([]fsapi.Dirent, syscall.Errno)
-}
-
-// NewReaddir is a constructor for Readdir. It takes a dirInit
-func NewReaddir(
-	dirInit func() ([]fsapi.Dirent, syscall.Errno),
-	dirReader func(n uint64) ([]fsapi.Dirent, syscall.Errno),
-) (*Readdir, syscall.Errno) {
-	d := &Readdir{dirReader: dirReader, dirInit: dirInit}
-	return d, d.init()
-}
-
-// init resets the cursor and invokes the dirInit, dirReader
-// methods to reset the internal state of the Readdir struct.
-//
-// Note: this is different from Reset, because it will not short-circuit
-// when cursor is already 0, but it will force an unconditional reload.
-func (d *Readdir) init() syscall.Errno {
-	d.cursor = 0
-	d.countRead = 0
-	// Reset the buffer to the initial state.
-	initialDirents, errno := d.dirInit()
-	if errno != 0 {
-		return errno
-	}
-	if len(initialDirents) > direntBufSize {
-		return syscall.EINVAL
-	}
-	d.dirents = initialDirents
-	// Fill the buffer with more data.
-	count := direntBufSize - len(initialDirents)
-	if count == 0 {
-		// No need to fill up the buffer further.
-		return 0
-	}
-	dirents, errno := d.dirReader(uint64(count))
-	if errno != 0 {
-		return errno
-	}
-	d.dirents = append(d.dirents, dirents...)
-	return 0
-}
+	// countRead is the total count of dirents read since last rewind.
+	countRead uint64
 
-// newReaddirFromFileEntry is a constructor for Readdir that takes a FileEntry to initialize.
-func newReaddirFromFileEntry(f *FileEntry, addDotEntries bool) (*Readdir, syscall.Errno) {
-	var dotEntries []fsapi.Dirent
-	if addDotEntries {
-		// Generate the dotEntries only once and return it many times in the dirInit closure.
-		var errno syscall.Errno
-		if dotEntries, errno = synthesizeDotEntries(f); errno != 0 {
-			return nil, errno
-		}
-	}
-	dirInit := func() ([]fsapi.Dirent, syscall.Errno) {
-		// Ensure we always rewind to the beginning when we re-init.
-		if _, errno := f.File.Seek(0, io.SeekStart); errno != 0 {
-			return nil, errno
-		}
-		// Return the dotEntries that we have already generated outside the closure.
-		return dotEntries, 0
-	}
-	dirReader := func(n uint64) ([]fsapi.Dirent, syscall.Errno) { return f.File.Readdir(int(n)) }
-	return NewReaddir(dirInit, dirReader)
+	// eof is true when the underlying file is at EOF. This avoids re-reading
+	// the directory when it is exhausted. Entires in an exhausted directory
+	// are not visible until it is rewound via calling Read with `pos==0`.
+	eof bool
 }
 
 // synthesizeDotEntries generates a slice of the two elements "." and "..".
-func synthesizeDotEntries(f *FileEntry) (result []fsapi.Dirent, errno syscall.Errno) {
+func synthesizeDotEntries(f *FileEntry) ([]fsapi.Dirent, syscall.Errno) {
 	dotIno, errno := f.File.Ino()
 	if errno != 0 {
 		return nil, errno
 	}
-	result = append(result, fsapi.Dirent{Name: ".", Ino: dotIno, Type: fs.ModeDir})
+	result := [2]fsapi.Dirent{}
+	result[0] = fsapi.Dirent{Name: ".", Ino: dotIno, Type: fs.ModeDir}
 	// See /RATIONALE.md for why we don't attempt to get an inode for ".."
-	result = append(result, fsapi.Dirent{Name: "..", Ino: 0, Type: fs.ModeDir})
-	return result, 0
+	result[1] = fsapi.Dirent{Name: "..", Ino: 0, Type: fs.ModeDir}
+	return result[:], 0
 }
 
-// Reset seeks the internal cursor to 0 and refills the buffer.
-func (d *Readdir) Reset() syscall.Errno {
-	if d.countRead == 0 {
-		return 0
+// exhaustedDirents avoids allocating empty slices.
+var exhaustedDirents = [0]fsapi.Dirent{}
+
+// Read is similar to and returns the same errors as `Readdir` on fsapi.File.
+// The main difference is this caches entries returned, resulting in multiple
+// valid positions to read from.
+//
+// When zero, `pos` means rewind to the beginning of this directory. This
+// implies a rewind (Seek to zero on the underlying fsapi.File), unless the
+// initial entries are still cached.
+//
+// When non-zero, `pos` is the zero based index of all dirents returned since
+// last rewind. Only entries beginning at `pos` are cached for subsequent
+// calls. A non-zero `pos` before the cache returns syscall.ENOENT for reasons
+// described on DirentCache documentation.
+//
+// Up to `n` entries are cached and returned. When `n` exceeds the cache, the
+// difference are read from the underlying fsapi.File via `Readdir`. EOF is
+// when `len(dirents)` returned are less than `n`.
+func (d *DirentCache) Read(pos uint64, n uint32) (dirents []fsapi.Dirent, errno syscall.Errno) {
+	switch {
+	case pos > d.countRead: // farther than read or negative coerced to uint64.
+		return nil, syscall.ENOENT
+	case pos == 0 && d.dirents != nil:
+		// Rewind if we have already read entries. This allows us to see new
+		// entries added after the directory was opened.
+		if _, errno = d.f.Seek(0, io.SeekStart); errno != 0 {
+			return
+		}
+		d.dirents = nil // dump cache
+		d.countRead = 0
 	}
-	return d.init()
-}
 
-// Skip is equivalent to calling n times Advance.
-func (d *Readdir) Skip(n uint64) {
-	end := d.countRead + n
-	var err syscall.Errno = 0
-	for d.countRead < end && err == 0 {
-		err = d.Advance()
+	if n == 0 {
+		return // special case no entries.
 	}
-}
 
-// Cookie returns a cookie representing the current state of the ReadDir struct.
-//
-// Note: this returns the countRead field, but it is an implementation detail.
-func (d *Readdir) Cookie() uint64 {
-	return d.countRead
-}
+	if d.dirents == nil {
+		// Always populate dot entries, which makes min len(dirents) == 2.
+		d.dirents = d.dotEntries
+		d.countRead = 2
+		d.eof = false
+
+		if countToRead := int(n - 2); countToRead <= 0 {
+			return
+		} else if dirents, errno = d.f.Readdir(countToRead); errno != 0 {
+			return
+		} else if countRead := len(dirents); countRead > 0 {
+			d.eof = countRead < countToRead
+			d.dirents = append(d.dotEntries, dirents...)
+			d.countRead += uint64(countRead)
+		}
 
-// Rewind seeks the internal cursor to the state represented by the cookie.
-// It returns a syscall.Errno if the cursor was reset and an I/O error occurred while trying to re-init.
-func (d *Readdir) Rewind(cookie int64) syscall.Errno {
-	unsignedCookie := uint64(cookie)
-	switch {
-	case cookie < 0 || unsignedCookie > d.countRead:
-		// the cookie can neither be negative nor can it be larger than countRead.
-		return syscall.EINVAL
-	case cookie == 0 && d.countRead == 0:
-		return 0
-	case cookie == 0 && d.countRead != 0:
-		// This means that there was a previous call to the dir, but cookie is reset.
-		// This happens when the program calls rewinddir, for example:
-		// https://github.com/WebAssembly/wasi-libc/blob/659ff414560721b1660a19685110e484a081c3d4/libc-bottom-half/cloudlibc/src/libc/dirent/rewinddir.c#L10-L12
-		return d.Reset()
-	case unsignedCookie < d.countRead:
-		if cookie/direntBufSize != int64(d.countRead)/direntBufSize {
-			// The cookie is not 0, but it points into a window before the current one.
-			return syscall.ENOSYS
+		return d.cachedDirents(n), 0
+	}
+
+	// Reset our cache to the first entry being read.
+	cacheStart := d.countRead - uint64(len(d.dirents))
+	if pos < cacheStart {
+		// We don't currently allow reads before our cache because Seek(0) is
+		// the only portable way. Doing otherwise requires skipping, which we
+		// won't do unless wasi-testsuite starts requiring it. Implementing
+		// this would allow re-reading a large directory, so care would be
+		// needed to not buffer the entire directory in memory while skipping.
+		errno = syscall.ENOENT
+		return
+	} else if posInCache := pos - cacheStart; posInCache != 0 {
+		if uint64(len(d.dirents)) == posInCache {
+			// Avoid allocation re-slicing to zero length.
+			d.dirents = exhaustedDirents[:]
+		} else {
+			d.dirents = d.dirents[posInCache:]
 		}
-		// We are allowed to rewind back to a previous offset within the current window.
-		d.countRead = unsignedCookie
-		d.cursor = d.countRead % direntBufSize
-		return 0
-	default:
-		// The cookie is valid.
-		return 0
 	}
-}
 
-// Peek emits the current value.
-// It returns syscall.ENOENT when there are no entries left in the directory.
-func (d *Readdir) Peek() (*fsapi.Dirent, syscall.Errno) {
-	switch {
-	case d.cursor == uint64(len(d.dirents)):
-		// We're past the buf size, fill it up again.
-		dirents, errno := d.dirReader(direntBufSize)
-		if errno != 0 {
-			return nil, errno
+	// See if we need more entries.
+	if countToRead := int(n) - len(d.dirents); countToRead > 0 && !d.eof {
+		// Try to read more, which could fail.
+		if dirents, errno = d.f.Readdir(countToRead); errno != 0 {
+			return
 		}
-		d.dirents = append(d.dirents, dirents...)
-		fallthrough
-	default: // d.cursor < direntBufSize FIXME
-		if d.cursor == uint64(len(d.dirents)) {
-			return nil, syscall.ENOENT
+
+		// Append the next read entries if we weren't at EOF.
+		if countRead := len(dirents); countRead > 0 {
+			d.eof = countRead < countToRead
+			d.dirents = append(d.dirents, dirents...)
+			d.countRead += uint64(countRead)
 		}
-		dirent := &d.dirents[d.cursor]
-		return dirent, 0
 	}
+
+	return d.cachedDirents(n), 0
 }
 
-// Advance advances the internal counters and indices to the next value.
-// It also empties and refill the buffer with the next set of values when the internal cursor
-// reaches the end of it.
-func (d *Readdir) Advance() syscall.Errno {
-	if d.cursor == uint64(len(d.dirents)) {
-		return syscall.ENOENT
+// cachedDirents returns up to `n` dirents from the cache.
+func (d *DirentCache) cachedDirents(n uint32) []fsapi.Dirent {
+	direntCount := uint32(len(d.dirents))
+	switch {
+	case direntCount == 0:
+		return nil
+	case direntCount > n:
+		return d.dirents[:n]
 	}
-	d.cursor++
-	d.countRead++
-	return 0
+	return d.dirents
 }
 
 type FSContext struct {


Test patch:
diff --git a/imports/wasi_snapshot_preview1/fs_test.go b/imports/wasi_snapshot_preview1/fs_test.go
index c6496c6f..4391f918 100644
--- a/imports/wasi_snapshot_preview1/fs_test.go
+++ b/imports/wasi_snapshot_preview1/fs_test.go
@@ -2024,7 +2024,6 @@ func Test_fdReaddir(t *testing.T) {
 		expectedMem     []byte
 		expectedMemSize int
 		expectedBufused uint32
-		expectedCookie  uint64
 	}{
 		{
 			name:            "empty dir",
@@ -2033,7 +2032,6 @@ func Test_fdReaddir(t *testing.T) {
 			cookie:          0,
 			expectedBufused: wasip1.DirentSize + 1, // one dot entry
 			expectedMem:     direntDot,
-			expectedCookie:  1,
 		},
 		{
 			name:            "full read",
@@ -2042,7 +2040,6 @@ func Test_fdReaddir(t *testing.T) {
 			cookie:          0,
 			expectedBufused: 129, // length of all entries
 			expectedMem:     dirents,
-			expectedCookie:  5,
 		},
 		{
 			name:            "can't read name",
@@ -2051,7 +2048,6 @@ func Test_fdReaddir(t *testing.T) {
 			cookie:          0,
 			expectedBufused: wasip1.DirentSize,             // == bufLen which is the size of the dirent
 			expectedMem:     direntDot[:wasip1.DirentSize], // header without name
-			expectedCookie:  0,
 		},
 		{
 			name:            "read exactly first",
@@ -2060,106 +2056,97 @@ func Test_fdReaddir(t *testing.T) {
 			cookie:          0,
 			expectedBufused: 25, // length to read exactly first.
 			expectedMem:     direntDot,
-			expectedCookie:  1,
 		},
 		{
 			name:       "read exactly second",
 			initialDir: "dir",
 			dir: func() {
 				f, _ := fsc.LookupFile(fd)
-				rdd, _ := f.OpenDir(true)
-				_ = rdd.Advance()
+				rdd, _ := f.DirentCache()
+				_, _ = rdd.Read(0, 1)
 			},
-
 			bufLen:          27, // length is long enough for exactly second.
 			cookie:          1,  // d_next of first
 			expectedBufused: 27, // length to read exactly second.
 			expectedMem:     direntDotDot,
-			expectedCookie:  2,
 		},
 		{
 			name:       "read second and a little more",
 			initialDir: "dir",
 			dir: func() {
 				f, _ := fsc.LookupFile(fd)
-				rdd, _ := f.OpenDir(true)
-				_ = rdd.Advance()
+				rdd, _ := f.DirentCache()
+				_, _ = rdd.Read(0, 1)
 			},
 			bufLen:          30, // length is longer than the second entry, but not long enough for a header.
 			cookie:          1,  // d_next of first
 			expectedBufused: 30, // length to read some more, but not enough for a header, so buf was exhausted.
 			expectedMem:     direntDotDot,
 			expectedMemSize: len(direntDotDot), // we do not want to compare the full buffer since we don't know what the leftover 4 bytes will contain.
-			expectedCookie:  2,
 		},
 		{
 			name:       "read second and header of third",
 			initialDir: "dir",
 			dir: func() {
 				f, _ := fsc.LookupFile(fd)
-				rdd, _ := f.OpenDir(true)
-				_ = rdd.Advance()
+				rdd, _ := f.DirentCache()
+				_, _ = rdd.Read(0, 1)
 			},
 			bufLen:          50, // length is longer than the second entry + enough for the header of third.
 			cookie:          1,  // d_next of first
 			expectedBufused: 50, // length to read exactly second and the header of third.
 			expectedMem:     append(direntDotDot, dirent1[0:24]...),
-			expectedCookie:  2,
 		},
 		{
 			name:       "read second and third",
 			initialDir: "dir",
 			dir: func() {
 				f, _ := fsc.LookupFile(fd)
-				rdd, _ := f.OpenDir(true)
-				_ = rdd.Advance()
+				rdd, _ := f.DirentCache()
+				_, _ = rdd.Read(0, 1)
 			},
 			bufLen:          53, // length is long enough for second and third.
 			cookie:          1,  // d_next of first
 			expectedBufused: 53, // length to read exactly one second and third.
 			expectedMem:     append(direntDotDot, dirent1...),
-			expectedCookie:  3,
 		},
 		{
 			name:       "read exactly third",
 			initialDir: "dir",
 			dir: func() {
 				f, _ := fsc.LookupFile(fd)
-				rdd, _ := f.OpenDir(true)
-				rdd.Skip(2)
+				rdd, _ := f.DirentCache()
+				_, _ = rdd.Read(0, 2)
 			},
 			bufLen:          27, // length is long enough for exactly third.
 			cookie:          2,  // d_next of second.
 			expectedBufused: 27, // length to read exactly third.
 			expectedMem:     dirent1,
-			expectedCookie:  3,
 		},
 		{
 			name:       "read third and beyond",
 			initialDir: "dir",
 			dir: func() {
 				f, _ := fsc.LookupFile(fd)
-				rdd, _ := f.OpenDir(true)
-				rdd.Skip(2)
+				rdd, _ := f.DirentCache()
+				_, _ = rdd.Read(0, 2)
 			},
 			bufLen:          300, // length is long enough for third and more
 			cookie:          2,   // d_next of second.
 			expectedBufused: 78,  // length to read the rest
 			expectedMem:     append(dirent1, dirent2...),
-			expectedCookie:  5,
 		},
 		{
 			name:       "read exhausted directory",
 			initialDir: "dir",
 			dir: func() {
 				f, _ := fsc.LookupFile(fd)
-				rdd, _ := f.OpenDir(true)
-				rdd.Skip(5)
+				rdd, _ := f.DirentCache()
+				_, _ = rdd.Read(0, 5)
 			},
 			bufLen:          300, // length is long enough for third and more
 			cookie:          5,   // d_next after entries.
 			expectedBufused: 0,   // nothing read
-			expectedCookie:  5,
 		},
 	}
 
@@ -2172,8 +2159,6 @@ func Test_fdReaddir(t *testing.T) {
 			require.EqualErrno(t, 0, errno)
 			defer fsc.CloseFile(fd) // nolint
 
-			file, _ := fsc.LookupFile(fd)
-
 			if tc.dir != nil {
 				tc.dir()
 			}
@@ -2186,11 +2171,11 @@ func Test_fdReaddir(t *testing.T) {
 				uint64(fd), uint64(buf), uint64(tc.bufLen), uint64(tc.cookie), uint64(resultBufused))
 
 			// read back the bufused and compare memory against it
-			bufUsed, ok := mod.Memory().ReadUint32Le(resultBufused)
+			bufused, ok := mod.Memory().ReadUint32Le(resultBufused)
 			require.True(t, ok)
-			require.Equal(t, tc.expectedBufused, bufUsed)
+			require.Equal(t, tc.expectedBufused, bufused)
 
-			mem, ok := mod.Memory().Read(buf, bufUsed)
+			mem, ok := mod.Memory().Read(buf, bufused)
 			require.True(t, ok)
 
 			if tc.expectedMem != nil {
@@ -2199,73 +2184,57 @@ func Test_fdReaddir(t *testing.T) {
 				}
 				require.Equal(t, tc.expectedMem, mem[:tc.expectedMemSize])
 			}
-
-			rdd, errno := file.OpenDir(true)
-			require.Equal(t, syscall.Errno(0), errno)
-			require.Equal(t, tc.expectedCookie, rdd.Cookie())
 		})
 	}
 }
 
+// This is similar to https://github.com/WebAssembly/wasi-testsuite/blob/ac32f57400cdcdd0425d3085c24fc7fc40011d1c/tests/rust/src/bin/fd_readdir.rs#L120
 func Test_fdReaddir_Rewind(t *testing.T) {
-	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(fstest.FS))
+	tmpDir := t.TempDir()
+
+	mod, r, log := requireProxyModule(t, wazero.NewModuleConfig().WithFS(os.DirFS(tmpDir)))
 	defer r.Close(testCtx)
 
 	fsc := mod.(*wasm.ModuleInstance).Sys.FS()
 
-	fd, errno := fsc.OpenFile(fsc.RootFS(), "dir", os.O_RDONLY, 0)
+	fd, errno := fsc.OpenFile(fsc.RootFS(), ".", os.O_RDONLY, 0)
 	require.EqualErrno(t, 0, errno)
 
 	mem := mod.Memory()
-	const resultBufused, buf, bufSize = 0, 8, 200
-	read := func(cookie, bufSize uint64) (bufUsed uint32) {
+	const resultBufused, buf = 0, 8
+	fdReaddir := func(cookie uint64) uint32 {
 		requireErrnoResult(t, wasip1.ErrnoSuccess, mod, wasip1.FdReaddirName,
-			uint64(fd), buf, bufSize, cookie, uint64(resultBufused))
-
-		bufUsed, ok := mem.ReadUint32Le(resultBufused)
+			uint64(fd), buf, 256, cookie, uint64(resultBufused))
+		bufused, ok := mem.ReadUint32Le(resultBufused)
 		require.True(t, ok)
-		return bufUsed
+		return bufused
 	}
 
-	cookie := uint64(0)
-	// Initial read.
-	initialBufUsed := read(cookie, bufSize)
-	// Ensure that all is read.
-	require.Equal(t, len(dirents), int(initialBufUsed))
-	resultBuf, ok := mem.Read(buf, initialBufUsed)
-	require.True(t, ok)
-	require.Equal(t, dirents, resultBuf)
+	// Read the empty directory, which should only have the dot entries.
+	bufused := fdReaddir(0)
+	dotDirentsLen := (wasip1.DirentSize + 1) + (wasip1.DirentSize + 2)
+	require.Equal(t, dotDirentsLen, bufused)
 
-	// Mask the result.
-	for i := range resultBuf {
-		resultBuf[i] = '?'
-	}
+	// Write a new file to the directory
+	fileName := "file"
+	require.NoError(t, os.WriteFile(path.Join(tmpDir, fileName), nil, 0o0666))
+	fileDirentLen := wasip1.DirentSize + uint32(len(fileName))
 
-	// Advance the cookie beyond the existing entries.
-	cookie += 5
-	// Nothing to read from, so bufUsed must be zero.
-	require.Zero(t, int(read(cookie, bufSize)))
+	// Read it again, which should see the new file.
+	bufused = fdReaddir(0)
+	require.Equal(t, dotDirentsLen+fileDirentLen, bufused)
 
-	// Ensure buffer is intact.
-	for i := range resultBuf {
-		require.Equal(t, byte('?'), resultBuf[i])
-	}
+	// Read it again, using the file position.
+	bufused = fdReaddir(2)
+	require.Equal(t, fileDirentLen, bufused)
 
-	// Here, we rewind the directory by setting cookie=0 on the same file descriptor.
-	cookie = 0
-	usedAfterRewind := read(cookie, bufSize)
-	// Ensure that all is read.
-	require.Equal(t, len(dirents), int(usedAfterRewind))
-	resultBuf, ok = mem.Read(buf, usedAfterRewind)
-	require.True(t, ok)
-	require.Equal(t, dirents, resultBuf)
 	require.Equal(t, `
-==> wasi_snapshot_preview1.fd_readdir(fd=4,buf=8,buf_len=200,cookie=0)
-<== (bufused=129,errno=ESUCCESS)
-==> wasi_snapshot_preview1.fd_readdir(fd=4,buf=8,buf_len=200,cookie=5)
-<== (bufused=0,errno=ESUCCESS)
-==> wasi_snapshot_preview1.fd_readdir(fd=4,buf=8,buf_len=200,cookie=0)
-<== (bufused=129,errno=ESUCCESS)
+==> wasi_snapshot_preview1.fd_readdir(fd=4,buf=8,buf_len=256,cookie=0)
+<== (bufused=51,errno=ESUCCESS)
+==> wasi_snapshot_preview1.fd_readdir(fd=4,buf=8,buf_len=256,cookie=0)
+<== (bufused=79,errno=ESUCCESS)
+==> wasi_snapshot_preview1.fd_readdir(fd=4,buf=8,buf_len=256,cookie=2)
+<== (bufused=28,errno=ESUCCESS)
 `, "\n"+log.String())
 }
 
@@ -2340,7 +2309,7 @@ func Test_fdReaddir_Errors(t *testing.T) {
 			fd:   dirFD,
 			buf:  0, bufLen: 1,
 			resultBufused: 1000,
-			expectedErrno: wasip1.ErrnoInval,
+			expectedErrno: wasip1.ErrnoInval, // Arbitrary error choice.
 			expectedLog: `
 ==> wasi_snapshot_preview1.fd_readdir(fd=5,buf=0,buf_len=1,cookie=0)
 <== (bufused=,errno=EINVAL)
@@ -2352,22 +2321,24 @@ func Test_fdReaddir_Errors(t *testing.T) {
 			buf:  0, bufLen: 1000,
 			cookie:        1,
 			resultBufused: 2000,
-			expectedErrno: wasip1.ErrnoInval,
+			expectedErrno: wasip1.ErrnoNoent,
 			expectedLog: `
 ==> wasi_snapshot_preview1.fd_readdir(fd=5,buf=0,buf_len=1000,cookie=1)
-<== (bufused=,errno=EINVAL)
+<== (bufused=,errno=ENOENT)
 `,
 		},
 		{
+			// cookie should be treated opaquely. When negative, it is a
+			// position not yet read,
 			name: "negative cookie invalid",
 			fd:   dirFD,
 			buf:  0, bufLen: 1000,
 			cookie:        -1,
 			resultBufused: 2000,
-			expectedErrno: wasip1.ErrnoInval,
+			expectedErrno: wasip1.ErrnoNoent,
 			expectedLog: `
 ==> wasi_snapshot_preview1.fd_readdir(fd=5,buf=0,buf_len=1000,cookie=-1)
-<== (bufused=,errno=EINVAL)
+<== (bufused=,errno=ENOENT)
 `,
 		},
 	}
diff --git a/imports/wasi_snapshot_preview1/fs_unit_test.go b/imports/wasi_snapshot_preview1/fs_unit_test.go
index ce84cdeb..c5a9c8e4 100644
--- a/imports/wasi_snapshot_preview1/fs_unit_test.go
+++ b/imports/wasi_snapshot_preview1/fs_unit_test.go
@@ -15,83 +15,83 @@ import (
 
 func Test_maxDirents(t *testing.T) {
 	tests := []struct {
-		name                        string
-		dirents                     []fsapi.Dirent
-		maxLen                      uint32
-		expectedCount               uint32
-		expectedwriteTruncatedEntry bool
-		expectedBufused             uint32
+		name                 string
+		dirents              []fsapi.Dirent
+		bufLen               uint32
+		expectedBufToWrite   uint32
+		expectedDirentCount  int
+		expectedTruncatedLen uint32
 	}{
 		{
 			name: "no entries",
 		},
 		{
-			name:                        "can't fit one",
-			dirents:                     testDirents,
-			maxLen:                      23,
-			expectedBufused:             23,
-			expectedwriteTruncatedEntry: false,
+			name:                 "can't fit one",
+			dirents:              testDirents,
+			bufLen:               23,
+			expectedBufToWrite:   23,
+			expectedDirentCount:  1,
+			expectedTruncatedLen: 23,
 		},
 		{
-			name:                        "only fits header",
-			dirents:                     testDirents,
-			maxLen:                      24,
-			expectedBufused:             24,
-			expectedwriteTruncatedEntry: true,
+			name:                 "only fits header",
+			dirents:              testDirents,
+			bufLen:               wasip1.DirentSize,
+			expectedBufToWrite:   wasip1.DirentSize,
+			expectedDirentCount:  1,
+			expectedTruncatedLen: wasip1.DirentSize,
 		},
 		{
-			name:            "one",
-			dirents:         testDirents,
-			maxLen:          25,
-			expectedCount:   1,
-			expectedBufused: 25,
+			name:                "one",
+			dirents:             testDirents,
+			bufLen:              25,
+			expectedBufToWrite:  25,
+			expectedDirentCount: 1,
 		},
 		{
-			name:                        "one but not room for two's name",
-			dirents:                     testDirents,
-			maxLen:                      25 + 25,
-			expectedCount:               1,
-			expectedwriteTruncatedEntry: true, // can write DirentSize
-			expectedBufused:             25 + 25,
+			name:                 "one but not room for two's name",
+			dirents:              testDirents,
+			bufLen:               25 + 25,
+			expectedBufToWrite:   25 + wasip1.DirentSize,
+			expectedDirentCount:  2,
+			expectedTruncatedLen: wasip1.DirentSize, // can write DirentSize
 		},
 		{
-			name:            "two",
-			dirents:         testDirents,
-			maxLen:          25 + 26,
-			expectedCount:   2,
-			expectedBufused: 25 + 26,
+			name:                "two",
+			dirents:             testDirents,
+			bufLen:              25 + 26,
+			expectedBufToWrite:  25 + 26,
+			expectedDirentCount: 2,
 		},
 		{
-			name:                        "two but not three's dirent",
-			dirents:                     testDirents,
-			maxLen:                      25 + 26 + 20,
-			expectedCount:               2,
-			expectedwriteTruncatedEntry: false, // 20 + 4 == DirentSize
-			expectedBufused:             25 + 26 + 20,
+			name:                 "two but not three's dirent",
+			dirents:              testDirents,
+			bufLen:               25 + 26 + 20,
+			expectedBufToWrite:   25 + 26 + 20,
+			expectedDirentCount:  3,
+			expectedTruncatedLen: 20, // 20 + 4 == DirentSize
 		},
 		{
-			name:                        "two but not three's name",
-			dirents:                     testDirents,
-			maxLen:                      25 + 26 + 26,
-			expectedCount:               2,
-			expectedwriteTruncatedEntry: true, // can write DirentSize
-			expectedBufused:             25 + 26 + 26,
+			name:                 "two but not three's name",
+			dirents:              testDirents,
+			bufLen:               25 + 26 + 25,
+			expectedBufToWrite:   25 + 26 + wasip1.DirentSize,
+			expectedDirentCount:  3,
+			expectedTruncatedLen: wasip1.DirentSize, // can write DirentSize
 		},
 		{
-			name:                        "three",
-			dirents:                     testDirents,
-			maxLen:                      25 + 26 + 27,
-			expectedCount:               3,
-			expectedwriteTruncatedEntry: false, // end of dir
-			expectedBufused:             25 + 26 + 27,
+			name:                "three",
+			dirents:             testDirents,
+			bufLen:              25 + 26 + 27,
+			expectedBufToWrite:  25 + 26 + 27,
+			expectedDirentCount: 3,
 		},
 		{
-			name:                        "max",
-			dirents:                     testDirents,
-			maxLen:                      100,
-			expectedCount:               3,
-			expectedwriteTruncatedEntry: false, // end of dir
-			expectedBufused:             25 + 26 + 27,
+			name:                "max",
+			dirents:             testDirents,
+			bufLen:              100,
+			expectedBufToWrite:  25 + 26 + 27,
+			expectedDirentCount: 3,
 		},
 	}
 
@@ -99,18 +99,10 @@ func Test_maxDirents(t *testing.T) {
 		tc := tt
 
 		t.Run(tc.name, func(t *testing.T) {
-			readdir, _ := sys.NewReaddir(
-				func() ([]fsapi.Dirent, syscall.Errno) {
-					return tc.dirents, 0
-				},
-				func(n uint64) ([]fsapi.Dirent, syscall.Errno) {
-					return nil, 0
-				},
-			)
-			_, bufused, direntCount, writeTruncatedEntry := maxDirents(readdir, tc.maxLen)
-			require.Equal(t, tc.expectedCount, direntCount)
-			require.Equal(t, tc.expectedwriteTruncatedEntry, writeTruncatedEntry)
-			require.Equal(t, tc.expectedBufused, bufused)
+			bufToWrite, direntCount, truncatedLen := maxDirents(tc.dirents, tc.bufLen)
+			require.Equal(t, tc.expectedBufToWrite, bufToWrite)
+			require.Equal(t, tc.expectedDirentCount, direntCount)
+			require.Equal(t, tc.expectedTruncatedLen, truncatedLen)
 		})
 	}
 }
@@ -155,40 +147,47 @@ var (
 
 func Test_writeDirents(t *testing.T) {
 	tests := []struct {
-		name                string
-		entries             []fsapi.Dirent
-		entryCount          uint32
-		writeTruncatedEntry bool
-		expectedEntriesBuf  []byte
+		name         string
+		dirents      []fsapi.Dirent
+		entryCount   int
+		truncatedLen uint32
+		expected     []byte
 	}{
 		{
 			name:    "none",
-			entries: testDirents,
+			dirents: testDirents,
 		},
 		{
-			name:               "one",
-			entries:            testDirents,
-			entryCount:         1,
-			expectedEntriesBuf: dirent1,
+			name:       "one",
+			dirents:    testDirents,
+			entryCount: 1,
+			expected:   dirent1,
 		},
 		{
-			name:               "two",
-			entries:            testDirents,
-			entryCount:         2,
-			expectedEntriesBuf: append(dirent1, dirent2...),
+			name:       "two",
+			dirents:    testDirents,
+			entryCount: 2,
+			expected:   append(dirent1, dirent2...),
 		},
 		{
-			name:                "two with truncated",
-			entries:             testDirents,
-			entryCount:          2,
-			writeTruncatedEntry: true,
-			expectedEntriesBuf:  append(append(dirent1, dirent2...), dirent3[0:10]...),
+			name:         "two with truncated dirent",
+			dirents:      testDirents,
+			entryCount:   3,
+			truncatedLen: wasip1.DirentSize,
+			expected:     append(append(dirent1, dirent2...), dirent3[:wasip1.DirentSize]...),
 		},
 		{
-			name:               "three",
-			entries:            testDirents,
-			entryCount:         3,
-			expectedEntriesBuf: append(append(dirent1, dirent2...), dirent3...),
+			name:         "two with truncated smaller than dirent",
+			dirents:      testDirents,
+			entryCount:   3,
+			truncatedLen: 5,
+			expected:     append(append(dirent1, dirent2...), 0, 0, 0, 0, 0),
+		},
+		{
+			name:       "three",
+			dirents:    testDirents,
+			entryCount: 3,
+			expected:   append(append(dirent1, dirent2...), dirent3...),
 		},
 	}
 
@@ -196,10 +195,10 @@ func Test_writeDirents(t *testing.T) {
 		tc := tt
 
 		t.Run(tc.name, func(t *testing.T) {
-			cookie := uint64(1)
-			entriesBuf := make([]byte, len(tc.expectedEntriesBuf))
-			writeDirents(tc.entries, tc.entryCount, tc.writeTruncatedEntry, entriesBuf, cookie)
-			require.Equal(t, tc.expectedEntriesBuf, entriesBuf)
+			d_next := uint64(1)
+			buf := make([]byte, len(tc.expected))
+			writeDirents(buf, tc.dirents, d_next, tc.entryCount, tc.truncatedLen)
+			require.Equal(t, tc.expected, buf)
 		})
 	}
 }
diff --git a/internal/sys/fs_test.go b/internal/sys/fs_test.go
index 987a12ae..9824cdd7 100644
--- a/internal/sys/fs_test.go
+++ b/internal/sys/fs_test.go
@@ -5,11 +5,13 @@ import (
 	"errors"
 	"io/fs"
 	"os"
+	"path"
 	"syscall"
 	"testing"
-	"testing/fstest"
+	gofstest "testing/fstest"
 
 	"github.com/tetratelabs/wazero/internal/fsapi"
+	"github.com/tetratelabs/wazero/internal/fstest"
 	"github.com/tetratelabs/wazero/internal/sysfs"
 	testfs "github.com/tetratelabs/wazero/internal/testing/fs"
 	"github.com/tetratelabs/wazero/internal/testing/require"
@@ -45,7 +47,7 @@ func TestNewFSContext(t *testing.T) {
 		},
 		{
 			name: "fstest.MapFS",
-			fs:   sysfs.Adapt(fstest.MapFS{}),
+			fs:   sysfs.Adapt(gofstest.MapFS{}),
 		},
 	}
 
@@ -243,90 +245,225 @@ func TestFSContext_Renumber(t *testing.T) {
 	})
 }
 
-func TestReaddDir_Rewind(t *testing.T) {
+func TestDirentCache_Read(t *testing.T) {
+	c := Context{}
+	err := c.InitFSContext(nil, nil, nil, []fsapi.FS{sysfs.Adapt(fstest.FS)}, []string{"/"}, nil)
+	require.NoError(t, err)
+	fsc := c.fsc
+	defer fsc.Close()
+
+	d, errno := sysfs.OpenFSFile(fstest.FS, "dir", 0, 0)
+	require.EqualErrno(t, 0, errno)
+	defer d.Close()
+
+	testDirents, errno := d.Readdir(-1)
+	if errno != 0 {
+		panic(errno)
+	}
+	testDirents = append([]fsapi.Dirent{
+		{Name: ".", Type: fs.ModeDir},
+		{Name: "..", Type: fs.ModeDir},
+	}, testDirents...)
+
 	tests := []struct {
-		name           string
-		f              *Readdir
-		cookie         int64
-		expectedCookie int64
-		expectedErrno  syscall.Errno
+		name            string
+		initialDir      string
+		dir             func(fd int32)
+		fd              int32
+		pos             uint64
+		n               uint32
+		expectedDirents []fsapi.Dirent
+		expectedErrno   syscall.Errno
 	}{
 		{
-			name: "no prior call",
+			name:            "empty dir has dot entries",
+			initialDir:      "emptydir",
+			pos:             0,
+			n:               100,
+			expectedDirents: testDirents[:2],
+		},
+		{
+			name:       "rewind empty directory",
+			initialDir: "emptydir",
+			dir: func(fd int32) {
+				f, _ := fsc.LookupFile(fd)
+				rdd, _ := f.DirentCache()
+				_, _ = rdd.Read(0, 5)
+			},
+			pos:             0,
+			n:               100,
+			expectedDirents: testDirents[:2],
 		},
 		{
-			name:          "no prior call, but passed a cookie",
-			cookie:        1,
-			expectedErrno: syscall.EINVAL,
+			name:            "full read",
+			initialDir:      "dir",
+			pos:             0,
+			n:               100,
+			expectedDirents: testDirents,
 		},
 		{
-			name: "cookie is negative",
-			f: &Readdir{
-				countRead: 3,
+			name:            "read first",
+			initialDir:      "dir",
+			pos:             0,
+			n:               1,
+			expectedDirents: testDirents[:1],
+		},
+		{
+			name:       "read second",
+			initialDir: "dir",
+			dir: func(fd int32) {
+				f, _ := fsc.LookupFile(fd)
+				rdd, _ := f.DirentCache()
+				_, _ = rdd.Read(0, 1)
 			},
-			cookie:        -1,
-			expectedErrno: syscall.EINVAL,
+			pos:             1,
+			n:               1,
+			expectedDirents: testDirents[1:2],
 		},
 		{
-			name: "cookie is greater than last d_next",
-			f: &Readdir{
-				countRead: 3,
+			name:       "read second and third",
+			initialDir: "dir",
+			dir: func(fd int32) {
+				f, _ := fsc.LookupFile(fd)
+				rdd, _ := f.DirentCache()
+				_, _ = rdd.Read(0, 1)
 			},
-			cookie:        5,
-			expectedErrno: syscall.EINVAL,
+			pos:             1,
+			n:               2,
+			expectedDirents: testDirents[1:3],
 		},
 		{
-			name: "cookie is last pos",
-			f: &Readdir{
-				countRead: 3,
+			name:       "read exactly third",
+			initialDir: "dir",
+			dir: func(fd int32) {
+				f, _ := fsc.LookupFile(fd)
+				rdd, _ := f.DirentCache()
+				_, _ = rdd.Read(0, 2)
 			},
-			cookie: 3,
+			pos:             2,
+			n:               1,
+			expectedDirents: testDirents[2:3],
 		},
 		{
-			name: "cookie is one before last pos",
-			f: &Readdir{
-				countRead: 3,
+			name:       "read third and beyond",
+			initialDir: "dir",
+			dir: func(fd int32) {
+				f, _ := fsc.LookupFile(fd)
+				rdd, _ := f.DirentCache()
+				_, _ = rdd.Read(0, 2)
 			},
-			cookie: 2,
+			pos:             2,
+			n:               5,
+			expectedDirents: testDirents[2:],
 		},
 		{
-			name: "cookie is before current entries",
-			f: &Readdir{
-				countRead: direntBufSize + 2,
+			name:       "read exhausted directory",
+			initialDir: "dir",
+			dir: func(fd int32) {
+				f, _ := fsc.LookupFile(fd)
+				rdd, _ := f.DirentCache()
+				_, _ = rdd.Read(0, 5)
 			},
-			cookie:        1,
-			expectedErrno: syscall.ENOSYS, // not implemented
+			pos:             5,
+			n:               5,
+			expectedDirents: nil,
 		},
 		{
-			name: "read from the beginning (cookie=0)",
-			f: &Readdir{
-				dirInit: func() ([]fsapi.Dirent, syscall.Errno) {
-					return []fsapi.Dirent{{Name: "."}, {Name: ".."}}, 0
-				},
-				dirReader: func(n uint64) ([]fsapi.Dirent, syscall.Errno) {
-					return nil, 0
-				},
-				cursor: 3,
+			name:       "rewind directory",
+			initialDir: "dir",
+			dir: func(fd int32) {
+				f, _ := fsc.LookupFile(fd)
+				rdd, _ := f.DirentCache()
+				_, _ = rdd.Read(0, 5)
 			},
-			cookie: 0,
+			pos:             0,
+			n:               5,
+			expectedDirents: testDirents,
+		},
+		{
+			name:          "DirentCache: not a dir",
+			initialDir:    "dir/-",
+			pos:           0,
+			n:             1,
+			expectedErrno: syscall.ENOTDIR,
+		},
+		{
+			name:          "pos invalid when no prior state",
+			initialDir:    "dir",
+			pos:           1,
+			n:             1,
+			expectedErrno: syscall.ENOENT,
 		},
 	}
 
 	for _, tt := range tests {
 		tc := tt
-
 		t.Run(tc.name, func(t *testing.T) {
-			f := tc.f
-			if f == nil {
-				f = &Readdir{}
+			fd, errno := fsc.OpenFile(fsc.RootFS(), tc.initialDir, os.O_RDONLY, 0)
+			require.EqualErrno(t, 0, errno)
+			defer fsc.CloseFile(fd) // nolint
+			f, _ := fsc.LookupFile(fd)
+			dir, errno := f.DirentCache()
+			if errno != 0 {
+				require.EqualErrno(t, tc.expectedErrno, errno)
+				return
 			}
 
-			errno := f.Rewind(tc.cookie)
+			if tc.dir != nil {
+				tc.dir(fd)
+			}
+
+			dirents, errno := dir.Read(tc.pos, tc.n)
 			require.EqualErrno(t, tc.expectedErrno, errno)
+			require.Equal(t, tc.expectedDirents, dirents)
 		})
 	}
 }
 
+// This is similar to https://github.com/WebAssembly/wasi-testsuite/blob/ac32f57400cdcdd0425d3085c24fc7fc40011d1c/tests/rust/src/bin/fd_readdir.rs#L120
+func TestDirentCache_ReadNewFile(t *testing.T) {
+	tmpDir := t.TempDir()
+
+	c := Context{}
+	err := c.InitFSContext(nil, nil, nil, []fsapi.FS{sysfs.NewDirFS(tmpDir)}, []string{"/"}, nil)
+	require.NoError(t, err)
+	fsc := c.fsc
+	defer fsc.Close()
+
+	fd, errno := fsc.OpenFile(fsc.RootFS(), ".", os.O_RDONLY, 0)
+	require.EqualErrno(t, 0, errno)
+	defer fsc.CloseFile(fd) // nolint
+	f, _ := fsc.LookupFile(fd)
+
+	dir, errno := f.DirentCache()
+	require.EqualErrno(t, 0, errno)
+
+	// Read the empty directory, which should only have the dot entries.
+	dirents, errno := dir.Read(0, 5)
+	require.EqualErrno(t, 0, errno)
+	require.Equal(t, 2, len(dirents))
+	require.Equal(t, ".", dirents[0].Name)
+	require.Equal(t, "..", dirents[1].Name)
+
+	// Write a new file to the directory
+	require.NoError(t, os.WriteFile(path.Join(tmpDir, "file"), nil, 0o0666))
+
+	// Read it again, which should see the new file.
+	dirents, errno = dir.Read(0, 5)
+	require.EqualErrno(t, 0, errno)
+	require.Equal(t, 3, len(dirents))
+	require.Equal(t, ".", dirents[0].Name)
+	require.Equal(t, "..", dirents[1].Name)
+	require.Equal(t, "file", dirents[2].Name)
+
+	// Read it again, using the file position.
+	filePos := uint64(2)
+	dirents, errno = dir.Read(filePos, 3)
+	require.EqualErrno(t, 0, errno)
+	require.Equal(t, 1, len(dirents))
+	require.Equal(t, "file", dirents[0].Name)
+}
+
 func TestStripPrefixesAndTrailingSlash(t *testing.T) {
 	tests := []struct {
 		path, expected string