Skip to content

fix(linux): use cgo bridge for dynamic system symbols - #73

Open
arturonaredo wants to merge 2 commits into
go-webgpu:mainfrom
arturonaredo:bugfix/issue-72-linux-cgo-dynamic-imports
Open

fix(linux): use cgo bridge for dynamic system symbols#73
arturonaredo wants to merge 2 commits into
go-webgpu:mainfrom
arturonaredo:bugfix/issue-72-linux-cgo-dynamic-imports

Conversation

@arturonaredo

Copy link
Copy Markdown

Problem

Linux CGO consumers can fail during final executable linking when goffi's assembly stubs branch directly to symbols declared with //go:cgo_import_dynamic:

goffi_errno_location_stub: unhandled relocation for goffi_errno_location (type 65 (SDYNIMPORT) rtype 7 (R_CALL))
dlopen_stub: unhandled relocation for goffi_dlopen (type 65 (SDYNIMPORT) rtype 7 (R_CALL))
dlsym_stub: unhandled relocation for goffi_dlsym (type 65 (SDYNIMPORT) rtype 7 (R_CALL))
dlerror_stub: unhandled relocation for goffi_dlerror (type 65 (SDYNIMPORT) rtype 7 (R_CALL))

The failure happens inside cmd/link before the external linker can repair the relocation.

Root cause

The Linux CGO path currently shares the zero-CGO implementation:

  • assembly stubs jump/branch directly to SDYNIMPORT symbols;
  • dlopen/dlsym/dlerror are imported from libdl.so.2;
  • __errno_location is imported from libc.so.6.

Changing the branch instruction or forcing external link mode does not help: Go still preprocesses the unsupported assembly-to-SDYNIMPORT relocation first.

Fix

  • Add an assembly-free internal/cbridge package for Linux CGO builds.
  • Call dlopen, dlsym, dlerror, and __errno_location through true C wrappers there.
  • Preserve the existing internal/dl and internal/syscall Go APIs via small Linux-CGO adapters.
  • Restrict the current assembly-stub / cgo_import_dynamic implementation to Linux !cgo.
  • Leave Darwin, FreeBSD, Android, and Linux !cgo behavior unchanged.

A separate cbridge package is required because Go does not permit import "C" in packages that also contain Go assembly.

Validation

  • gofmt -l .: clean
  • git diff --check: clean
  • Linux amd64 and arm64 targeted tests for internal/cbridge, internal/dl, and internal/syscall: pass
  • Linux amd64 and arm64 targeted race tests: pass
  • Linux amd64 go test ./...: pass
  • Linux CGO_ENABLED=0 amd64/arm64 internal/dl + internal/syscall: pass, proving the zero-CGO path remains
  • Real downstream reproducer (inditex/clr-aistudioinstaller/cmd/cli): links successfully on Linux arm64 with this checkout; the identical patch proof also links on Linux amd64
  • Darwin consumer build: pass

The existing Darwin ARM64 full-suite TestExecuteCaptureRegistersSimple failure also reproduces on an untouched upstream/main archive and is unrelated to this change.

develop currently points to v0.2.0-era sources and lacks the affected v0.6.x files, so this PR targets main, where the issue and v0.6.3 implementation live.

Fixes #72

Linux CGO builds currently route dlopen, dlsym, dlerror and
__errno_location through assembly stubs that branch directly to
symbols declared with //go:cgo_import_dynamic. In larger consumer
binaries that relocation can reach cmd/link as R_CALL -> SDYNIMPORT,
which the linker rejects before the external linker can run.

Use real C wrappers for Linux CGO builds instead. The new assembly-free
internal/cbridge package calls libc/libdl through import C, while
internal/dl and internal/syscall retain their existing Go-facing APIs.
The current assembly stubs and cgo_import_dynamic implementation remain
unchanged for Linux !cgo and for Darwin, FreeBSD, and Android paths.

The cbridge indirection is required because Go rejects packages that
contain both import C and Go assembly. Tests cover libc loading, stable
symbol resolution, invalid-library errors, and the errno accessor.

Validated against a real downstream consumer that previously reproduced
go-webgpu#72: clr-aistudioinstaller/cmd/cli now links on Linux amd64 and arm64
when replaced with this checkout.

