Skip to content

os: Stat and ReadDir on Windows use the file handle without holding a reference #82293

Open

Description

@gomaja

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

  1. dr2chase commented on Oct 21, 2026

    @dr2chase
    Contributor

    @golang/windows

  2. added
    NeedsFixThe path to resolution is known, but the work has not been done.
    on Oct 21, 2026
  3. gopherbot commented on Oct 26, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/853965 mentions this issue: os: hold Windows file handles during Stat and readdir

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    NeedsFixThe path to resolution is known, but the work has not been done.OS-Windows

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions