Skip to content

darwin: x/sys //go:cgo_import_dynamic trampoline addresses are nil → SIGSEGV #5604

Description

@neomantra

In working through TinyGo+BubbleTea issues (#5365) , I originally went down the path of fixing the file-level linkname directive (#5401) as well as mucking with the Darwin Syscall interface (#5403), which was rightfully rejected although it also got BubbleTea to work.

With the linkname work merged and with fresh eyes, further research revealed why it was still broken and it involves yet another directive, //go:cgo_import_dynamic) that the Golang private syscall implementation uses in Darwin. This directive is not properly implemented on any of the platforms, but it becomes detrimental to Darwin because of this detail.

We got an even simpler test case than #5365 and I have a PR forthcoming.

What remains is the LLM discussion of the issue, it's not exactly my voice, but I did work with it to uncover it and I've reviewed what it is saying.


What happened

On darwin, any program that reaches libc through golang.org/x/sys/unix builds
successfully with TinyGo but segfaults at runtime with a call to address 0.

The highest-profile casualty: every BubbleTea v2 / lipgloss v2 terminal app
crashes at startup on macOS native
— before main() even runs, because
charmbracelet/colorprofile calls x/term.IsTerminal (→ unix.IoctlGetTermios)
during package init. This is the current root cause behind the user reports in
#5365. (I believe this also affects anything else using x/sys on darwin:
x/term, raw-mode TUIs, etc.)

This is the next layer of the onion after #5401 (file-level //go:linkname,
merged — thanks!): the x/sys symbols now link, but the calls they make jump
through never-initialized function pointers.

Minimal reproduction (no bubbletea required)

package main

import (
	"fmt"

	"golang.org/x/sys/unix"
)

func main() {
	term, err := unix.IoctlGetTermios(1, unix.TIOCGETA)
	fmt.Println(term, err)
}
$ go mod init repro && go get golang.org/x/sys@v0.43.0
$ go build -o repro-go . && ./repro-go
&{...} inappropriate ioctl for device        # fine (or termios values on a tty)

$ tinygo build -o repro-tinygo . && ./repro-tinygo
panic: runtime error at 0x000000010236d978: nil pointer dereference

Reproduced with stock dev at f71b630 (2026-08-20). Under lldb, the crash is
a branch to address 0 from the darwin syscall engine, with the ioctl arguments
already in place:

* thread #1, stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
  * frame #0: 0x0000000000000000
    frame #1: repro-stock`syscall.rawsyscalln + 60
    frame #2: repro-stock`syscall.syscall + 28
    frame #3: repro-stock`runtime.runMain + 940      (x/sys frames inlined)

pc = 0x0          ← branched to the trampoline pointer
x0 = 0x1          ← fd (stdout)
x1 = 0x40487413   ← TIOCGETA

Diagnosis

x/sys on darwin dispatches every libc function through a per-function pointer
variable. For ioctl (x/sys zsyscall_darwin_arm64.go / .s):

func ioctlPtr(fd int, req uint, arg unsafe.Pointer) (err error) {
	_, _, e1 := syscall_syscall(libc_ioctl_trampoline_addr, uintptr(fd), uintptr(req), uintptr(arg))
	...
}

var libc_ioctl_trampoline_addr uintptr

//go:cgo_import_dynamic libc_ioctl ioctl "/usr/lib/libSystem.B.dylib"

Upstream Go populates libc_ioctl_trampoline_addr from the companion .s file
(DATA ·libc_ioctl_trampoline_addr(SB)/8, $libc_ioctl_trampoline<>(SB)), whose
trampoline jumps to the dyld-bound libc_ioctl import declared by the
//go:cgo_import_dynamic pragma.

TinyGo assembles neither the .s file nor the pragma, so
libc_ioctl_trampoline_addr keeps its zero value. The call chain — which
otherwise works — then branches to address 0:

  • syscall_syscall links fine now (compiler: support file-level //go:linkname directives #5401 handles the file-level linkname);
  • TinyGo's own Go 1.26 engine (src/runtime/os_darwin_go126.go,
    syscall.syscalln / syscall.rawsyscalln) receives the call and faithfully
    invokes the fn it was handed;
  • fn == 0EXC_BAD_ACCESS at pc 0.

The full impact chain, from lldb on a tinygo-built bubbletea spinner example
(same crash, showing how it fires during package init before main()):

* thread #1, stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
  * frame #0: 0x0000000000000000
    frame #1: syscall.rawsyscalln + 60
    frame #2: syscall.syscall + 28
    frame #3: golang.org/x/sys/unix.ioctlPtr + 20
    frame #4: golang.org/x/sys/unix.IoctlGetTermios + 44
    frame #5: github.com/charmbracelet/x/term.IsTerminal + 8
    frame #6: github.com/charmbracelet/colorprofile.Detect + 240
    frame #7: runtime.initAll + 24468
    frame #8: runtime.runMain + 80

pc = 0x0          ← branched to the trampoline pointer
x0 = 0x1          ← fd (stdout)
x1 = 0x40487413   ← TIOCGETA

Relationship to #5403 and public syscall.Syscall*