Fixes go-webgpu#72
@arturonaredo
arturonaredo requested a review from kolkov as a code owner August 20, 2026 06:40
@arturonaredo

Copy link
Copy Markdown
Author

Tested in my project using the line below and worked.

replace github.com/go-webgpu/goffi => github.com/arturonaredo/goffi v0.6.4-0.20260820064007-6772a59ede41

@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@lkmavi

lkmavi commented Aug 21, 2026

Copy link
Copy Markdown

Thanks for the clear write-up and for validating this against a real consumer — the root cause analysis matches what we see in cmd/link on Linux: assembly JMP/B to //go:cgo_import_dynamic symbols leaves R_CALLSDYNIMPORT, which breaks once the final link is in true external/cgo mode (e.g. when another package brings a real import "C", as with ebitengine/purego).

On this approach

Routing Linux cgo through ordinary C wrappers (and keeping the existing stub/cgo_import_dynamic path for !cgo) is consistent with what goffi already does on Android (internal/dl/dl_android_cgo.go) and with what purego does on Linux when CGO_ENABLED=1. As a targeted fix for the CGO_ENABLED=1 coexistence failure in #72, this looks correct.

Recommendation: prefer a no-import "C" Linux cgo path if possible

goffi’s value proposition is zero-CGO. Adding import "C" for the Linux cgo build tag means any consumer that already uses CGO (or pulls a dependency that does) will compile C for goffi as well.

We prototyped an alternative that avoids import "C" entirely on the Linux cgo path:

  1. Keep the same build-tag split as this PR (!cgo → existing stubs + cgo_import_dynamic).
  2. For linux && cgo, resolve loader symbols via the external linker instead of SDYNIMPORT call stubs:
//go:linkname libc_dlopen dlopen
var libc_dlopen byte
// same for dlsym, dlerror

addr := uintptr(unsafe.Pointer(&libc_dlopen))
  1. Feed those addresses into the existing runtime.cgocall wrappers (same shape as today’s stub ABI0 pointers).
  2. For errno: taking the address of __errno_location via linkname still hits SDYNIMPORT relocation failures on Linux, but this works without C:
errnoFn := dlsym(RTLD_DEFAULT, "__errno_location") // RTLD_DEFAULT == 0 on Linux

In a Docker linux/arm64 smoke test, that pattern linked and ran with purego in the same binary, and successfully called dlopen/dlsym. If maintainers are open to it, I’d rather see that direction than a permanent internal/cbridge + import "C" dependency — the separate cbridge package is only required because import "C" cannot live in a package that also has Go assembly.

Happy to turn this into a follow-up patch if useful; this PR can still land as an interim fix if you want the consumer unblocked now.

Separate issue: CGO_ENABLED=0 + purego

This PR does not fix (and cannot fix via cbridge) the CGO_ENABLED=0 failure when both goffi and purego are linked: both ship internal/fakecgo and the link fails with a duplicated _cgo_init (and related) symbols. Worth tracking as its own issue; the original #72 report mentioned CGO_ENABLED=0 as well, but that path appears to be the fakecgo collision rather than the SDYNIMPORT relocation.

Minor nits

  • RTLD_* constants are duplicated between dl_linux.go and dl_linux_cgo.go — consider a shared Linux constants file with //go:build linux && !android.
  • cbridge.Dlerror is unused by the dl adapter (errors are already captured inside Dlopen/Dlsym).
  • Build-tag comments in the assembly stubs that still mention dl_linux_nocgo.go / dl_darwin_nocgo.go look stale relative to current filenames (pre-existing, but easy to fix while touching those lines).

Again — appreciate the repro-driven fix. Main ask is whether we can land the Linux cgo path without import "C", either in this PR or as a fast follow-up.

….Dlerror

Addresses the three minor nits flagged in the go-webgpu#73 review.

