Skip to content

os: io.Copy/ReadFrom uses copy_file_range when destination has O_APPEND set post-open, returns EBADF instead of falling back #79660

Description

@Gchbg

Go version

go version go1.26.3 linux/arm64

Output of go env in your module/workspace:

AR='ar'
CC='gcc'
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_ENABLED='1'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
CXX='g++'
GCCGO='gccgo'
GO111MODULE=''
GOARCH='arm64'
GOARM64='v8.0'
GOAUTH='netrc'
GOBIN=''
GOCACHE='/home/dev/.cache/go-build'
GOCACHEPROG=''
GODEBUG=''
GOENV='/home/dev/.config/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFIPS140='off'
GOFLAGS=''
GOHOSTARCH='arm64'
GOHOSTOS='linux'
GOINSECURE=''
GOMOD='/IdeaProjects/gl/gl/go.mod'
GOMODCACHE='/home/dev/go/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='linux'
GOPATH='/home/dev/go'
GOPRIVATE=''
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/home/dev/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.26.3.linux-arm64'
GOSUMDB='sum.golang.org'
GOTELEMETRY='local'
GOTELEMETRYDIR='/home/dev/.config/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN='auto'
GOVCS=''
GOVERSION='go1.26.3'
GOWORK=''
PKG_CONFIG='pkg-config'

What did you do?

Run the following program, which mimics a real-world scenario where the destination file's O_APPEND flag is set on the open file description after the *os.File was constructed (in our case, an inherited os.Stdout whose underlying open file description was modified by a child process via fcntl(F_SETFL)):

package main

import (
	"fmt"
	"io"
	"os"
	"syscall"
)

func main() {
	src, _ := os.CreateTemp("", "src-*")
	_, _ = src.WriteString("hello world\n")
	_ = src.Close()
	defer os.Remove(src.Name())

	// Open destination WITHOUT O_APPEND.
	dst, _ := os.OpenFile("/tmp/dst.bin", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
	defer func() {
		_ = dst.Close()
		_ = os.Remove("/tmp/dst.bin")
	}()

	// Add O_APPEND to the open file description after the *os.File was constructed.
	flags, _, errno := syscall.Syscall(syscall.SYS_FCNTL, dst.Fd(), syscall.F_GETFL, 0)
	if errno != 0 {
		fmt.Println("F_GETFL:", errno)
		os.Exit(1)
	}
	if _, _, errno := syscall.Syscall(syscall.SYS_FCNTL, dst.Fd(), syscall.F_SETFL, flags|syscall.O_APPEND); errno != 0 {
		fmt.Println("F_SETFL:", errno)
		os.Exit(1)
	}

	srcRO, _ := os.Open(src.Name())
	defer srcRO.Close()

	n, err := io.Copy(dst, srcRO)
	fmt.Printf("io.Copy n=%d err=%v\n", n, err)
}

What did you see happen?

io.Copy n=0 err=write /tmp/dst.bin: copy_file_range: bad file descriptor

os/zero_copy_linux.go's (*File).readFrom already knows that copy_file_range(2) and splice(2) cannot target O_APPEND destinations:

func (f *File) readFrom(r io.Reader) (written int64, handled bool, err error) {
	// Neither copy_file_range(2) nor splice(2) supports destinations opened with
	// O_APPEND, so don't bother to try zero-copy with these system calls.
	if f.appendMode {
		return 0, false, nil
	}
	...
}

But f.appendMode is set once, at File construction (os/file_unix.go:117):

f.appendMode = flags&syscall.O_APPEND != 0

— and never refreshed. Two well-defined cases hit this:

  1. os.Stdout / os.Stderr / os.NewFile(...) — files constructed without going through openFileNolog. Their appendMode is the default false, regardless of the underlying open file description's actual flags.
  2. Any *os.File whose underlying open file description is modified via fcntl(F_SETFL, ...|O_APPEND) — by the same process, by an inheriting child process (which shares the open file description), or via a different fd that points at the same description (e.g., shell 2>&1).

Both lead to (*File).readFrom taking the copy_file_range fast path, the kernel returning EBADF, and internal/poll/copy_file_range_linux.go's handleCopyFileRangeErr propagating it (EBADF is not in its fallback list).

The bug surfaces in any long-running Go process that (a) writes to a *os.File it didn't open itself (typically os.Stdout/os.Stderr inherited via shell redirection) and (b) spawns subprocesses that fcntl the inherited fd's open file description into O_APPEND — common for tools that adopt append-mode for safe concurrent logging. /proc/<pid>/fdinfo/<fd> confirms the transition: same mnt_id and ino (the underlying file is unchanged), flags changes from 0400001 (O_WRONLY|O_LARGEFILE) to 0402001 (O_WRONLY|O_LARGEFILE|O_APPEND), at which point io.Copy to that *os.File starts returning EBADF while plain write(2) keeps succeeding.

Workaround: hide *os.File's concrete type so io.Copy doesn't take the (*File).ReadFrom fast path:

io.Copy(struct{ io.Writer }{dst}, src)

— but this is fragile (any caller of io.Copy(*os.File, *os.File) is silently affected, and the workaround has to be applied at every call site that might receive a tainted destination).

Related: #60181 — "os: File.ReadFrom copy_file_range: bad file descriptor on append only file" (closed in Go 1.21). That issue's fix added the if f.appendMode guard cited above. The fix is correct for files opened with O_APPEND, but doesn't cover (1) *os.Files constructed via NewFile (which don't query the kernel for flags) or (2) open file descriptions modified post-construction via fcntl(F_SETFL). This issue is the missing follow-up.

What did you expect to see?

io.Copy should succeed (12 bytes copied), falling back to a userspace read/write loop because the kernel cannot perform copy_file_range(2) to an O_APPEND destination.

Two reasonable shapes for the fix:

(A) On EBADF, fall back. Add this case to internal/poll/copy_file_range_linux.go's handleCopyFileRangeErr:

case syscall.EBADF:
    // Kernel returns EBADF when fd_out has O_APPEND.
    // (https://man7.org/linux/man-pages/man2/copy_file_range.2.html#ERRORS)
    return false, nil

Risk: masks legitimate EBADF (e.g., the fd was closed concurrently). Mitigated by gating on copied == 0 (matches the existing EXDEV/EINVAL/etc. fallback policy).

(B) Pre-check O_APPEND via fcntl(F_GETFL) before attempting copy_file_range in (*File).readFrom. More expensive (one extra syscall per ReadFrom), unambiguous.

(A) is consistent with how EXDEV, EINVAL, EOPNOTSUPP, EPERM are already handled — kernel says "I can't do this op", Go falls back. (B) is conservative.

The same fix should be considered for splice(2) in (*File).spliceToFile.

Metadata

Metadata

Assignees

No one assigned

    Labels

    NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions