What version of Go are you using (go version)?

$ go version
devel go1.22-98bacdeafe Mon Aug 14 12:51:16 2023 +0000

Does this issue reproduce with the latest release?

Yes.

What did you do?

  1. Use os.Open to open an os.File for a relative directory.
  2. Call os.Chdir to change the current working directory.
  3. Call ReadDir or Readdir on the File opened in step (1).
  4. Inspect the resulting FileInfos.

(https://go.dev/play/p/bLLMvUJGu_u?v=gotip)

What did you expect to see?

The FileInfos should correspond to the directory entries relative to the File.

What did you see instead?

On Linux: - ReadDir produces the directory contents relative to the current directory instead of the opened file. - Readdir produces DirEntry contents relative to the opened file, but calling the Info method on those entries attempts to open the wrong paths (compare #52747).

It isn't clear to me how this can be fixed in general — it is possible to use fdopendir to read the correct entries while the parent file is still open, but if the parent file is closed before the Info method is called on the returned DirEntry, I don't see a standard API to reliably open the correct path.

Comment From: rsc

In general Chdir breaks plenty of invariants, and we're not likely to try to work around that fact, simply because it's impossible to catch them all and just makes things more brittle. The only safe Chdirs are (1) at quiet moments when nothing else is going on and you're not going to try to reuse FS state from before the Chdir afterward, which typically reduces to (1a) at startup, and (2) during os/exec by setting cmd.Dir, which doesn't affect your process.

If there was some compelling use case where Open(dir), Chdir, ReadDir+Info, dir.Close was common, then I think the fix would be optimistic fstatat in the implementation of Readdir and the implementation of DirEntry.Info, with the latter falling back to name-based stat if the directory fd has been closed. But I'd want to see that use case before complicating this code. A racing Chdir in another goroutine would be the most common way to get that sequence, but in that case the Chdir is racing with Open and so you have bigger problems.

Leaving this open but probably we won't fix this unless there is a more compelling demonstration of this arising in actual practice.

Comment From: bcmills

A racing Chdir in another goroutine would be the most common way to get that sequence, but in that case the Chdir is racing with Open and so you have bigger problems.

Note that the Chdir need not race with Open: it only needs to race with the Info method of the DirEntry structs returned by File.ReadDir.

But I agree that this doesn't seem to arise in real-world use. (I noticed it in the context of thinking about edge-cases while reviewing https://go.dev/cl/518195.)

Comment From: neild

This isn't just a problem with Chdir.

  1. link is a symlink to dir, dir is a directory.
  2. os.Open("link").
  3. Remove link.
  4. Call ReadDir on the File obtained in step 2.
  5. Call Info on the resulting FileInfo.

This returns an error, because the Info call tries to stat a file via the now-deleted symlink.

Comment From: ngicks

This path-based lstat became a real world problem after introduction of *os.Root because it may return file info for outside of the *os.Root.

  1. os.OpenRoot("root")
  2. Mkdir "a" and "b" under "root", create "file" under each directory.
  3. Open sub root for "a" using the root we've opened by 1)
  4. rename "a" -> "b", and "b" -> "c"
  5. At this point, f, _ := a.Open("."); dirents, _ := f.ReadDir(-1); dirents[0].Info() returns ENOENT.
  6. rename "c" -> "a", then 5) returns info for file under "b"

This scenario feels naturally existent to me as I expect users open sub roots and move them around while keeping them in struct's fields or anything, but will not reopen them after rename.

I didn't know this behavior so I don't feel it is obvious to users that they need to reopen sub roots after rename.

I've prepared repro for this as shown below. This requires gotip and CL698376.

https://github.com/ngicks/go-open-root-readdir

$ gotip download 698376
$ gotip version
go version go1.26-devel_aa8c784247 Fri Aug 22 10:47:01 2025 -0700 linux/amd64
test code

package openrootreaddir

import (
    "io/fs"
    "os"
    "path/filepath"
    "testing"
)

func TestOsRoot_subRoot_Readdir_after_rename(t *testing.T) {
    tempDir := t.TempDir()

    err := os.MkdirAll(filepath.Join(tempDir, "root"), fs.ModePerm)
    if err != nil {
        t.Fatalf("Mkdir failed: %v", err)
    }

    r, err := os.OpenRoot(filepath.Join(tempDir, "root"))
    if err != nil {
        t.Fatalf("OpenRoot failed: %v", err)
    }
    defer r.Close()

    sysMap := make(map[string]fileIdent)

    for _, dir := range []string{"a", "b"} {
        err := os.MkdirAll(filepath.Join(tempDir, "root", dir), fs.ModePerm)
        if err != nil {
            t.Fatalf("Mkdir failed: %v", err)
        }

        f, err := os.Create(filepath.Join(tempDir, "root", dir, "file"))
        if err != nil {
            t.Fatalf("Create failed: %v", err)
        }

        s, err := f.Stat()
        _ = f.Close()

        if err != nil {
            t.Fatalf("Stat failed: %v", err)
        }

        sysMap[dir], _ = fileIdentFromSys(r, filepath.Join(dir, "file"), s)
    }

    a, err := r.OpenRoot("a")
    if err != nil {
        t.Fatalf("OpenRoot failed: %v", err)
    }
    defer a.Close()

    // It should succeed before rename
    assertFileInfo(t, a, "file", sysMap)

    err = r.Rename("b", "c")
    if err != nil {
        t.Fatalf("rename \"b\" -> \"c\" failed: %v", err)
    }
    err = r.Rename("a", "b")
    if err != nil {
        t.Fatalf("rename \"a\" -> \"b\" failed: %v", err)
    }

    assertFileInfo(t, a, "file", sysMap)

    err = r.Rename("c", "a")
    if err != nil {
        t.Fatalf("rename \"c\" -> \"a\" failed: %v", err)
    }

    assertFileInfo(t, a, "file", sysMap)
}

func assertFileInfo(t *testing.T, r *os.Root, name string, sysMap map[string]fileIdent) {
    t.Helper()

    rootDir, err := r.Open(".")
    if err != nil {
        t.Errorf("r.Open(\".\") failed: %v", err)
        return
    }

    dirents, err := rootDir.ReadDir(-1)
    if err != nil {
        t.Errorf("ReadDir failed: %v", err)
        return
    }

    var tgt os.DirEntry
    for _, dirent := range dirents {
        if dirent.Name() == name {
            tgt = dirent
            break
        }
    }

    if tgt == nil {
        t.Errorf("%q not found in root", name)
        return
    }

    info, err := tgt.Info()
    if err != nil {
        t.Errorf("os.DirEntry.Info on %q failed: %v", tgt.Name(), err)
        return
    }

    fi, _ := fileIdentFromSys(r, name, info)
    if fi == sysMap[filepath.Base(r.Name())] {
        // ok
        return
    }

    t.Errorf("os.DirEntry.Info on %q returned info about differnt file", tgt.Name())
    var found bool
    for k, v := range sysMap {
        if v == fi {
            found = true
            t.Errorf("os.DirEntry.Info returned file under %q", k)
        }
    }
    if !found {
        t.Errorf("os.DirEntry.Info returned info about unknown file: %#v", fi)
    }
}

--- FAIL: TestOsRoot_subRoot_Readdir_after_rename (0.00s)
    openroot_readdir_test.go:65: os.DirEntry.Info on "file" failed: lstat /tmp/TestOsRoot_subRoot_Readdir_after_rename1840602248/001/root/a/./file: no such file or directory
    openroot_readdir_test.go:72: os.DirEntry.Info on "file" returned info about differnt file
    openroot_readdir_test.go:72: os.DirEntry.Info returned file under "b"
FAIL
FAIL    openrootreaddir 0.003s
FAIL