Repository: tetratelabs/wazero
Pull request #1483 (2023-05-19 19:38:48)

wasi: move Readdir logics from fdReaddirFn to Readdir methods
As suggested [in this comment](https://github.com/tetratelabs/wazero/pull/1482#issuecomment-1554008113), the logic that populates the fields in `Readdir` is moved to methods of the `Readdir` struct. We are not exporting an interface yet.

- The fields are no longer exported. 
- There are new fields that are necessary to maintain the position of the cursor, and `func` fields to avoid embedding a `FileEntry`.
- We export 2 constructors: one takes a `FileEntry`, and it is defined in terms of the other; the other takes 2 `func`s `dirInit` and `dirReader` that are invoked when the initializing, resetting, advancing the cursor and filling the internal buffer.

Notably, for simplicity, entries are read in batches; `dirents` is always at most large `direntBufSize = 16` (currently), which is usually the (fixed) size of each batch, unless it is the initial fetch, or it is the last fetch:
- the initial fetch is `dirReader(n)`, where `n := direntBufSize - len(initial)`, and `len(initial)` is the size of the slice returned by `dirInit()`
- all fetches may return `len(batch) <= direntBufSize`; if `len(dirent) < direntBufSize`, then we have reached the end of the listing for this directory.

The cursor/iterator-like API has `Peek()`, `Advance()` methods. It also provides `Skip(n)`, `Reset()`, `Cookie()` and `Rewind(cookie)`.

- `Advance()` advances the `cursor` field and the `countRead` field. The `countRead` is the absolute index of the current item; `cursor` is the index in the current window (`dirent`)
- `Rewind(cookie)` allows seeking backwards to the position of the given cookie. The cookie corresponds to the internal `countRead`. When the cookie is 0, `Rewind(0)` is equivalent to `Reset()`. It is not possible to seek forward (beyond the cursor). It is not possible to seek backwards to a value outside the current window.
- `Skip(n)` is mostly used for testing, the other methods are more useful and used in `fdReaddirFn` too, and it is a short-hand for invoking `Advance()` `n` times.

Tests have been migrated to unit tests of `Readdir` when it made sense, and they were kept in the wasi namespace when it was simpler.



evacchi: ```
thread 'main' panicked at 'assertion failed: `(left == right)`
  left: `0`,
 right: `1`: expected one entry', src/bin/fd_readdir.rs:167:5
```

eh fair enough 🤷 😛 
evacchi: restored the sliding window, cleaned up, squashed, rebased

Patch:
diff --git a/imports/wasi_snapshot_preview1/fs.go b/imports/wasi_snapshot_preview1/fs.go
index d13d969c..3947a6ac 100644
--- a/imports/wasi_snapshot_preview1/fs.go
+++ b/imports/wasi_snapshot_preview1/fs.go
@@ -834,86 +834,29 @@ func fdReaddirFn(_ context.Context, mod api.Module, params []uint64) syscall.Err
 	if errno != 0 {
 		return errno
 	}
-	rd := f.File
 	// Discard the bool value because we validated the fd already.
-	dir, _ := fsc.LookupReaddir(fd)
-
-	if cookie == 0 && dir.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
-		if _, errno = rd.Seek(0, io.SeekStart); errno != 0 {
-			return errno
-		}
-		*dir = sys.Readdir{}
-	}
-
-	// 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 := int(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
-
-	// The host keeps state for any unread entries from the prior call because
-	// we cannot seek to a previous directory position. Collect these entries.
-	dirents, errno := lastDirents(dir, cookie)
+	dir, errno := fsc.LookupReaddir(fd, f)
 	if errno != 0 {
 		return errno
 	}
