From 9161a8c6e75cb1444c21bee6e720a6605e39bc44 Mon Sep 17 00:00:00 2001 From: yohimik Date: Sun, 30 Aug 2026 15:12:20 +0400 Subject: [PATCH] compiler,runtime: call fcntl through a C wrapper on darwin The third parameter of fcntl is variadic, and on darwin/arm64 a variadic argument goes on the stack and not in a register. A call to libc fcntl through a plain three-argument function pointer thus makes the callee read that argument from an unrelated stack slot. open() already has a C wrapper for the same reason. The symptom is quiet. fcntl(fd, F_SETFD, FD_CLOEXEC) sets the flag or does not, which depends on the stack contents, so the result is the same for one binary and different between binaries. syscall.CloseOnExec is the main caller, so when it fails, every descriptor of the program goes into every process that it starts. A child that holds a copy of the write end of a pipe keeps that pipe from a report of EOF, which is how os/exec collects the output of a command. Measured on macOS 26.6 arm64 before this change, fcntl(fd, F_DUPFD, 100) returns EINVAL, and three F_SETFL calls with 0x4, 0x0 and 0x8 all leave F_GETFL with 0x48. The wrapper takes the argument as a uintptr_t so that the pointer commands reached through syscall.fcntlPtr use it too. Both spellings go through libc_fcntl_trampoline, and on a little-endian target the int commands read the low half of the same stack slot. The new tests in src/os cover both shapes. TestFcntlSetNonblock fails on darwin before this change and passes after it. --- compiler/syscall.go | 5 +++ src/os/fcntl_test.go | 72 +++++++++++++++++++++++++++++++++++++++++ src/runtime/os_darwin.c | 13 ++++++++ 3 files changed, 90 insertions(+) create mode 100644 src/os/fcntl_test.go diff --git a/compiler/syscall.go b/compiler/syscall.go index 5172e78380..e77e675b8c 100644 --- a/compiler/syscall.go +++ b/compiler/syscall.go @@ -529,6 +529,11 @@ func (b *builder) createDarwinFuncPCABI0Call(instr *ssa.CallCommon) llvm.Value { // in C. name = "syscall_libc_open" } + if name == "fcntl" { + // Same for fcntl(), whose third parameter is variadic. See + // src/runtime/os_darwin.c for what goes wrong without the wrapper. + name = "syscall_libc_fcntl" + } if b.GOARCH == "amd64" { if name == "fdopendir" || name == "readdir_r" { // Hack to support amd64, which needs the $INODE64 suffix. diff --git a/src/os/fcntl_test.go b/src/os/fcntl_test.go new file mode 100644 index 0000000000..16258ecd1d --- /dev/null +++ b/src/os/fcntl_test.go @@ -0,0 +1,72 @@ +//go:build (darwin || linux) && !baremetal && !tinygo.wasm && !nintendoswitch + +package os_test + +import ( + . "os" + "syscall" + "testing" + "time" +) + +// fcntl takes its third parameter through a variadic list, so the call needs +// the C wrapper in src/runtime/os_darwin.c on darwin. + +// F_SETFL must reach fcntl with the value that the caller gave it. A read on +// an empty pipe returns EAGAIN when the descriptor is non-blocking, and blocks +// when the flag did not arrive. +func TestFcntlSetNonblock(t *testing.T) { + var fds [2]int + if err := syscall.Pipe(fds[:]); err != nil { + t.Fatalf("Pipe failed: %v", err) + } + defer syscall.Close(fds[0]) + defer syscall.Close(fds[1]) + + if err := syscall.SetNonblock(fds[0], true); err != nil { + t.Fatalf("SetNonblock failed: %v", err) + } + + type result struct { + n int + err error + } + done := make(chan result, 1) + go func() { + buf := make([]byte, 1) + n, err := syscall.Read(fds[0], buf) + done <- result{n, err} + }() + + select { + case r := <-done: + if r.err != syscall.EAGAIN { + t.Errorf("wanted EAGAIN from a read on an empty non-blocking pipe, got %d, %v", r.n, r.err) + } + case <-time.After(10 * time.Second): + t.Fatal("the read blocked, so the descriptor is still blocking") + } +} + +// The pointer commands go through the same wrapper as the int commands. A +// F_GETLK on a file that nobody locked reports F_UNLCK. +func TestFcntlGetLock(t *testing.T) { + f, err := CreateTemp(t.TempDir(), "fcntl") + if err != nil { + t.Fatalf("CreateTemp failed: %v", err) + } + defer f.Close() + + lk := syscall.Flock_t{ + Type: syscall.F_RDLCK, + Whence: 0, + Start: 0, + Len: 0, + } + if err := syscall.FcntlFlock(f.Fd(), syscall.F_GETLK, &lk); err != nil { + t.Fatalf("FcntlFlock(F_GETLK) failed: %v", err) + } + if lk.Type != syscall.F_UNLCK { + t.Errorf("wanted F_UNLCK on an unlocked file, got %d", lk.Type) + } +} diff --git a/src/runtime/os_darwin.c b/src/runtime/os_darwin.c index 5d7cd7c71d..5aa022e2e4 100644 --- a/src/runtime/os_darwin.c +++ b/src/runtime/os_darwin.c @@ -3,6 +3,7 @@ // This file is included in the build, despite the //go:build line above. #include +#include // Wrapper function because 'open' is a variadic function and variadic functions // use a different (incompatible) calling convention on darwin/arm64. @@ -12,6 +13,18 @@ int syscall_libc_open(const char *pathname, int flags, mode_t mode) { return open(pathname, flags, mode); } +// Wrapper function for 'fcntl', whose third parameter is variadic as well. +// A call through a plain three-argument function pointer makes the callee read +// that argument from the stack, which holds an unrelated value. +// +// The argument is a uintptr_t so that the pointer commands reached through +// syscall.fcntlPtr use the same wrapper. Both spellings share +// libc_fcntl_trampoline, and on a little-endian target the int commands read +// the low half of the same stack slot. +int syscall_libc_fcntl(int fd, int cmd, uintptr_t arg) { + return fcntl(fd, cmd, arg); +} + // The following functions are called by the runtime because Go can't call // function pointers directly.