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:
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.
- 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.
Go version
go version go1.26.3 linux/arm64
Output of
go envin your module/workspace:What did you do?
Run the following program, which mimics a real-world scenario where the destination file's
O_APPENDflag is set on the open file description after the*os.Filewas constructed (in our case, an inheritedos.Stdoutwhose underlying open file description was modified by a child process viafcntl(F_SETFL)):What did you see happen?
os/zero_copy_linux.go's(*File).readFromalready knows thatcopy_file_range(2)andsplice(2)cannot targetO_APPENDdestinations:But
f.appendModeis set once, at File construction (os/file_unix.go:117):— and never refreshed. Two well-defined cases hit this:
os.Stdout/os.Stderr/os.NewFile(...)— files constructed without going throughopenFileNolog. TheirappendModeis the defaultfalse, regardless of the underlying open file description's actual flags.*os.Filewhose underlying open file description is modified viafcntl(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., shell2>&1).Both lead to
(*File).readFromtaking thecopy_file_rangefast path, the kernel returningEBADF, andinternal/poll/copy_file_range_linux.go'shandleCopyFileRangeErrpropagating it (EBADF is not in its fallback list).The bug surfaces in any long-running Go process that (a) writes to a
*os.Fileit didn't open itself (typicallyos.Stdout/os.Stderrinherited via shell redirection) and (b) spawns subprocesses that fcntl the inherited fd's open file description intoO_APPEND— common for tools that adopt append-mode for safe concurrent logging./proc/<pid>/fdinfo/<fd>confirms the transition: samemnt_idandino(the underlying file is unchanged),flagschanges from0400001(O_WRONLY|O_LARGEFILE) to0402001(O_WRONLY|O_LARGEFILE|O_APPEND), at which pointio.Copyto that*os.Filestarts returning EBADF while plainwrite(2)keeps succeeding.Workaround: hide
*os.File's concrete type soio.Copydoesn't take the(*File).ReadFromfast path:— 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.appendModeguard cited above. The fix is correct for files opened withO_APPEND, but doesn't cover (1)*os.Files constructed viaNewFile(which don't query the kernel for flags) or (2) open file descriptions modified post-construction viafcntl(F_SETFL). This issue is the missing follow-up.What did you expect to see?
io.Copyshould succeed (12 bytes copied), falling back to a userspace read/write loop because the kernel cannot performcopy_file_range(2)to anO_APPENDdestination.Two reasonable shapes for the fix:
(A) On
EBADF, fall back. Add this case tointernal/poll/copy_file_range_linux.go'shandleCopyFileRangeErr:Risk: masks legitimate
EBADF(e.g., the fd was closed concurrently). Mitigated by gating oncopied == 0(matches the existingEXDEV/EINVAL/etc. fallback policy).(B) Pre-check
O_APPENDviafcntl(F_GETFL)before attemptingcopy_file_rangein(*File).readFrom. More expensive (one extra syscall perReadFrom), unambiguous.(A) is consistent with how
EXDEV,EINVAL,EOPNOTSUPP,EPERMare 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.