-
-	// Add entries for dot and dot-dot as wasi-testsuite requires them.
-	if cookie == 0 && dirents == nil {
-		if dirents, errno = dotDirents(f); errno != 0 {
-			return errno
-		}
-		dir.Dirents = dirents
-		dir.CountRead = 2 // . and ..
-	}
-
-	// Check if we have maxDirEntries, and read more from the FS as needed.
-	if entryCount := len(dirents); entryCount < maxDirEntries {
-		// Note: platform.Readdir does not return io.EOF as it is
-		// inconsistently returned (e.g. darwin does, but linux doesn't).
-		l, errno := rd.Readdir(maxDirEntries - entryCount)
-		if errno != 0 {
-			return errno
-		}
-
-		// Zero length read is possible on an empty or exhausted directory.
-		if len(l) > 0 {
-			dir.CountRead += uint64(len(l))
-			dirents = append(dirents, l...)
-			// Replace the cache with up to maxDirEntries, starting at cookie.
-			dir.Dirents = dirents
-		}
+	// Validate the cookie and possibly sync the internal state to the one the cookie represents.
+	if errno = dir.Rewind(cookie); errno != 0 {
+		return errno
 	}
 
 	// Determine how many dirents we can write, excluding a potentially
 	// truncated entry.
-	bufused, direntCount, writeTruncatedEntry := maxDirents(dirents, bufLen)
+	dirents, bufused, direntCount, writeTruncatedEntry := maxDirents(dir, bufLen)
 
 	// Now, write entries to the underlying buffer.
 	if bufused > 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 := uint64(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, bufused)
 		if !ok {
 			return syscall.EFAULT
 		}
 
-		writeDirents(dirents, direntCount, writeTruncatedEntry, buf, d_next)
+		// Iterate again on dir, this time writing the entries.
+		writeDirents(dirents, direntCount, writeTruncatedEntry, buf, uint64(cookie+1))
 	}
 
 	if !mem.WriteUint32Le(resultBufused, bufused) {
@@ -922,69 +865,8 @@ func fdReaddirFn(_ context.Context, mod api.Module, params []uint64) syscall.Err
 	return 0
 }
 
-// dotDirents returns "." and "..", where "." because wasi-testsuite does inode
-// validation.
-func dotDirents(f *sys.FileEntry) ([]fsapi.Dirent, syscall.Errno) {
-	if isDir, errno := f.File.IsDir(); errno != 0 {
-		return nil, errno
-	} else if !isDir {
-		return nil, syscall.ENOTDIR
-	}
-	dotIno, errno := f.File.Ino()
-	if errno != 0 {
-		return nil, errno
-	}
-	dotDotIno := uint64(0)
-	if !f.IsPreopen && f.Name != "." {
-		if st, errno := f.FS.Stat(path.Dir(f.Name)); errno != 0 {
-			return nil, errno
-		} else {
-			dotDotIno = st.Ino
-		}
-	}
-	return []fsapi.Dirent{
-		{Name: ".", Ino: dotIno, Type: fs.ModeDir},
-		{Name: "..", Ino: dotDotIno, Type: fs.ModeDir},
-	}, 0
-}
-
 const largestDirent = int64(math.MaxUint32 - wasip1.DirentSize)
 
-// lastDirents is broken out from fdReaddirFn for testability.
-func lastDirents(dir *sys.Readdir, cookie int64) (dirents []fsapi.Dirent, errno syscall.Errno) {
-	if cookie < 0 {
-		errno = syscall.EINVAL // invalid as we will never send a negative cookie.
-		return
-	}
-
-	entryCount := int64(len(dir.Dirents))
-	if entryCount == 0 { // there was no prior call
-		if cookie != 0 {
-			errno = syscall.EINVAL // invalid as we haven't sent that cookie
-		}
-		return
-	}
-
-	// Get the first absolute position in our window of results
-	firstPos := int64(dir.CountRead) - entryCount
-	cookiePos := cookie - firstPos
-
-	switch {
-	case cookiePos < 0: // cookie is asking for results outside our window.
-		errno = syscall.ENOSYS // we can't implement directory seeking backwards.
-	case cookiePos > entryCount:
-		errno = syscall.EINVAL // invalid as we read that far, yet.
-	case cookiePos > 0: // truncate so to avoid large lists.
-		dirents = dir.Dirents[cookiePos:]
-	default:
-		dirents = dir.Dirents
-	}
-	if len(dirents) == 0 {
-		dirents = nil
-	}
-	return
-}
-
 // maxDirents returns the maximum count and total entries that can fit in
 // maxLen bytes.
 //
