Skip to content

builder: declare the BSD socket API in the darwin libSystem stub - #5636

Open
yohimik wants to merge 5 commits into
tinygo-org:devfrom
yohimik:upstream-pr/darwin-socket-symbols
Open

builder: declare the BSD socket API in the darwin libSystem stub#5636
yohimik wants to merge 5 commits into
tinygo-org:devfrom
yohimik:upstream-pr/darwin-socket-symbols

Conversation

@yohimik

@yohimik yohimik commented Aug 30, 2026

Copy link
Copy Markdown

builder: declare the BSD socket API in the darwin libSystem stub

Last PR of the series. See "Dependencies".

What this does

The stub libSystem.dylib that TinyGo links a darwin binary against comes from
lib/macos-minimal-sdk, whose generator reads a fixed list of headers that does
not have <sys/socket.h>. socket, bind, connect, setsockopt and the
rest are thus absent, and a darwin program that opens a socket through the
standard library syscall package does not link.

libSystem exports them. This adds the names to the same extra-symbol list that
the posix_spawn family uses, which the os: start processes with posix_spawn
PR introduces.

Why it is last

This is the piece that makes host networking work on darwin, and it only has an
effect once three other things are in place.

  1. upstream-pr/os-exec-posix-spawn introduces darwinExtraLibSystemSymbols
    and the second stub object file. If that PR is not taken first, this PR must
    carry that infrastructure instead. It is about 30 lines.
  2. upstream-pr/hosted-crypto-tls gives hosted targets the real crypto/tls,
    which is what an HTTPS client needs.
  3. The src/net submodule must point at a tinygo-org/net commit that has the
    darwin host netdev and the darwin trust roots. See the four tinygo-org/net
    PRs in this series.

TODO before this can merge

The src/net submodule pin is not updated in this branch. It still points
at the current commit. Once the tinygo-org/net PRs land, this PR needs a second
commit that moves src/net to the merged tinygo-org/net commit. Do not merge
this PR until that commit is present, or darwin still has no netdev and the new
symbols are unused.

Evidence

There is no test in this PR on its own, because the symbols are only reachable
once the submodule carries the darwin netdev. Once the pin moves,
TEST_PACKAGES_DARWIN already includes net, so make tinygo-test in the
macOS CI job exercises it with no makefile change.

With the fork that carries the full series, on macOS 26.6 arm64, a program that
calls net.LookupHost and http.Get("https://example.com/") builds and
completes.

A downstream product ships binaries built with these changes in a production
release. dispat v1.4.0 is published and is not a prerelease. It carries
dispat-tiny-linux-amd64 and dispat-tiny-linux-arm64, built by the fork
release v0.42.0-net.4 from sha256-pinned tarballs and smoke-executed under
binfmt before upload, beside six binaries from the gc toolchain.
https://github.com/yohimik/dispat/releases/tag/services%2Fdispat%2Fv1.4.0

The acceptance record of that repository is committed at
packages/docs/docs/internals/tinygo.md. It reports the net.2 to net.4
acceptance history, an integration suite of 694 rows that passes with 0 failures
and 1 documented skip on darwin, and a size table of 0.58x to 0.63x against the
gc equivalents with TinyGo -opt=z -no-debug against go build -trimpath -ldflags "-s -w". Those figures come from that document. They are not a
measurement of this branch.

The suite exercises file I/O, environment variables, process spawning with
pipes, goroutine concurrency under the threads scheduler, net, TLS and x509, and
time and context handling. The self-update path runs over real TLS against a
live host, with negative rows. An unknown CA is refused, and a plaintext server
on a TLS port is refused. The shipped binaries in that release are for linux.
The darwin evidence is the acceptance record and the checks above.

Known gaps

  • Deadlines in the netdev use SO_RCVTIMEO and SO_SNDTIMEO, and a call that
    EINTR restarts begins its timeout again, so a deadline can be later than it
    should be under a heavy GC load.
  • http.Client.Timeout is inert in the tinygo-org/net client.
  • Windows keeps the TLS stub and has no host netdev. wasm and baremetal
    behaviour is unchanged.

Related

Enables

A command line program that needs an HTTPS client path on macOS. Self-update, an
API client, a webhook sender. Client side only. There is no server claim here
beyond what the tests show.

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.
The process layer was a stub. StartProcess refused every ProcAttr that carried
Dir, Sys or Files, and os/exec always passes three Files, so no command could
run. Wait, Kill and Signal returned ErrNotImplemented, and ProcessState was an
empty struct whose methods all reported a failure. The code that did exist was
a fork() and an execve() with no branch on the result of the fork, so the
parent fell into the exec as well.