- RTLD_* constants were duplicated between dl_linux.go (!cgo) and
  dl_linux_cgo.go (cgo). Extract them into dl_linux_consts.go
  (//go:build linux && !android) so both paths share the same values.
  Darwin and FreeBSD were not affected; their constants live in single
  per-platform files.

- cbridge.Dlerror was unused after the cgo bridge landed in go-webgpu#73
  (errors are embedded in goffi_dl_result and reformatted inside
  dl_linux_cgo.go). Remove it to keep the public surface intentional.

- The stale build-tag comments in dl_stubs_unix.s / dl_stubs_arm64.s
  mentioned dl_linux_nocgo.go / dl_darwin_nocgo.go. Those references
  were already updated in 6772a59 (this branch's leading commit); no
  further action.

The dl_unix.go block comment is updated to point at the new file.
@arturonaredo

Copy link
Copy Markdown
Author

Thanks for the detailed review and for the alternative prototype — the
no-import-C direction is a much better fit for goffi's "zero-CGO"
proposition. I agree landing #73 as the consumer-unblocker is the right
interim call while we work on the no-import-C path.

Follow-ups just pushed on this branch (commit 5c495e6 on top of 6772a59):

  • RTLD_* deduplication in a new internal/dl/dl_linux_consts.go. Both
    dl_linux.go and dl_linux_cgo.go now share the same constants under
    //go:build linux && !android. Darwin/FreeBSD are unaffected.
  • Removed cbridge.Dlerror — it was dead after the bridge landed (errors
    are already captured in goffi_dl_result).
  • The stale build-tag comments in dl_stubs_unix.s / dl_stubs_arm64.s
    you flagged were already fixed in 6772a59 (the leading commit of this
    branch); the stack points at dl_linux.go / dl_darwin.go since the
    same commit. No follow-up needed.

What I'm not doing in this PR:

Branch bugfix/issue-72-linux-cgo-dynamic-imports is now 2 commits
ahead of upstream main. No merge from my side — the PR stays open for
upstream review.

@kolkov kolkov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tracking this down and for the real downstream reproducer — the R_CALL → SDYNIMPORT linker failure is a genuine problem that needs fixing.

That said, we can't merge the import "C" / internal/cbridge approach. goffi's entire reason for existing is zero-CGO FFI — we added CGO_ENABLED=1 support as a concession for users who can't avoid it (race detector, coexistence with other CGO libraries), but the contract is that goffi itself doesn't bring C code into the build. Adding import "C" breaks that contract: any consumer that pulls goffi with CGO_ENABLED=1 would now need a C compiler to build goffi's own internals, not just their own code.

@lkmavi's prototype in the comments — resolving loader symbols via //go:linkname to external linker symbols, then feeding those addresses into the existing runtime.cgocall wrappers — is the direction we want to go. When CGO_ENABLED=1, the external linker already sees dlopen/dlsym/__errno_location from libc; we just need their addresses without going through //go:cgo_import_dynamic assembly stubs that trigger the SDYNIMPORT relocation. No C wrappers, no separate cbridge package.

Roughly, the pattern we're looking at for the linux && cgo path:

//go:build linux && !android && cgo

package dl

//go:linkname libc_dlopen dlopen
var libc_dlopen byte

//go:linkname libc_dlsym dlsym
var libc_dlsym byte

//go:linkname libc_dlerror dlerror
var libc_dlerror byte

func init() {
    // The external linker resolves these symbols from libc.
    // We take their addresses and feed them into the existing
    // runtime.cgocall wrappers — same shape as the !cgo stub ABI0 pointers.
    dlopen_stub_addr = uintptr(unsafe.Pointer(&libc_dlopen))
    dlsym_stub_addr = uintptr(unsafe.Pointer(&libc_dlsym))
    dlerror_stub_addr = uintptr(unsafe.Pointer(&libc_dlerror))
}

For errno, since //go:linkname to __errno_location still hits SDYNIMPORT, resolve it at runtime:

errnoFn = dlsym(RTLD_DEFAULT, "__errno_location")  // RTLD_DEFAULT == 0 on Linux

No import "C", no cbridge package, no C compiler for goffi internals. The existing assembly wrappers and runtime.cgocall path stay as-is.

We're investigating this path now. @lkmavi already validated it in a Docker linux/arm64 smoke test with purego in the same binary. If you're interested in collaborating on the implementation, we can coordinate on the test matrix (amd64/arm64 × glibc/musl). Otherwise we'll pick it up on our side.

The separate CGO_ENABLED=0 + purego fakecgo collision you mentioned is a known issue (#22, closed with workaround: -tags nofakecgo) — that's a different problem from this SDYNIMPORT relocation.

Appreciate the thorough analysis and the follow-up cleanup (RTLD dedup, dead code removal).

@kolkov

kolkov commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

@besmpl — would appreciate your take on this. You've been deep in the Go linker internals with the Android fakecgo work and know the cgo_import_dynamic / SDYNIMPORT mechanics firsthand.

The core question: when CGO_ENABLED=1 forces external linking, our assembly stubs that JMP to //go:cgo_import_dynamic symbols hit R_CALL → SDYNIMPORT failures in cmd/link. PR #73 solves this with import "C" wrappers, which purego also does (internal/cgo). But we'd rather not add C code to goffi's Linux path.

The root cause is cmd/link ld/data.go:255 — the SDYNIMPORT guard exempts Darwin, AIX, Solaris, and OpenBSD but not Linux. Related Go issues are all closed: golang/go#61220, golang/go#49789, golang/go#42459. Do you see a path that avoids import "C" here, or should we push for a fix upstream — adding Linux to that exception list (or a cleaner linker-level solution)?

@arturonaredo

Copy link
Copy Markdown
Author

@kolkov and @lkmavi — thanks for the architectural steer.

You're right on the zero-CGO contract. Adding import "C" for the
Linux cgo path was the wrong fix even if it unblocked the reproducer.
Goffi's whole reason for existing is that consumers shouldn't need a
C compiler to build goffi internals. Lesson learned; closing this
approach.

I agree with the //go:linkname direction @lkmavi laid out, and I'd
like to co-implement it. Proposed plan:

  1. New branch from develop (per CONTRIBUTING Git-Flow):
    feature/issue-72-linux-cgo-no-import-c. New PR against
    develop once the work lands.
  2. Implementation: rewrite internal/dl/dl_linux_cgo.go and
    internal/syscall/errno_linux_cgo.go using the linkname pattern
    @kolkov sketched. libc_dlopen / libc_dlsym / libc_dlerror
    resolved at link time, their addresses fed into the existing
    runtime.cgocall + dlopen_wrapper machinery (the same shape as
    the !cgo stub ABI0 pointers — dl_unix.go patterns, no new
    assembly required). For errno, resolve __errno_location via
    dlsym(RTLD_DEFAULT, ...) at init.
  3. Delete internal/cbridge/ entirely (its only purpose was
    hosting the import "C" wrappers).
  4. Tests: same coverage surface as the cbridge tests (libc
    loading, malloc symbol resolution, invalid-library errors,
    errno address non-zero), moved into internal/dl and
    internal/syscall.
  5. Test matrix I can run locally: amd64 + arm64, glibc ≥ 2.34
    and < 2.34 (libdl.so.2 stub vs. real), musl (Docker
    arm64v8/golang:1.25-alpine per @kolkov's Anything that imports goffi cannot be linked statically #74 comment),
    coexistence with purego (inditex/clr-aistudioinstaller cmd/cli
    — the original unhandled relocation ... SDYNIMPORT when goffi and ebitengine/purego are linked into the same binary #72 reproducer), go test -race ./...,
    golangci-lint run --config=.golangci.yml ./.... If @lkmavi
    or anyone has the original Docker linux/arm64 smoke-test artifacts
    still around, sharing them would let me cross-check from the same
    baseline.

Out of scope (confirming): #74 stays orthogonal. Per @kolkov's
comment there, both //go:cgo_import_dynamic and //go:linkname to
libc produce identical dynamic ELF (PT_INTERP + DT_NEEDED); the
only path to FROM scratch is the pure-Go ELF loader research, which
is a separate project. The new PR description will say so explicitly
so no reviewer expects this to fix static linking.

Two questions before I touch the codebase:

a. PR #73 itself: do you want me to close it once the new PR is
ready, or leave it open as historical reference for the
import "C" approach you rejected? Either is fine; I'll follow
your preference.

b. Approval to proceed: green light on the plan above? In
particular, the file scope (rewrite the two *_linux_cgo.go
files, delete internal/cbridge/) and the build-tag split
(linux && !android && cgo for the new files, matching the
current dl_linux_cgo.go).

I'll wait for your answers before pushing the new branch. If you'd
rather take the implementation in-house and have me step back, just
say so — I've already burned more cycles on this than the repro
deserved, but I'd like to see it land correctly given how much we
both learned.

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.

unhandled relocation ... SDYNIMPORT when goffi and ebitengine/purego are linked into the same binary

3 participants