You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
$ 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:
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:
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 == 0 → EXC_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()):
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:
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
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.
Alternative: resolve at runtime via dlsym(RTLD_DEFAULT, name) during init
(dlsym would itself need adding to the stub list).
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.
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/unixbuildssuccessfully 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, becausecharmbracelet/colorprofilecallsx/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)
Reproduced with stock
devat f71b630 (2026-08-20). Under lldb, the crash isa branch to address 0 from the darwin syscall engine, with the ioctl arguments
already in place:
Diagnosis
x/sys on darwin dispatches every libc function through a per-function pointer
variable. For
ioctl(x/syszsyscall_darwin_arm64.go/.s):Upstream Go populates
libc_ioctl_trampoline_addrfrom the companion.sfile(
DATA ·libc_ioctl_trampoline_addr(SB)/8, $libc_ioctl_trampoline<>(SB)), whosetrampoline jumps to the dyld-bound
libc_ioctlimport declared by the//go:cgo_import_dynamicpragma.TinyGo assembles neither the
.sfile nor the pragma, solibc_ioctl_trampoline_addrkeeps its zero value. The call chain — whichotherwise works — then branches to address 0:
syscall_syscalllinks fine now (compiler: support file-level //go:linkname directives #5401 handles the file-level linkname);src/runtime/os_darwin_go126.go,syscall.syscalln/syscall.rawsyscalln) receives the call and faithfullyinvokes the
fnit was handed;fn == 0→EXC_BAD_ACCESSat pc 0.The full impact chain, from lldb on a tinygo-built bubbletea
spinnerexample(same crash, showing how it fires during package init before
main()):Relationship to #5403 and public
syscall.Syscall*An earlier attempt in #5403 made public
syscall.Syscall*calls work on darwinby recognizing them as compiler intrinsics and emitting raw kernel syscall
instructions. That supplied a separate path for direct syscall-number callers:
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:
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
ioctlarguments, but withfn == 0. In other words, thedispatcher 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_dynamictrampoline address to the imported libc symbol. It doesnot implement public
syscall.Syscall*-by-number on darwin, which remains theseparate class of problem tracked in #4794.
There is no userland workaround: the trampoline-addr variables are defined
Go vars, so a
//go:linknamepush onto them fails with "symbol multiplydefined" — 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-numbersupport).
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, andioctlare variadic in theDarwin SDK headers. TinyGo's syscall engine calls imported addresses through
fixed-signature function pointers (
tinygo_syscallXinsrc/runtime/os_darwin.c), which pass every argument in a register — but avariadic 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
createDarwinFuncPCABI0Calllowering handles this onlyfor
open(via thesyscall_libc_openC wrapper), leaving the standardlibrary's
fcntl,ioctl, andopenattrampolines unsound.Verified on stock
devat f71b630 (darwin/arm64) with nothing but thestandard library: after
syscall.SetNonblock(fd, true)— fcntlF_SETFLwith the new flags in the variadic slot — a read from the empty pipe still
blocks forever. An
F_GETFLreadback (run on a build that is identical onthis code path) measured garbage flags
0o20000110instead of0o4. Unlikethe 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_dynamicpattern used bydarwin'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_addrsymbol-address lowering for
GOOS=darwin. Non-darwin targets should retaintheir 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
Lower the darwin x/sys
//go:cgo_import_dynamicpattern — use thepragma's local and remote symbol names to replace loads of matching
libc_*_trampoline_addrglobals 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
_ioctlstub required by the fixed-signature wrapper is proposed inmacos-minimal-sdk#5,
together with the
___sincos_stretstub surfaced by the Bubble Tea spinnerverification. 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, andfcntl), 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 directioctlsubstitutionobservably fails with
EFAULT.Alternative: resolve at runtime via
dlsym(RTLD_DEFAULT, name)during init(
dlsymwould itself need adding to the stub list).Small orthogonal DX improvement regardless of the real fix: an
fn == 0check in
os_darwin_go126.go's engine that panics with something like"unresolved cgo_import_dynamic symbol"instead of a raw SIGSEGV. Happy tosend that as a standalone PR if wanted.
Related
fixed by compiler: support file-level //go:linkname directives #5401; the crash remains).
syscall.Syscall-by-number on darwin: disjoint symbol class,see @mparrett's comment there covering both.
//go:linkname(merged), which exposed this next layer.— companion
_ioctland___sincos_stretstubs; open as of 2026-08-22.Environment
tinygo version 0.42.0-dev darwin/arm64 (using go version go1.26.7 and LLVM version 22.1.8)— built fromdevat f71b630-targetflag)