Use posix_spawn(3) on hosted Linux and macOS, which are the two targets where
the standard library os/exec and syscall packages compile against this
override. Those targets run the threads scheduler and collect with Boehm, so a
fork from Go gives the child one thread that holds the locks of the other
threads, malloc among them, and the stop-the-world signal of the collector can
arrive between the fork and the exec. posix_spawn does the clone and the exec
inside libc, where no Go code runs, and it reports a failed exec as its return
value, so the usual status pipe is not necessary.

The descriptors of the child come from a file-actions list. There is a dup2 for
each entry of ProcAttr.Files, a close for a missing one, and an addchdir_np for
Dir. A nil Env means the environment of the parent, as Go documents. The
attribute block installs an empty signal mask, because a blocked mask survives
an exec and the spawning thread can carry the signal of the collector blocked.

Setpgid and Pgid are honoured through posix_spawnattr_setpgroup, which is the
one SysProcAttr request that posix_spawn can express. Every other field is
refused by name, and the error unwraps to ErrNotImplementedSys.

Wait reaps with wait4 and retries on EINTR, which a thread in wait4 gets as a
matter of course, because the collector interrupts it. ProcessState now carries
the pid and the real syscall.WaitStatus, so exec.ExitError reports "exit status
N", and ExitCode, Exited, Success and Sys work. A killed child is reported as
signalled. Signal refuses a pid that Wait reaped and maps ESRCH to
ErrProcessDone, which is what exec.CommandContext expects when its context
fires as the command finishes.

macOS has no pipe2, so os.Pipe there marks both descriptors close-on-exec
afterwards, under ForkLock. Without the flag every pipe goes into every child,
and a child that holds a copy of a write end keeps that pipe from a report of
EOF. Linux asks for O_CLOEXEC in pipe2 and gets it atomically.

The minimal macOS SDK in lib/macos-minimal-sdk does not declare <spawn.h>, so
the generated libSystem stub has none of the posix_spawn symbols and a darwin
program that starts a process does not link. The builder now assembles the
missing names into a second stub object. posix_spawn_file_actions_addchdir_np
came with macOS 10.15, so a binary from this toolchain needs at least that
release.

Targets without a process model keep the previous stubs. Only the build tag on
exec_other.go changes, to let macOS through to the new implementation.
The runtime had weak.runtime_registerWeakPointer but not its counterpart, so a
program that reads a weak pointer back did not link. crypto/tls does this in
the certificate cache that it keeps behind a weak.Pointer.

Weak pointers are not weak here. registerWeakPointer returns the pointer that
it got, so the value it refers to stays and the way back to a strong pointer is
the identity too. weak.Pointer.Value thus never reports a collected value,
which the documented contract permits.

testdata/weak.go does not link on the current dev branch and prints the
expected value with this change.
TinyGo replaces crypto/tls with a stub whose handshake does nothing, so a
program that dials https gets a plaintext connection behind the TLS API. That
stub is correct for a target with no OS below it, which has neither the code
size for a full TLS implementation nor usually a socket to speak it over.
Hosted linux and macOS have both, and there the crypto/tls of the Go standard
library compiles and runs.

Make the override conditional. Without an entry in the map, crypto/tls falls
under the "crypto/" merge, which links the package of the standard library into
the synthetic GOROOT. GOOS alone cannot decide this, because a baremetal target
reports GOOS=linux, so the build tags decide as well.

The goroot cache key is a hash of the merge links, so the two variants get
separate cache entries.

testdata/hostcryptotls.go does a TLS handshake over an in-memory pipe with a
certificate that it makes at run time. On the current dev branch it prints
"negotiated an unexpected version: 0", because the stub does no handshake. With
this change the handshake completes, the data goes through, and a client that
does not trust the certificate refuses it. loader/goroot_test.go covers the
targets that keep the stub, the baremetal one that reports GOOS=linux included.
The stub libSystem.dylib that TinyGo links a darwin binary against comes from
lib/macos-minimal-sdk, whose generator reads a fixed list of headers that does
not have <sys/socket.h>. socket, bind, connect, setsockopt and the rest are
thus absent, and a darwin program that opens a socket through the standard
library syscall package does not link.

libSystem exports them. Add the names to the same extra-symbol list that the
posix_spawn family uses.
@yohimik
yohimik force-pushed the upstream-pr/darwin-socket-symbols branch from 702b4e8 to 8153e9a 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 change applies on top of v0.42.0
as released without a conflict, and the diff is unchanged.

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