Repository navigation
lean_exe link fails with undefined libstdc++ symbols from libleanffi.a (lean_lib works fine) #196
Description
Activity
Update: root cause fully nailed down, working fix found
Continued digging into the mystery from the original report (
-L+-lstdc++present in the.rspbut having zero effect on the link). Both findings below have been independently verified — the executable now builds and runs.1. Why
-lstdc++silently did nothing (confirmed in clang's own source)Clang's driver silently rewrites any literal
-lstdc++argument before it ever reaches the linker — this isn't sysroot- or platform-specific, it's a deliberate, hardcoded driver behavior:clang/lib/Driver/Driver.cpp(TranslateInputArgs): a literal-largument with valuestdc++gets rewritten to an internal reserved marker,OPT_Z_reserved_lib_stdcxx.clang/lib/Driver/ToolChains/CommonArgs.cpp(AddLinkerInputs): when that marker shows up among the linker inputs, it's replaced by whateverToolChain::AddCXXStdlibLibArgsdecides based onGetCXXStdlibType()— for this Lean-bundled clang (22.1.4), that's libc++, so it silently emits an extra-lc++instead.
Verified this directly:
clang -von both the real failing.rspand a trivialclang t.c -lstdc++ -vminimal repro shows the finalld.lldinvocation never contains-lstdc++at all — it's swapped for a no-op extra-lc++(Lean's own recipe already links-lc++/-lc++abiexplicitly). That's why the flag appeared to do nothing despite being correctly positioned inmoreLinkArgsand despite the target library genuinely exporting the needed symbols.Bypass: anything that doesn't parse as a literal
-loption with value exactlystdc++escapes this rewrite.-Wl,-lstdc++works (routes around the driver's arg-matching entirely, since-Wl,-wrapped payloads aren't checked againstOPT_l). Note the corresponding-Lmust stay a plain-Lflag (not-Wl,-L...) —-Wl,-Ldoesn't get hoisted into the search-path list the way plain-Ldoes.2. A second, distinct bug this exposed once libstdc++ was actually linked
With the real libstdc++ finally linked, four new undefined symbols appeared:
__isoc23_strtoul,__isoc23_strtoull,__isoc23_strtoll,__libc_single_threaded.Root cause: Lean's toolchain bundles its own hermetic glibc 2.26 build for portability (
lib/glibc/libc.so— confirmed via its ELF interpreter path pointing at a Nix-store glibc 2.26 build), but the system'slibstdc++.so.6(built by Fedora's GCC 13 against glibc 2.38) calls glibc entry points that are genuinely new in glibc 2.32/2.38 and have no older-ABI equivalent:__isoc23_strtoul/__isoc23_strtoull/__isoc23_strtoll— C23 changedstrtol-family semantics (0b binary-prefix support whenbase==0); glibc ≥2.38 ships these as new, separately-versioned entry points alongside the classic ones.__libc_single_threaded— a fast-path hint forshared_ptrrefcounting, exposed since glibc ≥2.32.
I confirmed the naive fix (pointing
-Lat the real system glibc ahead of Lean's bundled one) trades this for a worse problem: Lean's bundledScrt1.ostartup code needs__libc_csu_init/__libc_csu_fini, which modern glibc (≥2.34) removed entirely — so overriding-lcresolution wholesale breaks CRT startup linkage instead. This is a genuine toolchain-generation mismatch (Lean's fixed old-glibc baseline vs. anything built against a modern system glibc), not something fixable by-L/-lreordering alone.Working resolution: leave libc/CRT linkage untouched; add a tiny compatibility object defining the 4 missing symbols as trivial forwarders to the classic pre-C23
strtoul/strtoull/strtoll(safe here since neither CTranslate2's npy-header parser nor nlohmann::json's number scanner ever rely on0b-prefix parsing), plus__libc_single_threaded = false(the always-safe, conservative answer — just forces the atomic/thread-safe refcounting path)./* glibc_compat_stub.c */ #include <stdlib.h> unsigned long __isoc23_strtoul(const char *nptr, char **endptr, int base) { return strtoul(nptr, endptr, base); } unsigned long long __isoc23_strtoull(const char *nptr, char **endptr, int base) { return strtoull(nptr, endptr, base); } long long __isoc23_strtoll(const char *nptr, char **endptr, int base) { return strtoll(nptr, endptr, base); } _Bool __libc_single_threaded = 0;
Compiled with the system's plain
gcc(Lean's own clang sysroot ships no libc headers at all):gcc -c glibc_compat_stub.c -o glibc_compat_stub.o -fPIC -O2, then linked in as a plain object file viamoreLinkArgs.Final working
lakefile.tomldiff- moreLinkArgs = ["-L./.lake/packages/LeanCopilot/.lake/build/lib", "-lctranslate2"] + moreLinkArgs = ["./glibc_compat_stub.o", "-L./.lake/packages/LeanCopilot/.lake/build/lib", "-lctranslate2", "-Wl,-L/usr/lib/gcc/x86_64-redhat-linux/13", "-Wl,-lstdc++"]
(the
-L/usr/lib/gcc/x86_64-redhat-linux/13path is this machine's Fedora-13 GCC install dir — find the equivalent on other distros viagcc -print-file-name=libstdc++.soor similar)Verification (independently re-confirmed from a full clean rebuild)
$ lake build leancopilot_demo_pkg:exe ... ✔ [714/714] Built leancopilot_demo_pkg:exe (1.3s) Build completed successfully (714 jobs). $ ./.lake/build/bin/leancopilot_demo_pkg Hello, world! $ echo $? 0Happy to open a PR against
lakefile.leanif a maintainer wants this baked into thelibctranslate2/libleanffibuild recipe directly (e.g. adding the-Wl,-lstdc+++ a bundled version of the glibc-compat stub to the Linux link flags), rather than leaving it as a per-downstream-project workaround.- added 4 commits that reference this issue
on Aug 18, 2026 Fixed in #195 (built on top of the investigation here). Thanks.
- libleanffi.a now automatically bundles the glibc-compat shim on Linux, so that half is transparent: no downstream config needed.
- Statically folding libstdc++ itself into libleanffi.a turned out to be unsound (confirmed by CI): it causes duplicate-symbol errors against Lean's own statically-linked libc++, since both standard library implementations define identically-mangled symbols for types like std::logic_error. So a downstream lean_exe on Linux still needs a moreLinkArgs entry that dynamically links libstdc++, documented as a complete, copy-pasteable recipe in the README's Caveats section.
- Added a lean_exe smoke test to CI (leanffi_exe_smoke_test) that builds with that exact recipe on every run. The existing suite only ever built a lean_lib, so this specific failure mode had no regression coverage before.
Closing this issue as a result.
- added a commit that references this issue
on Aug 20, 2026
Summary
Building a
[[lean_exe]]target in a project that depends on LeanCopilot fails to link with dozens ofundefined symbolerrors for basic libstdc++ types (std::basic_ifstream,std::filesystem::path,std::__cxx11::basic_string, exception-handling primitives, etc.) — all originating fromct2.cppinside LeanCopilot's ownlibleanffi.a. A[[lean_lib]]target that imports and actually calls LeanCopilot (suggest_tactics) in the exact same project builds and runs correctly — only the executable-target link fails. I've root-caused this precisely (see below) but was not able to find a working link-time fix despite several targeted attempts, which I'm reporting honestly rather than claiming a false resolution.Environment
6.8.4-200.fc39.x86_64clang version 22.1.4/ lld, from the Lean toolchain itself4.32.0-rc1/ Lake5.0.0-srcvia elan (the toolchain LeanCopilotv4.31.0pins)v4.31.0, commit2458f7339df4d90643cdc6eb3fbbe968cd73ac78(Also reproduces, same symptom, on the same machine's macOS-adjacent setup is irrelevant here — this is Linux-only, since macOS builds CTranslate2 against Accelerate rather than OpenBLAS/system libstdc++; not tested on macOS since the underlying ABI mismatch doesn't apply there.)
Minimal reproduction
lakefile.toml
LeancopilotDemoPkg/Basic.lean:Main.leanis the defaultlake newtemplate (def main : IO Unit := IO.println "hello"— doesn't even reference LeanCopilot directly, it's just the default[[lean_exe]]entry point).What works
This is a genuine, fresh model-inference call (confirmed via
Built LeancopilotDemoPkg.Basic (7-10s), not a cached replay) — LeanCopilot's ML pipeline is fully functional in the library target.What fails
Full error output (click to expand)
(Full untruncated log available if useful — trimmed here to representative errors; roughly 15 distinct symbol names, ~300 total references, all traced to
ct2.cppinsidelibleanffi.a.)Root cause (confirmed via the actual link command)
The response file (
.rsp) for theleancopilot_demo_pkg:exelink step shows Lean's own exe-link recipe:libstdc++never appears anywhere in this link line — Lean statically links its own bundled libc++/libc++abi (LLVM's C++ runtime) for executables. Butlibleanffi.a'sct2.cpp(and everything it pulls in from CTranslate2) is compiled by the systemg++, and every undefined symbol above uses the GCC-specificstd::__cxx11inline-namespace mangling — these symbols simply don't exist in libc++ at all; they're libstdc++-only.This is presumably why the library target works: a
.so/dynlib built for--load-dynlibloading resolves its libstdc++ dependency at runtime, transitively vialibctranslate2.so.4/libopenblas.so.0(which are themselves normal system-g++ binaries dynamically linked againstlibstdc++.so.6, already present in the process by the time Leandlopens LeanCopilot's.so). The executable link is a from-scratch static/dynamic combination that never provides libstdc++ at all, so the momentlibleanffi.a's object code is pulled into that link (regardless of whetherMain.leanitself even calls anything LeanCopilot-related — it's pulled in transitively because the library depends on it), it's dead on arrival.What I tried (honestly reporting a non-fix)
Since
moreLinkArgsis user-configurable, I tried adding-lstdc++there, expecting it to supply the missing symbols. This did not work cleanly, and I want to report the actual investigation rather than a false "add-lstdc++and it's fixed":"-lstdc++"added tomoreLinkArgs: same undefined-symbol list, unchanged. Turned out this Fedora system has only the runtime/usr/lib64/libstdc++.so.6— the unversioned-lstdc++-resolvable dev symlink (libstdc++.so) exists only under GCC's own private directory,/usr/lib/gcc/x86_64-redhat-linux/13/libstdc++.so(confirmed viarpm -ql libstdc++-devel— the package is installed, it's just not on clang's default search path). Since Lean's clang invocation passes an explicit--sysrootpointing at the Lean toolchain directory (not/), it apparently doesn't auto-detect the host's real GCC installation directory the way an unwrapped system clang normally would."-l:libstdc++.so.6"(referencing the versioned runtime lib directly by exact name, bypassing the need for the dev symlink): failed outright withld.lld: error: unable to find library -l:libstdc++.so.6— confirming/usr/lib64genuinely isn't on the search path for this sysroot-scoped invocation at all."-L/usr/lib/gcc/x86_64-redhat-linux/13"+"-lstdc++"(explicit-Lto the real, confirmed-present dev symlink —fileconfirms it's a genuine symlink to a real ELF.so.6.0.32, not a linker script): the library is now presumably found (no more "unable to find library" error), and bothLeancopilotDemoPkg.Basic:dynlibandLeancopilotDemoPkg:sharedbuild fine with this flag present — but the finalleancopilot_demo_pkg:exelink step still fails with the exact same, byte-for-byte identical list of undefined symbols, despite the.rspfile (re-verified after the build) genuinely containing"-L/usr/lib/gcc/x86_64-redhat-linux/13" "-lstdc++"right afterlibleanffi.a. I independently confirmed vianm -Dthat/usr/lib64/libstdc++.so.6.0.32does export the missing symbols (e.g._ZSt19__throw_logic_errorPKc@@GLIBCXX_3.4), so the symbols genuinely exist in a library that's ostensibly on the link line — I could not determine why the linker isn't using them for this specific target.I don't have enough visibility into Lake's own
moreLinkArgsapplication per-target (whether it's actually applied identically to:exevs:sharedtargets' final link steps) or into lld's exact archive-vs-shared-library resolution order in this scenario to go further — flagging this precisely in case it's either a Lake bug (moreLinkArgs not fully honored for the exe target's own link recipe) or a subtler lld interaction, since I've run out of ideas I can verify from outside the build system's own internals.Why this matters
Any downstream project that wants to ship a
lean_exe(not just a library) using LeanCopilot on Linux hits this immediately and unconditionally — it's not input- or model-dependent, it fails at pure link time before any LeanCopilot code even runs. Happy to share the full untruncated build logs,.rspfiles, or test further configurations if that helps narrow this down.