@@ -994,10 +876,15 @@ func lastDirents(dir *sys.Readdir, cookie int64) (dirents []fsapi.Dirent, errno
 //
 // 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(dirents []fsapi.Dirent, bufLen uint32) (bufused, direntCount uint32, writeTruncatedEntry bool) {
+func maxDirents(dir *sys.Readdir, bufLen uint32) (dirents []fsapi.Dirent, bufused, direntCount uint32, writeTruncatedEntry bool) {
 	lenRemaining := bufLen
-	for i := range dirents {
-		d := dirents[i]
+	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.
@@ -1044,6 +931,7 @@ func maxDirents(dirents []fsapi.Dirent, bufLen uint32) (bufused, direntCount uin
 		lenRemaining -= entryLen
 		bufused += entryLen
 		direntCount++
+		_ = dir.Advance()
 	}
 	return
 }
diff --git a/internal/sys/fs.go b/internal/sys/fs.go
index fa9b5c45..8fe11512 100644
--- a/internal/sys/fs.go
+++ b/internal/sys/fs.go
@@ -3,6 +3,7 @@ package sys
 import (
 	"io"
 	"io/fs"
+	"path"
 	"syscall"
 
 	"github.com/tetratelabs/wazero/internal/descriptor"
@@ -53,12 +54,27 @@ type FileEntry struct {
 	File fsapi.File
 }
 
+const direntBufSize = 16
+
 // Readdir is the status of a prior fs.ReadDirFile call.
 type Readdir struct {
-	// CountRead is the total count of files read including Dirents.
-	CountRead uint64
+	// cursor is the current position in the buffer.
+	cursor uint64
 
-	// Dirents is the contents of the last platform.Readdir call. Notably,
+	// 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
+
+	// 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.
 	//
@@ -66,7 +82,179 @@ type Readdir struct {
 	// 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.
-	Dirents []fsapi.Dirent
+	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
+}
+
+// newReaddirFromFileEntry is a constructor for Readdir that takes a FileEntry to initialize.
+func newReaddirFromFileEntry(f *FileEntry) (*Readdir, syscall.Errno) {
+	// Generate the dotEntries only once and return it many times in the dirInit closure.
+	dotEntries, errno := synthesizeDotEntries(f)
+	if 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)
+}
+
+// synthesizeDotEntries generates a slice of the two elements "." and "..".
+func synthesizeDotEntries(f *FileEntry) (result []fsapi.Dirent, errno syscall.Errno) {
+	dotIno, errno := f.File.Ino()
+	if errno != 0 {
+		return nil, errno
+	}
+	result = append(result, fsapi.Dirent{Name: ".", Ino: dotIno, Type: fs.ModeDir})
+	dotDotIno := uint64(0)
+	if !f.IsPreopen && f.Name != "." {
+		if st, errno := f.FS.Stat(path.Dir(f.Name)); errno != 0 {
+			return nil, errno
+		} else {
+			dotDotIno = st.Ino
+		}
+	}
+	result = append(result, fsapi.Dirent{Name: "..", Ino: dotDotIno, 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
+	}
+	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()
+	}
+}
+
+// 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
+}
+
+// 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
+		}
+		// 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
+		}
+		d.dirents = append(d.dirents, dirents...)
+		fallthrough
+	default: // d.cursor < direntBufSize FIXME
+		if d.cursor == uint64(len(d.dirents)) {
+			return nil, syscall.ENOENT
+		}
+		dirent := &d.dirents[d.cursor]
+		return dirent, 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
+	}
+	d.cursor++
+	d.countRead++
+	return 0
 }
 
 type FSContext struct {
@@ -128,12 +316,19 @@ func (c *FSContext) LookupFile(fd int32) (*FileEntry, bool) {
 // Note: this currently assumes that idx == fd, where fd is the file descriptor of the directory.
 // CloseFile will delete this idx from the internal store. In the future, idx may be independent
 // of a file fd, and the idx may have to be disposed with an explicit CloseReaddir.
-func (c *FSContext) LookupReaddir(idx int32) (*Readdir, bool) {
+func (c *FSContext) LookupReaddir(idx int32, f *FileEntry) (*Readdir, syscall.Errno) {
 	if item, _ := c.readdirs.Lookup(idx); item != nil {
-		return item, true
+		return item, 0
 	} else {
-		item = &Readdir{}
-		return item, c.readdirs.InsertAt(item, idx)
+		item, err := newReaddirFromFileEntry(f)
+		if err != 0 {
+			return nil, err
+		}
+		ok := c.readdirs.InsertAt(item, idx)
+		if !ok {
+			return nil, syscall.EINVAL
+		}
+		return item, 0
 	}
 }
 


Test patch:
diff --git a/imports/wasi_snapshot_preview1/fs_test.go b/imports/wasi_snapshot_preview1/fs_test.go
index 3a24e28e..dc7f8730 100644
--- a/imports/wasi_snapshot_preview1/fs_test.go
+++ b/imports/wasi_snapshot_preview1/fs_test.go
@@ -1951,23 +1951,6 @@ func Test_fdRead_Errors(t *testing.T) {
 }
 
 var (
-	testDirents = func() []fsapi.Dirent {
-		d, errno := sysfs.OpenFSFile(fstest.FS, "dir", 0, 0)
-		if errno != 0 {
-			panic(errno)
-		}
-		defer d.Close()
-		dirents, errno := d.Readdir(-1)
-		if errno != 0 {
-			panic(errno)
-		}
-		dots := []fsapi.Dirent{
-			{Name: ".", Type: fs.ModeDir},
-			{Name: "..", Type: fs.ModeDir},
-		}
-		return append(dots, dirents...)
-	}()
-
 	direntDot = []byte{
 		1, 0, 0, 0, 0, 0, 0, 0, // d_next = 1
 		0, 0, 0, 0, 0, 0, 0, 0, // d_ino = 0
@@ -2034,257 +2017,151 @@ func Test_fdReaddir(t *testing.T) {
 	fd, errno := fsc.OpenFile(preopen, "dir", os.O_RDONLY, 0)
 	require.EqualErrno(t, 0, errno)
 
-	type result struct {
-		entry   fsapi.File
-		readDir *sys.Readdir
-	}
-
 	tests := []struct {
 		name            string
-		dir             func() result
+		initialDir      string
+		dir             func()
 		bufLen          uint32
 		cookie          int64
 		expectedMem     []byte
 		expectedMemSize int
 		expectedBufused uint32
-		expectedReadDir *sys.Readdir
+		expectedCookie  uint64
 	}{
 		{
-			name: "empty dir",
-			dir: func() result {
-				dir, errno := preopen.OpenFile("emptydir", os.O_RDONLY, 0)
-				require.EqualErrno(t, 0, errno)
-
-				return result{entry: dir}
-			},
+			name:            "empty dir",
+			initialDir:      "emptydir",
 			bufLen:          wasip1.DirentSize + 1, // size of one entry
 			cookie:          0,
 			expectedBufused: wasip1.DirentSize + 1, // one dot entry
 			expectedMem:     direntDot,
-			expectedReadDir: &sys.Readdir{
-				CountRead: 2,
-				Dirents:   testDirents[0:2], // dot and dot-dot
-			},
+			expectedCookie:  1,
 		},
 		{
-			name: "full read",
-			dir: func() result {
-				dir, errno := preopen.OpenFile("dir", os.O_RDONLY, 0)
-				require.EqualErrno(t, 0, errno)
-
-				return result{entry: dir}
-			},
+			name:            "full read",
+			initialDir:      "dir",
 			bufLen:          4096,
 			cookie:          0,
 			expectedBufused: 129, // length of all entries
 			expectedMem:     dirents,
-			expectedReadDir: &sys.Readdir{
-				CountRead: 5,
-				Dirents:   testDirents,
-			},
+			expectedCookie:  5,
 		},
 		{
-			name: "can't read name",
-			dir: func() result {
-				dir, errno := preopen.OpenFile("dir", os.O_RDONLY, 0)
-				require.EqualErrno(t, 0, errno)
-
-				return result{entry: dir}
-			},
+			name:            "can't read name",
+			initialDir:      "dir",
 			bufLen:          wasip1.DirentSize, // length is long enough for first, but not the name.
 			cookie:          0,
 			expectedBufused: wasip1.DirentSize,             // == bufLen which is the size of the dirent
 			expectedMem:     direntDot[:wasip1.DirentSize], // header without name
-			expectedReadDir: &sys.Readdir{
-				CountRead: 3,
-				Dirents:   testDirents[0:3],
-			},
+			expectedCookie:  0,
 		},
 		{
-			name: "read exactly first",
-			dir: func() result {
-				dir, errno := preopen.OpenFile("dir", os.O_RDONLY, 0)
-				require.EqualErrno(t, 0, errno)
-
-				return result{entry: dir}
-			},
+			name:            "read exactly first",
+			initialDir:      "dir",
 			bufLen:          25, // length is long enough for first + the name, but not more.
 			cookie:          0,
 			expectedBufused: 25, // length to read exactly first.
 			expectedMem:     direntDot,
-			expectedReadDir: &sys.Readdir{
-				CountRead: 3,
-				Dirents:   testDirents[0:3],
-			},
+			expectedCookie:  1,
 		},
 		{
-			name: "read exactly second",
-			dir: func() result {
-				dir, errno := preopen.OpenFile("dir", os.O_RDONLY, 0)
-				require.EqualErrno(t, 0, errno)
-				dirent, errno := dir.Readdir(1)
-				require.EqualErrno(t, 0, errno)
-
-				return result{
-					entry: dir,
-					readDir: &sys.Readdir{
-						CountRead: 3,
-						Dirents:   append(testDirents[0:2], dirent...),
-					},
-				}
+			name:       "read exactly second",
+			initialDir: "dir",
+			dir: func() {
+				f, _ := fsc.LookupFile(fd)
+				rdd, _ := fsc.LookupReaddir(fd, f)
+				_ = rdd.Advance()
 			},
+
 			bufLen:          27, // length is long enough for exactly second.
 			cookie:          1,  // d_next of first
 			expectedBufused: 27, // length to read exactly second.
 			expectedMem:     direntDotDot,
-			expectedReadDir: &sys.Readdir{
-				CountRead: 4,
-				Dirents:   testDirents[1:4],
-			},
+			expectedCookie:  2,
 		},
 		{
-			name: "read second and a little more",
-			dir: func() result {
-				dir, errno := preopen.OpenFile("dir", os.O_RDONLY, 0)
-				require.EqualErrno(t, 0, errno)
-				dirent, errno := dir.Readdir(1)
-				require.EqualErrno(t, 0, errno)
-				return result{
-					entry: dir,
-					readDir: &sys.Readdir{
-						CountRead: 3,
-						Dirents:   append(testDirents[0:2], dirent...),
-					},
-				}
+			name:       "read second and a little more",
+			initialDir: "dir",
+			dir: func() {
+				f, _ := fsc.LookupFile(fd)
+				rdd, _ := fsc.LookupReaddir(fd, f)
+				_ = rdd.Advance()
 			},
 			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.
-			expectedReadDir: &sys.Readdir{
-				CountRead: 4,
-				Dirents:   testDirents[1:4],
-			},
+			expectedCookie:  2,
 		},
 		{
-			name: "read second and header of third",
-			dir: func() result {
-				dir, errno := preopen.OpenFile("dir", os.O_RDONLY, 0)
-				require.EqualErrno(t, 0, errno)
-				dirent, errno := dir.Readdir(1)
-				require.EqualErrno(t, 0, errno)
-
-				rdd := &sys.Readdir{
-					CountRead: 3,
-					Dirents:   append(testDirents[0:2], dirent...),
-				}
-				return result{
-					entry:   dir,
-					readDir: rdd,
-				}
+			name:       "read second and header of third",
+			initialDir: "dir",
+			dir: func() {
+				f, _ := fsc.LookupFile(fd)
+				rdd, _ := fsc.LookupReaddir(fd, f)
+				_ = rdd.Advance()
 			},
 			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]...),
-			expectedReadDir: &sys.Readdir{
-				CountRead: 5,
-				Dirents:   testDirents[1:5],
-			},
+			expectedCookie:  2,
 		},
 		{
-			name: "read second and third",
-			dir: func() result {
-				dir, errno := preopen.OpenFile("dir", os.O_RDONLY, 0)
-				require.EqualErrno(t, 0, errno)
-				dirent, errno := dir.Readdir(1)
-				require.EqualErrno(t, 0, errno)
-
-				rdd := &sys.Readdir{
-					CountRead: 3,
-					Dirents:   append(testDirents[0:2], dirent...),
-				}
-				return result{entry: dir, readDir: rdd}
+			name:       "read second and third",
+			initialDir: "dir",
+			dir: func() {
+				f, _ := fsc.LookupFile(fd)
+				rdd, _ := fsc.LookupReaddir(fd, f)
+				_ = rdd.Advance()
 			},
 			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...),
-			expectedReadDir: &sys.Readdir{
-				CountRead: 5,
-				Dirents:   testDirents[1:5],
-			},
+			expectedCookie:  3,
 		},
 		{
-			name: "read exactly third",
-			dir: func() result {
-				dir, errno := preopen.OpenFile("dir", os.O_RDONLY, 0)
-				require.EqualErrno(t, 0, errno)
-				two, errno := dir.Readdir(2)
-				require.EqualErrno(t, 0, errno)
-
-				rdd := &sys.Readdir{
-					CountRead: 4,
-					Dirents:   append(testDirents[0:2], two[0:]...),
-				}
-				return result{entry: dir, readDir: rdd}
+			name:       "read exactly third",
+			initialDir: "dir",
+			dir: func() {
+				f, _ := fsc.LookupFile(fd)
+				rdd, _ := fsc.LookupReaddir(fd, f)
+				rdd.Skip(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,
-			expectedReadDir: &sys.Readdir{
-				CountRead: 5,
-				Dirents:   testDirents[2:],
-			},
+			expectedCookie:  3,
 		},
 		{
-			name: "read third and beyond",
-			dir: func() result {
-				dir, errno := preopen.OpenFile("dir", os.O_RDONLY, 0)
-				require.EqualErrno(t, 0, errno)
-				two, errno := dir.Readdir(2)
-				require.EqualErrno(t, 0, errno)
-
-				rdd := &sys.Readdir{
-					CountRead: 4,
-					Dirents:   append(testDirents[0:2], two[0:]...),
-				}
-				readDir, _ := fsc.LookupReaddir(fd)
-				*readDir = *rdd
-				return result{entry: dir, readDir: rdd}
+			name:       "read third and beyond",
+			initialDir: "dir",
+			dir: func() {
+				f, _ := fsc.LookupFile(fd)
+				rdd, _ := fsc.LookupReaddir(fd, f)
+				rdd.Skip(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...),
-			expectedReadDir: &sys.Readdir{
-				CountRead: 5,
-				Dirents:   testDirents[2:],
-			},
+			expectedCookie:  5,
 		},
 		{
-			name: "read exhausted directory",
-			dir: func() result {
-				dir, errno := preopen.OpenFile("dir", os.O_RDONLY, 0)
-				require.EqualErrno(t, 0, errno)
-				_, errno = dir.Readdir(3)
-				require.EqualErrno(t, 0, errno)
-
-				rdd := &sys.Readdir{
-					CountRead: 5,
-					Dirents:   testDirents,
-				}
-				return result{entry: dir, readDir: rdd}
+			name:       "read exhausted directory",
+			initialDir: "dir",
+			dir: func() {
+				f, _ := fsc.LookupFile(fd)
+				rdd, _ := fsc.LookupReaddir(fd, f)
+				rdd.Skip(5)
 			},
 			bufLen:          300, // length is long enough for third and more
 			cookie:          5,   // d_next after entries.
 			expectedBufused: 0,   // nothing read
-			expectedReadDir: &sys.Readdir{
-				CountRead: 5,
-				Dirents:   testDirents,
-			},
+			expectedCookie:  5,
 		},
 	}
 
@@ -2292,19 +2169,15 @@ func Test_fdReaddir(t *testing.T) {
 		tc := tt
 		t.Run(tc.name, func(t *testing.T) {
 			defer log.Reset()
+			defer fsc.CloseReaddir(fd)
 
-			// Assign the state we are testing
-			file, ok := fsc.LookupFile(fd)
-			require.True(t, ok)
-			res := tc.dir()
-			dir := res.entry
-			rdd := res.readDir
-			defer dir.Close()
-
+			dir, errno := preopen.OpenFile(tc.initialDir, os.O_RDONLY, 0)
+			require.EqualErrno(t, 0, errno)
+			file, _ := fsc.LookupFile(fd)
 			file.File = dir
-			if rdd != nil {
-				readDir, _ := fsc.LookupReaddir(fd)
-				*readDir = *rdd
+
+			if tc.dir != nil {
+				tc.dir()
 			}
 
 			maskMemory(t, mod, int(tc.bufLen))
@@ -2329,9 +2202,9 @@ func Test_fdReaddir(t *testing.T) {
 				require.Equal(t, tc.expectedMem, mem[:tc.expectedMemSize])
 			}
 
-			rdd, ok = fsc.LookupReaddir(fd)
-			require.True(t, ok)
-			require.Equal(t, tc.expectedReadDir, rdd)
+			rdd, errno := fsc.LookupReaddir(fd, file)
+			require.Equal(t, syscall.Errno(0), errno)
+			require.Equal(t, tc.expectedCookie, rdd.Cookie())
 		})
 	}
 }
diff --git a/imports/wasi_snapshot_preview1/fs_unit_test.go b/imports/wasi_snapshot_preview1/fs_unit_test.go
index 9e628e65..ce84cdeb 100644
--- a/imports/wasi_snapshot_preview1/fs_unit_test.go
+++ b/imports/wasi_snapshot_preview1/fs_unit_test.go
@@ -13,93 +13,6 @@ import (
 	"github.com/tetratelabs/wazero/internal/wasip1"
 )
 
-func Test_lastDirents(t *testing.T) {
-	tests := []struct {
-		name            string
-		f               *sys.Readdir
-		cookie          int64
-		expectedDirents []fsapi.Dirent
-		expectedErrno   syscall.Errno
-	}{
-		{
-			name: "no prior call",
-		},
-		{
-			name:          "no prior call, but passed a cookie",
-			cookie:        1,
-			expectedErrno: syscall.EINVAL,
-		},
-		{
-			name: "cookie is negative",
-			f: &sys.Readdir{
-				CountRead: 3,
-				Dirents:   testDirents,
-			},
-			cookie:        -1,
-			expectedErrno: syscall.EINVAL,
-		},
-		{
-			name: "cookie is greater than last d_next",
-			f: &sys.Readdir{
-				CountRead: 3,
-				Dirents:   testDirents,
-			},
-			cookie:        5,
-			expectedErrno: syscall.EINVAL,
-		},
-		{
-			name: "cookie is last pos",
-			f: &sys.Readdir{
-				CountRead: 3,
-				Dirents:   testDirents,
-			},
-			cookie:          3,
-			expectedDirents: nil,
-		},
-		{
-			name: "cookie is one before last pos",
-			f: &sys.Readdir{
-				CountRead: 3,
-				Dirents:   testDirents,
-			},
-			cookie:          2,
-			expectedDirents: testDirents[2:],
-		},
-		{
-			name: "cookie is before current entries",
-			f: &sys.Readdir{
-				CountRead: 5,
-				Dirents:   testDirents,
-			},
-			cookie:        1,
-			expectedErrno: syscall.ENOSYS, // not implemented
-		},
-		{
-			name: "read from the beginning (cookie=0)",
-			f: &sys.Readdir{
-				CountRead: 3,
-				Dirents:   testDirents,
-			},
-			cookie:          0,
-			expectedDirents: testDirents,
-		},
-	}
-
-	for _, tt := range tests {
-		tc := tt
-
-		t.Run(tc.name, func(t *testing.T) {
-			f := tc.f
-			if f == nil {
-				f = &sys.Readdir{}
-			}
-			entries, errno := lastDirents(f, tc.cookie)
-			require.EqualErrno(t, tc.expectedErrno, errno)
-			require.Equal(t, tc.expectedDirents, entries)
-		})
-	}
-}
-
 func Test_maxDirents(t *testing.T) {
 	tests := []struct {
 		name                        string
@@ -186,7 +99,15 @@ func Test_maxDirents(t *testing.T) {
 		tc := tt
 
 		t.Run(tc.name, func(t *testing.T) {
-			bufused, direntCount, writeTruncatedEntry := maxDirents(tc.dirents, tc.maxLen)
+			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)
diff --git a/internal/sys/fs_test.go b/internal/sys/fs_test.go
index 99586d07..6612df23 100644
--- a/internal/sys/fs_test.go
+++ b/internal/sys/fs_test.go
@@ -274,3 +274,87 @@ func TestFSContext_Renumber(t *testing.T) {
 		require.Equal(t, syscall.ENOTSUP, fsc.Renumber(3, 3))
 	})
 }
+
+func TestReaddDir_Rewind(t *testing.T) {
+	tests := []struct {
+		name           string
+		f              *Readdir
+		cookie         int64
+		expectedCookie int64
+		expectedErrno  syscall.Errno
+	}{
+		{
+			name: "no prior call",
+		},
+		{
+			name:          "no prior call, but passed a cookie",
+			cookie:        1,
+			expectedErrno: syscall.EINVAL,
+		},
+		{
+			name: "cookie is negative",
+			f: &Readdir{
+				countRead: 3,
+			},
+			cookie:        -1,
+			expectedErrno: syscall.EINVAL,
+		},
+		{
+			name: "cookie is greater than last d_next",
+			f: &Readdir{
+				countRead: 3,
+			},
+			cookie:        5,
+			expectedErrno: syscall.EINVAL,
+		},
+		{
+			name: "cookie is last pos",
+			f: &Readdir{
+				countRead: 3,
+			},
+			cookie: 3,
+		},
+		{
+			name: "cookie is one before last pos",
+			f: &Readdir{
+				countRead: 3,
+			},
+			cookie: 2,
+		},
+		{
+			name: "cookie is before current entries",
+			f: &Readdir{
+				countRead: direntBufSize + 2,
+			},
+			cookie:        1,
+			expectedErrno: syscall.ENOSYS, // not implemented
+		},
+		{
+			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,
+			},
+			cookie: 0,
+		},
+	}
+
+	for _, tt := range tests {
+		tc := tt
+
+		t.Run(tc.name, func(t *testing.T) {
+			f := tc.f
+			if f == nil {
+				f = &Readdir{}
+			}
+
+			errno := f.Rewind(tc.cookie)
+			require.EqualErrno(t, tc.expectedErrno, errno)
+		})
+	}
+}