Skip to content

compiler,runtime: call fcntl through a C wrapper on darwin - #5632

Open
yohimik wants to merge 1 commit into
tinygo-org:devfrom
yohimik:upstream-pr/darwin-fcntl
Open

compiler,runtime: call fcntl through a C wrapper on darwin#5632
yohimik wants to merge 1 commit into
tinygo-org:devfrom
yohimik:upstream-pr/darwin-fcntl

Conversation

@yohimik

@yohimik yohimik commented Aug 30, 2026

Copy link
Copy Markdown

compiler,runtime: call fcntl through a C wrapper on darwin

What this does

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 in
src/runtime/os_darwin.c for the same reason, and this adds the matching one
for fcntl.

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.

Why it matters

The failure 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, and a child that holds a copy of the write end of a pipe keeps that
pipe from a report of EOF.

Evidence

Measured on macOS 26.6 arm64 before this change.

  • fcntl(fd, F_DUPFD, 100) returns EINVAL.
  • Three F_SETFL calls with 0x4, 0x0 and 0x8 all leave F_GETFL reading 0x48.

Two tests are added to src/os/fcntl_test.go. The os package is already in
TEST_PACKAGES_FAST, so both run in the linux and the macOS CI jobs with no
makefile change.

Test What it covers
TestFcntlSetNonblock F_SETFL round trip. A read on an empty non-blocking pipe must return EAGAIN. It fails on darwin before this change.
TestFcntlGetLock The pointer command path. F_GETLK on an unlocked file must report F_UNLCK.

Both pass on macOS 26.6 arm64 with this change. Linux is unaffected, because it
does not use the darwin trampoline path, and the tests are a regression guard
there.

A downstream product also ships binaries built with a fork that carries this
change, in the published release dispat v1.4.0.
https://github.com/yohimik/dispat/releases/tag/services%2Fdispat%2Fv1.4.0

Scope

  • compiler/syscall.go only changes createDarwinFuncPCABI0Call, so no other
    target sees a difference.
  • No API change.

Related

This is a prerequisite for correct process creation on darwin. os.Pipe on
darwin has no pipe2, so it must mark both ends close-on-exec with fcntl.

Related pull requests

This change is part of one body of work. Together the changes make programs that use the network and child processes work on hosted linux and macOS. A full CLI was tested end to end with all of them and ships binaries built this way, see dispat v1.4.0 in the evidence section.

In this repository

In tinygo-org/net

A merge order that works. The three bug fixes are independent. #5633 goes before #5635. HTTPS on linux needs only #5633 and #5635. Full darwin support also needs #5636, the net changes and a new src/net submodule pin.

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.
@yohimik
yohimik force-pushed the upstream-pr/darwin-fcntl branch from c3bdc58 to 9161a8c Compare September 2, 2026 08:50
@yohimik

yohimik commented Sep 2, 2026

Copy link
Copy Markdown
Author

Rebased on dev after the 0.42.0 release. The problem is present in v0.42.0 as
released. src/os/fcntl_test.go from this branch, put into the src tree of
the official v0.42.0 darwin/arm64 tarball and run with that toolchain, reports
--- FAIL: TestFcntlSetNonblock, "the read blocked, so the descriptor is still
blocking", and --- FAIL: TestFcntlGetLock, "FcntlFlock(F_GETLK) failed:
invalid argument". Both tests pass on linux/arm64, where the fix is not needed.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant