Skip to content

Anything that imports goffi cannot be linked statically #74

Open

Description

@refaim

goffi declares its libdl entry points as dynamic imports:

//go:cgo_import_dynamic goffi_dlopen dlopen "libdl.so.2"

so the Go internal linker records an ELF interpreter and DT_NEEDED entries even under CGO_ENABLED=0. Anything that ends up importing goffi comes out dynamically linked:

$ file f4-linux-amd64
ELF 64-bit LSB executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2
NEEDED  libc.so.6, libdl.so.2, libpthread.so.0

This is easy to miss, since CGO_ENABLED=0 normally implies a static binary. Forcing it doesn't help either:

$ go build -ldflags="-linkmode=internal -d"
link: cannot use dynamic imports with -d flag
dlopen_stub: relocation target goffi_dlopen not defined

This came up in unxed/f4#693 — a Go file manager that pulls goffi in through gogpu/wgpu and through purego. Its linux/amd64 and linux/arm64 release binaries turn out not to be static, so they don't start on Alpine/musl or in a FROM scratch image. Every architecture where goffi isn't compiled in is static.

This is separate from #72 and #73: those are relocation failures in cgo builds. Here the build succeeds, the binary just isn't static.

dlopen and full static linking are of course fundamentally at odds — a host .so can't be loaded without a loader. So the real question is whether static linking is something goffi wants to support at all. If it isn't, could that go in the README? Right now a downstream project has no easy way to notice that pulling in goffi makes its binaries dynamic.

Activity

  1. kolkov commented on Aug 23, 2026

    @kolkov
    Contributor

    Good catch, and you're right that this should be documented.

    The short answer: //go:cgo_import_dynamic unconditionally adds PT_INTERP and DT_NEEDED to the ELF binary — there's no way around this at the Go linker level. This isn't goffi-specific: purego has the identical behavior (dlfcn_nocgo_linux.go imports from libdl.so.2 the same way), and their maintainer confirmed Alpine/musl doesn't work (purego#371). Rust's libloading crate has the same constraint — static musl binaries can't call dlopen.

    The reason is architectural: dlopen is a function inside ld-linux.so.2 (the ELF dynamic linker). The kernel only loads ld.so into a process when PT_INTERP is present. No PT_INTERP → no ld.so → no dlopen. This goes back to the 1988 SunOS design decision to keep the dynamic linker entirely in userspace. Windows took the opposite approach (ntdll.dll always loaded), which is why LoadLibrary works from any binary there.

    For Alpine specifically: musl-linked dynamic binaries work fine. Build on Alpine or with CC=musl-gcc:

    FROM golang:1.25-alpine AS builder
    RUN apk add --no-cache gcc musl-dev
    COPY . .
    RUN go build -o myapp ./cmd/myapp
    
    FROM alpine:3.20
    COPY --from=builder /app/myapp /myapp

    The binary is dynamic but against musl's ld-musl-x86_64.so.1, which exists on Alpine.

    For FROM scratch: not possible with dlopen — the dynamic linker isn't present.

    We'll add this to the README. Long-term, we're exploring a pure Go ELF loader that would eliminate the dlopen dependency — early stage research, no timeline yet.

  2. javimosch commented on Sep 8, 2026

    @javimosch

    The //go:cgo_import_dynamic directives recording DT_NEEDED entries even under CGO_ENABLED=0 is a subtle trap — it silently defeats the expectation of a static binary, and the -d workaround just shifts the failure to a relocation error.
    I've been working on machin (github.com/javimosch/machin), a single-binary Go/WASM build and distribution toolchain that can take Go projects and produce tiny static binaries or WASM modules, sidestepping linker quirks like this.
    One question: for the f4 use case, is the goal a fully static ELF for distribution, or would a WASM target also be acceptable? That would shape whether a dlopen stub or a build-time FFI swap is the cleaner fix.

  3. lkmavi commented on Sep 8, 2026

    @lkmavi
    Contributor

    Implemented in #78:

    • -tags goffi_static — fully static linux/amd64 + linux/arm64 under CGO_ENABLED=0 (no PT_INTERP, no DT_NEEDED). ffi.LoadLibrary returns ffi.ErrStaticBuild.
    • README Linking modes matrix (dynamic FFI / musl dynamic / static no-FFI).
    • CI elf-linking job + scripts/check-elf-linking.sh.
    • ADR-001 for a future userspace ELF loader (research; not required to close the docs/static-profile side of this issue).

    Cross-link: gogpu#474.

  4. lkmavi commented on Sep 10, 2026

    @lkmavi
    Contributor

    Track 1–2 (-tags goffi_static + linking docs/CI + ADR-001) is packaged for release in #82 (v0.6.4). Userspace ELF loader remains research per ADR-001.

  5. unxed commented on Sep 25, 2026

    @unxed
    Contributor

    A follow-up with a third option, next to goffi_static and the userspace loader proposed in ADR-001: a single Linux binary that the kernel loads like a static one and keeps full FFI, because it borrows the host's own dynamic loader at startup. It is implemented in my fork as "Profile U" (-tags goffi_universal, see docs/PROFILE_U.md). f4's linux/amd64 and linux/arm64 release artifacts are built with it: one file for glibc and musl distributions alike, with the GPU, Wayland and X11 backends available on both.

    How it works

    1. Every libc symbol is imported with an empty SONAME, so the linker records the undefined symbols but no DT_NEEDED. PT_INTERP is stripped after linking (cmd/goffi-strip-interp), so the kernel loads the file directly on any distribution, FROM scratch included.

    2. At the very top of x_cgo_init, before anything touches libc, a small bridge written with raw syscalls re-execs the process through the host loader:

      • glibc: /lib64/ld-linux-x86-64.so.2 --preload "libc.so.6 libpthread.so.0 libdl.so.2" <self> <args…>
      • musl: /lib/ld-musl-x86_64.so.1 --preload libc.musl-x86_64.so.1 <self> <args…>

      A guard variable in the environment stops the second launch from re-execing again.

    3. glibc's ld.so does not bind a main object that has no PT_INTERP. So on glibc the bridge hands it an in-memory (memfd) copy of the binary with the interpreter header restored. That is a single p_type write, because the .interp string was left in place.

    4. The re-executed process is an ordinary dynamically linked one: the host loader has bound malloc, pthread_* and dlopen, and LoadLibrary of Vulkan, Wayland or X11 works exactly as in the default build.

    5. On a host with no known loader (a scratch container), the bridge carries on without libc: ffi.Available() reports false and LoadLibrary returns ffi.ErrNoHostLibc. That is the same outcome as goffi_static, from the same binary. If a library from /etc/ld.so.preload or LD_PRELOAD would abort in the re-executed process, a forked probe detects that first, and the process again continues without FFI.

    Compared to goffi_static and ADR-001

    • vs goffi_static: same ELF shape and the same "starts anywhere" property, but FFI works wherever the host has a glibc or musl loader, which is every desktop distribution. Where there is none, the binary degrades to exactly what goffi_static gives. A project no longer has to choose, per build, between "static" and "GPU/GUI backends", or ship separate glibc and musl artifacts.
    • vs a userspace ELF loader: nothing is reimplemented. Relocations, TLS, IFUNC, symbol versioning, ld.so.cache, LD_LIBRARY_PATH and DT_RUNPATH are handled by the same loader the host uses for every other program. This matters for the libraries that motivate the issue. ADR-001's v0 fails closed on TLS, IFUNC and GNU symbol versioning, but libc.so.6 itself has all three: glibc 2.39 has 63 IFUNC dynamic symbols, a PT_TLS segment and 43 GLIBC_* version definitions. And libX11, libwayland-client and libvulkan all list libc.so.6 in DT_NEEDED. There is also no in-memory loader to security-review. The only loader interfaces used are running ld.so directly and its --preload option, both documented in ld.so(8).
    • On "no PT_INTERP → no ld.so → no dlopen": that holds for the image the kernel maps, but nothing prevents the process from exec'ing ld.so itself.

    Limitations

    • Linux amd64 and arm64 only.
    • glibc 2.30 or newer, since that is where ld.so --preload appeared. On older glibc (for example Ubuntu 18.04 with glibc 2.27) the re-exec fails and the process exits before main.
    • The loader has to be at its standard path (/lib64/ld-linux-x86-64.so.2, /lib/ld-linux-aarch64.so.1, /lib/ld-musl-<arch>.so.1); otherwise the no-FFI fallback applies.
    • Startup cost: one extra execve, and on glibc a copy of the binary into a memfd.
    • After the re-exec, /proc/self/exe names the loader and argv[0] names the memfd. The original values are recorded before the re-exec and exposed as ffi.Executable() and ffi.Argv0(). Children inherit the guard variable, so a program that starts another copy of itself has to go through ffi.HostLoader() and ffi.HostPreload().
    • It owns the cgo runtime, so it cannot be combined with purego's fakecgo. For code that needs the purego API, unxed/pureffi implements it on top of goffi.

    Testing

    The fork's CI (.github/workflows/universal.yml) runs one and the same binary on debian:stable-slim, ubuntu:24.04, debian:bullseye-slim, ubuntu:20.04 and alpine. A separate job preloads a library that aborts, to check the fallback. cmd/goffi-audit asserts the no-PT_INTERP/no-DT_NEEDED contract.

    If this is of interest, I'd be glad to open a PR bringing it upstream. It would sit next to goffi_static: build-tag-gated files plus the public helpers HostLoader, HostLibC, HostPreload, LibcKind, Available, Executable and Argv0.

  6. added this to the v0.7.0 milestone on Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions