Repository navigation
os: Stat and ReadDir on Windows use the file handle without holding a reference #82293
Description
Go version
go version go1.27.2 windows/amd64, and master (dfc6f7d). Windows 10.0.26300, amd64.
Output of go env in your module/workspace:
GOARCH='amd64'
GOOS='windows'What did you do?
Called Stat, ReadDir and Readdirnames on a closed directory. Then called Stat on a directory in a loop while the main goroutine closed the File and opened a regular file of a known size four times.
Program (standard library only)
// Command winstat shows that (*os.File).Stat and ReadDir on Windows use
// the file's handle without holding a reference to it:
//
// - after Close they fail with ERROR_INVALID_HANDLE rather than an
// error matching os.ErrClosed;
// - while Close runs concurrently, the handle value may be closed and
// reused by another open, so Stat can describe a different file.
package main
import (
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
"sync/atomic"
)
const otherSize = 12345 // size of the regular file whose handle may be reused
func main() {
trials := flag.Int("n", 20000, "trials for the concurrent part")
flag.Parse()
fmt.Println(runtime.Version(), runtime.GOOS+"/"+runtime.GOARCH)
dir, err := os.MkdirTemp("", "winstat")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
other := filepath.Join(dir, "other")
if err := os.WriteFile(other, make([]byte, otherSize), 0o666); err != nil {
panic(err)
}
// Part A: methods on a closed directory.
f, err := os.Open(dir)
if err != nil {
panic(err)
}
if err := f.Close(); err != nil {
panic(err)
}
_, statErr := f.Stat()
_, readDirErr := f.ReadDir(-1)
_, namesErr := f.Readdirnames(-1)
for _, c := range []struct {
name string
err error
}{{"Stat", statErr}, {"ReadDir", readDirErr}, {"Readdirnames", namesErr}} {
fmt.Printf("closed dir %-12s err=%q Is(os.ErrClosed)=%v\n", c.name, c.err, errors.Is(c.err, os.ErrClosed))
}
// Part B: Stat racing Close while the handle value is reused.
var wrong, wrongSize atomic.Int64
var example atomic.Value
for i := 0; i < *trials; i++ {
f, err := os.Open(dir)
if err != nil {
panic(err)
}
start := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
<-start
for {
fi, err := f.Stat()
if err != nil {
return // the File is closed
}
if !fi.IsDir() {
wrong.Add(1)
if fi.Size() == otherSize {
wrongSize.Add(1)
}
example.CompareAndSwap(nil, fmt.Sprintf("Stat of directory %q returned IsDir=false Size=%d Mode=%v", fi.Name(), fi.Size(), fi.Mode()))
}
}
}()
close(start)
runtime.Gosched()
f.Close()
// Reopen other files so that the freed handle value is reused.
var opened []*os.File
for j := 0; j < 4; j++ {
g, err := os.Open(other)
if err != nil {
panic(err)
}
opened = append(opened, g)
}
wg.Wait()
for _, g := range opened {
g.Close()
}
}
fmt.Printf("concurrent part: %d trials, Stat of the directory described a non-directory %d times (%d with the other file's size)\n",
*trials, wrong.Load(), wrongSize.Load())
if v := example.Load(); v != nil {
fmt.Println("example:", v)
}
}What did you see happen?
The output on Windows, with the temporary directory shortened to %TEMP%:
go1.27.2 windows/amd64
closed dir Stat err="GetFileType %TEMP%\\winstat1117156126: The handle is invalid." Is(os.ErrClosed)=false
closed dir ReadDir err="GetFileInformationByHandleEx %TEMP%\\winstat1117156126: The handle is invalid." Is(os.ErrClosed)=false
closed dir Readdirnames err="GetFileInformationByHandleEx %TEMP%\\winstat1117156126: The handle is invalid." Is(os.ErrClosed)=false
concurrent part: 20000 trials, Stat of the directory described a non-directory 1 times (1 with the other file's size)
example: Stat of directory "winstat1117156126" returned IsDir=false Size=12345 Mode=-rw-rw-rw-
At master, the concurrent part found 3 in 20000 trials. In each case Stat of the directory returned the size and mode of the regular file that the program opens after Close. So the handle value Stat used had been closed and reused for that file.
Under -race, with go1.27.2:
WARNING: DATA RACE
Read at 0x00c0001a83d0 by goroutine 8:
os.(*File).Stat()
src/os/stat_windows.go:21 +0x90
main.main.func1()
main.go:71 +0xfa
Previous write at 0x00c0001a83d0 by main goroutine:
internal/poll.(*FD).destroy()
src/internal/poll/fd_windows.go:558 +0x10f
internal/poll.(*FD).decref()
src/internal/poll/fd_mutex.go:224 +0x36
internal/poll.(*FD).Close()
src/internal/poll/fd_windows.go:575 +0xb6
os.(*file).close()
src/os/file_windows.go:182 +0xfc
os.(*File).Close()
src/os/file_posix.go:24 +0xa94
main.main()
main.go:86 +0xa95
The same program on linux/amd64 at master: Stat of the closed directory matches os.ErrClosed, and the concurrent part found 0 in 20000 trials.
On Windows, (*File).Stat passes file.pfd.Sysfd to statHandle (stat_windows.go:21). readdir passes it to dirInfo.init and to GetFileInformationByHandleEx (dir_windows.go:105 and 125). Neither takes a reference on the poll.FD, so neither is ordered with Close, which closes the handle and then sets Sysfd to InvalidHandle in (*FD).destroy (fd_windows.go:639 at master). On Unix, Stat uses f.pfd.Fstat (stat_unix.go:20) and readdir uses f.pfd.ReadDirent. Both hold a reference for the duration of the call, and both fail with ErrFileClosing once the File is closed.
A ReadDir that runs concurrently with Close can also crash. That has a different cause, the directory buffer, and is #82292.
What did you expect to see?
Stat and ReadDir on a closed File returning an error that matches os.ErrClosed, as Stat does on Unix (for ReadDir on Unix, see #82229). And Stat never describing a file other than the one the File refers to. The os documentation says "The methods of File are safe for concurrent use" (types.go:17).
Activity
gabyhelp commentedon Oct 17, 2026
Related Issues
Related Code Changes
(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)
dr2chase commentedon Oct 21, 2026
@golang/windows
gopherbot commentedon Oct 26, 2026
Change https://go.dev/cl/853965 mentions this issue:
os: hold Windows file handles during Stat and readdir