An earlier attempt in #5403 made public syscall.Syscall* calls work on darwin
by recognizing them as compiler intrinsics and emitting raw kernel syscall
instructions. That supplied a separate path for direct syscall-number callers:

syscall.Syscall(SYS_*, ...)
  → compiler lowering
  → raw svc/syscall instruction
  → darwin kernel

That approach bypassed darwin's libc-based route, ran against the maintainers'
preferred direction, and was not the path used by x/sys or BubbleTea. The x/sys
path is instead:

x/sys generated wrapper
  → libc_*_trampoline_addr
  → syscall.syscalln / syscall.rawsyscalln
  → libc function

After #5401 made x/sys's private linknames resolve, this reproducer showed that
TinyGo's existing libc dispatcher was already functional: execution reached it
with the correct ioctl arguments, but with fn == 0. In other words, the
dispatcher was not broken; the assembly-generated bridge that supplied its
libc function pointer was missing.

This issue completes that existing path by resolving the x/sys
cgo_import_dynamic trampoline address to the imported libc symbol. It does
not implement public syscall.Syscall*-by-number on darwin, which remains the
separate class of problem tracked in #4794.

There is no userland workaround: the trampoline-addr variables are defined
Go vars, so a //go:linkname push onto them fails with "symbol multiply
defined" — as @mparrett established in their excellent analysis on
#4794 (comment) (their
"class 2" is exactly this bug; I'm filing it separately so it's searchable and
independently trackable — note the two classes are disjoint: this one is what
x/sys/BubbleTea needs, and it does not require syscall.Syscall-by-number
support).

A second, silent gap: variadic imports on the existing stdlib path

While diagnosing this, a pre-existing bug surfaced on the trampoline path
TinyGo does implement. Of the symbols darwin's generated syscall wrappers
import, exactly open, openat, fcntl, and ioctl are variadic in the
Darwin SDK headers. TinyGo's syscall engine calls imported addresses through
fixed-signature function pointers (tinygo_syscallX in
src/runtime/os_darwin.c), which pass every argument in a register — but a
variadic callee reads its variadic arguments from the stack on darwin/arm64,
so a directly substituted variadic function receives garbage in the variadic
slot. The existing createDarwinFuncPCABI0Call lowering handles this only
for open (via the syscall_libc_open C wrapper), leaving the standard
library's fcntl, ioctl, and openat trampolines unsound.

Verified on stock dev at f71b630 (darwin/arm64) with nothing but the
standard library: after syscall.SetNonblock(fd, true) — fcntl F_SETFL
with the new flags in the variadic slot — a read from the empty pipe still
blocks forever. An F_GETFL readback (run on a build that is identical on
this code path) measured garbage flags 0o20000110 instead of 0o4. Unlike
the crash above, this fails silently. A fix for this issue naturally covers
both paths with the same wrapper set.

Scope

This issue is specifically about the //go:cgo_import_dynamic pattern used by
darwin's generated syscall wrappers. The directive is not darwin-specific:
upstream Go also uses it on platforms such as OpenBSD, AIX, Solaris, and
illumos. A fix for this issue does not need to implement the directive as a
general cross-platform linker feature.

The targeted fix may parse the file-level directive to obtain its local and
remote symbol names, but should only perform the libc_*_trampoline_addr
symbol-address lowering for GOOS=darwin. Non-darwin targets should retain
their existing behavior. Supporting the directive's library operand, other
use patterns, and other platforms' linking and calling conventions is out of
scope and can be addressed separately.

Possible fix directions

  1. Lower the darwin x/sys //go:cgo_import_dynamic pattern — use the
    pragma's local and remote symbol names to replace loads of matching
    libc_*_trampoline_addr globals with the address of the dyld-bound symbol,
    extending TinyGo's existing darwin stdlib trampoline lowering. This does
    not require general implementation of the pragma's library operand.
    The _ioctl stub required by the fixed-signature wrapper is proposed in
    macos-minimal-sdk#5,
    together with the ___sincos_stret stub surfaced by the Bubble Tea spinner
    verification. After that PR merges, TinyGo will also need a submodule bump.
    No new stubs are needed for the other variadic imports handled by the fix
    (open, openat, and fcntl), which are already in the SDK.

    One wrinkle: the variadic imports (open, openat, fcntl, ioctl
    see "A second, silent gap" above) must be routed through fixed-signature
    C wrappers on this path too, the way the existing lowering already does
    for the standard library's open. A direct ioctl substitution
    observably fails with EFAULT.

  2. Alternative: resolve at runtime via dlsym(RTLD_DEFAULT, name) during init
    (dlsym would itself need adding to the stub list).

  3. Small orthogonal DX improvement regardless of the real fix: an fn == 0
    check in os_darwin_go126.go's engine that panics with something like
    "unresolved cgo_import_dynamic symbol" instead of a raw SIGSEGV. Happy to
    send that as a standalone PR if wanted.

Related

Environment

  • TinyGo: tinygo version 0.42.0-dev darwin/arm64 (using go version go1.26.7 and LLVM version 22.1.8) — built from dev at f71b630
  • macOS: Darwin 25.6.0, arm64 (M-series)
  • golang.org/x/sys v0.43.0
  • Target: native darwin/arm64 (no -target flag)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions