The C-ABI artifact does not load off the machine that built it
What the shared library built from m0-core contains, how a foreign caller loads it, and the licensing position of the bundle.
libm0core is published with every GitHub release for "Bun's dlopen,
Node's N-API, or Python's ctypes". It does not work for any of them.
Every release from v0.1.0 to v0.7.0 ships an asset that a consumer cannot
load.
What is wrong
A Mojo shared library records a search path (LC_RPATH on Mach-O,
DT_RUNPATH on ELF) pointing at the absolute path of the venv it was
built in, and resolves the Mojo runtime through it. Downloading v0.7.0 and
inspecting it:
install name : packages/m0-core/libm0core.dylib
search paths : ['/Users/runner/work/mojo-http/mojo-http/.venv/lib/python3.13/site-packages/modular/lib']
dependencies : ['@rpath/libKGENCompilerRTShared.dylib', '/usr/lib/libSystem.B.dylib']
That search path is the CI runner's home directory, baked into a
published asset. dlopen on any other machine fails with:
Library not loaded: @rpath/libKGENCompilerRTShared.dylib
tried: '/Users/runner/work/mojo-http/mojo-http/.venv/.../modular/lib/...' (no such file)
Three distinct defects, two of which are entirely ours:
- The search path is a build-tree absolute path. Ours. Nothing resolves through it anywhere else.
- The install name is a build-tree relative path
(
packages/m0-core/libm0core.dylib). Ours.dlopenignores it, but anything that links against the library records it and then cannot find it. - The Mojo runtime is not shipped alongside. Not ours to fix unilaterally — see the licensing question below.
Why no test caught it, for seven releases
smoke-ffi builds the library and dlopens it in the build tree, where
the venv it was linked against is still present. It therefore passes on
exactly the machine where the defect cannot manifest, and would pass forever.
The release workflow runs the same smoke, so CI proving the artifact "through
the same ctypes smoke" proved only that it works there.
This is a test-design flaw, not an oversight: a load attempt on the build machine cannot detect a dependency on the build machine.
poe check-ffi-portable (scripts/ffi_portability_check.py) checks the
property a load attempt cannot: every recorded search path is relative to
the artifact, and every dependency is either a system library or shipped
beside it. Static inspection, both formats. It fails on the current
artifact and on every published one, which is how it was verified.
The fix, demonstrated
Rewriting the two paths that are ours and shipping the runtime's dependency closure alongside produces an artifact that loads from an unrelated directory with a clean environment:
install name : @rpath/libm0core.dylib
search paths : ['@loader_path']
=> portable: every search path is self-relative and every dependency is a
system library or shipped alongside
The runtime closure is three files, 1.57 MB — libKGENCompilerRTShared
(1.12 MB), which itself needs libMSupportGlobals (0.12 MB) and
libAsyncRTRuntimeGlobals (0.33 MB). It is a closure, not one file; the
first attempt shipped only the first and failed on the second.
Verified: the bundled artifact dlopens from /tmp with DYLD_LIBRARY_PATH
unset and answers the public FNV-1a and xxHash32 vectors.
The open question: may we redistribute the Mojo runtime?
Not resolved here, because it is a licensing determination and the two sources of truth disagree.
- Mojo's compiler and tooling were open sourced on 2026-08-18, under
Apache 2.0 with LLVM Exceptions. Verified from
modular/modular's own rootLICENSE, not only the announcement; no carve-out for the runtime appears in it. - The runtime sources are in that repo —
KGEN/,AsyncRT/,Support/lib/, with real build files and C++ — so the three libraries are built from what is now Apache-licensed source. - But the wheel that ships the prebuilt binaries says otherwise. The
three dylibs are installed by
mojo_compiler-1.0.0, whose METADATA declaresLicense: LicenseRef-MAX-Platform-Software-License— Modular's proprietary MAX Platform license, not Apache.
So the sources are Apache 2.0 + LLVM Exceptions while the binaries as distributed to us declare a proprietary license. The likeliest explanation is stale wheel metadata — Mojo 1.0.0 shipped the same day as the relicensing — but "likeliest" is not a basis for redistributing someone else's binaries.
Note also that the LLVM Exception addresses a different case than this one.
It covers portions of the Software embedded into Object form as a result of
compiling your source — which is libm0core.dylib itself, and is fine. It
does not obviously cover redistributing the runtime libraries as separate
files, which is ordinary Apache §4 redistribution and depends on those files
actually being Apache-licensed.
What would settle it, in increasing order of effort: the wheel metadata being corrected in a later Mojo release; a statement from Modular about redistributing prebuilt runtime binaries; or building the runtime from the Apache-licensed source ourselves rather than shipping Modular's build.
Update, 2026-08-24: the two owned defects are fixed
build-ffi now rewrites both paths after the link (they cannot be
suppressed with a linker flag, because mojo build adds them itself):
| before | after | |
|---|---|---|
| install name | packages/m0-core/libm0core.dylib |
@rpath/libm0core.dylib |
| search path | /Users/runner/work/.../modular/lib |
@loader_path |
| state | BROKEN | SATISFIABLE |
macOS uses install_name_tool plus an ad-hoc codesign — arm64 invalidates
the signature on any Mach-O edit, and an unsigned dylib will not load at all.
smoke-ffi was silently undoing this. It ran its own mojo build into
the same output path, so it overwrote whatever build-ffi produced and
tested an unfixed artifact. It now takes build-ffi as a dependency and
tests what that task actually emits, supplying the Mojo runtime through
DYLD_LIBRARY_PATH — which is the documented consumer requirement, so the
smoke exercises it rather than accidentally depending on a baked-in path.
Linux was never broken in the way macOS is
Parsing the published .so (the checker reads ELF itself, so a macOS
checkout can inspect it) shows no DT_NEEDED entries at all and no .so
references anywhere in the file: the Linux artifact is statically linked and
resolves nothing at load time. Its DT_RUNPATH was inert debris — it named
/home/runner/work/... but nothing was ever looked up through it.
So the missing-runtime problem is macOS-only, and the Linux artifact has been loadable all along. That is worth stating plainly because the original report implied both were broken.
Correction, 2026-08-25. That holds for
libm0core.soand does not generalise.bin/m0servelinks the three Mojo runtime.sofiles dynamically on Linux, exactly as on macOS — the first Linux CI run that tried to bundle the executable is what established it, by refusing. The lesson is narrow and worth keeping: "the Linux artifact is statically linked" was measured on one file and then carried as a platform fact. Nothing in the tooling decides by platform any more, only by what the file records.With
patchelfinstalled, the Linux path now produces what the macOS one does:relocated bin/m0serve (search paths: ['$ORIGIN', '$ORIGIN/../_lib']) bundle ready: dist/m0serve-linux-x86_64 smoke-wheel OK (m0serve-0.9.0-py3-none-manylinux_2_35_x86_64.whl)The measured glibc floor is worth one more note, because it is not where anyone would look for it: 2.35, on a runner whose own glibc is 2.39. The floor is what the toolchain's output requires, not what the build image has — so unlike the macOS deployment target, building on an older image would not lower it.
The checker reports three states rather than pass/fail, which is what makes
it gateable: BROKEN (only loads where it was built), SATISFIABLE (loads
once named files are placed beside it), SELF-CONTAINED (loads with nothing
else). --require-self-contained demands the strongest. Today macOS is
satisfiable and Linux is self-contained; neither is broken.
The licensing question, re-checked 2026-08-24
The nightly metadata has not changed. mojo_compiler-1.1.0.dev2026082305
— built five days after the relicensing — still declares:
License: LicenseRef-MAX-Platform-Software-License
(read via HTTP range requests against the 71.6 MB wheel's central directory, so this costs one request rather than a download.)
So the discrepancy is not a same-day packaging slip that has since been corrected. It has persisted across five nightlies. That does not make redistribution impermissible — the sources really are Apache-2.0 — but it does remove the "obviously just stale" reading, and it is not a question to answer by inference.
Building the runtime from source: assessed, not recommended
The sources are Apache-2.0 and the repo ships bazelw and MODULE.bazel, so
it is possible in principle. In practice KGEN/BUILD.bazel is an MLIR-based
compiler stack (CODialect, HLCFDialect, TableGen targets), so this means
building LLVM/MLIR — hours and gigabytes, per platform, in CI, kept in step
with the pinned Mojo version, and re-done on every pin move. For a 1.57 MB
bundle that only affects macOS, that is far more machinery than the problem
justifies.
Ask Modular instead. The question is one line — are the prebuilt runtime
libraries in the mojo-compiler wheel covered by the Apache-2.0 relicensing,
or still under the MAX Platform license? — and the forum and Discord are
both linked from the wheel's own metadata. A definitive answer costs nothing
and unblocks the last step; the mechanism is already proven and
check-ffi-portable --require-self-contained already validates the result.
Resolved, 2026-08-24: the bundle ships
All three defects are fixed. poe bundle-ffi assembles
dist/libm0core-<platform>/ containing the artifact, the Mojo runtime it
loads, and both licences — and refuses to finish unless the result is
self-contained, so the assembly step and the assertion cannot drift apart.
Verified by loading it from /tmp with DYLD_LIBRARY_PATH and
DYLD_FALLBACK_LIBRARY_PATH unset: every public vector passes.
The closure is discovered, not listed. bundle_artifact.py walks the
dependency graph, so a toolchain bump that adds a fourth library is picked up
instead of silently producing a broken bundle — which is exactly the failure
the first hand-written attempt hit, shipping only the library named in the
error message and then failing on its dependency.
CI runs bundle-ffi on every commit, and the release workflow ships the
bundle as <asset>.tar.gz beside the bare library (kept so existing download
URLs keep working). A release can no longer publish an asset that only loads
on the runner.
Why proceeding was reasonable, and what it does not rest on
The evidence is not unanimous, so the position is stated rather than implied:
- The
mojo_compilerwheel contains onlymodular/bin,modular/libandmojo— the compiler and its runtime, no MAX components. That is precisely the scope open sourced on 2026-08-18. - It ships no licence file of its own, so its
License:metadata field is the only contrary signal — and a packaging field is weak evidence against theLICENSEactually governing the sources. - The same runtime code is already redistributed by this project, embedded, in the statically linked Linux artifact. macOS differs only because Modular's toolchain links it dynamically there; a licence outcome should not turn on their linking strategy.
Nothing here rests on the LLVM Exception. That exception excuses Apache
sections 4(a), 4(b) and 4(d) for portions embedded into Object form by
compiling one's own source — which describes libm0core itself and the
Linux artifact, and was never in doubt. Rather than argue it stretches to
separate files, the bundle simply complies with section 4 in full: the
Apache text ships as LICENSE.mojo-runtime.txt, and NOTICE.txt carries
attribution plus an explicit 4(b) modification notice (install name, rpath
and ad-hoc re-signing changed; executable code byte-for-byte as built).
If Modular later states that the prebuilt binaries are not Apache-licensed, the remedy is one line — drop the copy step — and everything else here still stands.
That remedy shrinks once anything is on PyPI. A GitHub release asset can be deleted. A PyPI file cannot be re-uploaded under the same filename ever again, even after deletion, and a yank (PEP 592) leaves the file downloadable and installable by exact pin — it only removes it from range resolution. So "drop the copy step" fixes future versions and recalls nothing. The actual remedy for a published wheel is: yank, republish without the component, and record in this file what was shipped and when. Worth knowing before the first upload rather than after.
Recommended sequence
Fix defects 1 and 2.Done — see the update above. macOS isSATISFIABLE, Linux isSELF-CONTAINED, neither isBROKEN.- Document the three files a consumer must supply on macOS. Done in the README; the release notes should say it too.
- Ship the runtime alongside once the licensing question is answered —
by asking Modular, not by inferring from the Apache-2.0 sources while the
shipped binaries say otherwise. The mechanism is proven and
check-ffi-portable --require-self-containedalready validates it.
check-ffi-portable can now be a gate rather than a diagnostic: it
passes today on both platforms, and fails if either regresses to BROKEN.
It is not yet wired into CI as one — that belongs with the release workflow
change in (3).
2026-08-25: executables differ from dylibs, and both tools missed it
Scoping the PyPI wheel meant pointing this machinery at bin/m0serve instead
of libm0core. It answered immediately, and the answer was wrong:
$ python3 scripts/ffi_portability_check.py bin/m0serve
install name : @rpath/libKGENCompilerRTShared.dylib
dependencies : ['/usr/lib/libSystem.B.dylib']
SELF-CONTAINED — nothing is resolved at load time.
exit 0
otool -L prints a header line, then — for a dylib only — the file's own
LC_ID_DYLIB install name, then its dependencies. inspect_macho dropped the
first entry unconditionally. bin/m0serve is an MH_EXECUTE and has no
LC_ID_DYLIB at all, so what got dropped was
@rpath/libKGENCompilerRTShared.dylib — the Mojo runtime — leaving only a
system library, and therefore nothing to complain about.
bundle_ffi.py had its own copy of the same four lines, so it would have
copied zero runtime libraries and declared the bundle complete. The two
tools would then have agreed with each other about an artifact neither could
see. That is the same shape as the defect that shipped for seven releases,
one level up: not a test that passes where the defect cannot appear, but a
pair of tests blind in the same direction.
The dependency was real. Deleting the build-machine rpath from a copy and running it:
$ install_name_tool -delete_rpath /Users/.../.venv/.../modular/lib /tmp/m0serve
$ /tmp/m0serve --version
dyld: Library not loaded: @rpath/libKGENCompilerRTShared.dylib
Reason: no LC_RPATH's found
The fix
The parse moved to scripts/binfmt.py, shared by both tools, keyed on
LC_ID_DYLIB's presence rather than on the header's filetype — an
MH_BUNDLE may or may not carry one, and the question being asked is only
ever is the first entry this file's own name?
binfmt.py --selftest runs both cases against canned otool output, in CI,
beside the warning-parser self-test and for the same reason: a parser whose
regression is invisible to every test that uses it is not a guard.
Verified by sabotage
| command | before | after |
|---|---|---|
ffi_portability_check.py bin/m0serve |
SELF-CONTAINED, exit 0 |
BROKEN, exit 1 |
ffi_portability_check.py .../libm0core.dylib |
SATISFIABLE, exit 0 |
SATISFIABLE, exit 0 |
bundle_artifact.py bin/m0serve <dir> |
copies 0 dylibs | copies 3, 1.57 MB |
The middle row is the one worth insisting on. Without it, "the checker now
fails on m0serve" is equally consistent with having simply broken the
checker; with it, the change is localised to the case that was wrong.
Reintroducing the bug in a copy of binfmt.py (entries[1:] unconditionally)
fails the self-test with executable's runtime dependency was dropped, and
assuming every Mach-O has an install name fails it the same way.
bin/m0serve had no post-link surgery at all
build-ffi rewrote install name and rpath; build-serve did neither, because
the surgery was ~25 lines of shell inside one task rather than a thing tasks
could share. So every bin/m0serve ever built recorded a search path into the
venv that built it. It now runs scripts/relocate.py, which build-ffi also
runs, and takes two self-relative rpaths — @loader_path and
@loader_path/../_lib — so one build output serves both the flat tarball
layout and the wheel's _bin/+_lib/ layout with no second pass.
build-serve then completes the bundle in place, so bin/ is the shipped
shape rather than a development-only arrangement that works for a different
reason. That closes the loop the original defect opened: the binary the smokes
exercise is now the binary a user gets. Before this, removing the venv rpath
broke every serve-* and smoke-* task, which is a fair measure of how much
the development path had been relying on it.
A third machine dependency, previously invisible: the deployment target
$ otool -l bin/m0serve | grep -A4 LC_BUILD_VERSION
platform 1
minos 26.0
sdk 26.5 (build host: macOS 26.6.1)
A Mojo binary inherits the build host's SDK. The Mojo toolchain wheel is
tagged macosx_13_0_arm64, but what it emits here requires macOS 26 — so a
PyPI wheel tagged from uv.lock would install cleanly on macOS 15 and then
fail in dyld, and the published libm0core-macos-arm64.dylib assets carry
whatever minos the runner happened to have.
The checker reports min OS now and accepts --require-min-os=X.Y. The
consequence for the wheel is that the platform tag must be measured from the
staged binary, never copied from the toolchain's.
It is a choice, though, not a fate
mojo build honours MACOSX_DEPLOYMENT_TARGET. Measured:
$ MACOSX_DEPLOYMENT_TARGET=13.0 mojo build --emit shared-lib ... -o probe.dylib
$ otool -l probe.dylib | grep -A3 LC_BUILD_VERSION
minos 13.0
So build-ffi and build-serve pin it to 13.0 — the same version the
Mojo toolchain's own wheel is tagged for (macosx_13_0_arm64). Not lower:
the toolchain does not claim to run below that, and promising more than the
compiler does would just move the failure. Modular's runtime dylibs turn out
to be built for 11.0, so they satisfy 13.0 comfortably — wheel_tag.py
reports the spread rather than hiding it:
note: floors differ across files, taking the strictest (macosx_13_0_arm64):
macosx_11_0_arm64 <- libAsyncRTRuntimeGlobals.dylib
macosx_13_0_arm64 <- libm0core.dylib
Pinning changes the wheel's reach from macOS 26+ to macOS 13+, and — more
usefully — makes the floor a property of this repository rather than of
whichever image GitHub is currently calling macos-latest. The measurement
stays regardless: smoke-wheel asserts the tag is never below the binary's
own LC_BUILD_VERSION, so if the pin ever stops working the wheel gets a
narrower tag instead of a false promise.
2026-08-25: the first real release crashed, and it was the CPU
The v0.10.0rc1 release run built both wheels, passed smoke-wheel inside
both build jobs, and passed wheel-consume-macos. Then
wheel-consume-linux did this:
Stack dump without symbol names ...
0 libKGENCompilerRTShared.so 0x00007f39a1540f8e
...
4 m0serve 0x000056203c1260e9
Illegal instruction (core dumped)
m0serve --version — the simplest thing the binary can do — died with
SIGILL in a clean container, having worked on the runner that built it
minutes earlier.
mojo build compiles for the host CPU by default
--target-cpu <CPU>
Sets the compilation target CPU. Defaults to the host CPU.
That is -march=native, silently, for every artifact this repository has
ever produced. Asking the compiler what it was actually doing on a developer
machine:
$ mojo build --print-effective-target ...
--target-cpu apple-m4
--target-features +aes,+bf16,...,+sme,+sme-f64f64,+sme-i16i64,+sme2
+sme and +sme2 are the Scalable Matrix Extension. No M1, M2 or M3 has
them. Every wheel built on that machine — including the ones dogfooded
against three real Django projects, all on the same M4 — would very likely
have crashed on any other Apple Silicon Mac.
Why nothing caught it earlier
This is the rpath defect one level up, and it fails the same way for the same reason:
| rpath | target CPU | |
|---|---|---|
| passes where built | yes | yes |
| fails elsewhere | yes | yes |
| detectable on the build machine | statically | no |
The rpath version was at least visible to static inspection — that is what
ffi_portability_check.py exists for. This one is not: the binary is
correct, it simply requires instructions the consumer's CPU does not
implement. Nothing short of running it on different silicon can tell.
So wheel-consume-linux did not merely catch a bug; it caught the one class
of bug that no amount of care on the build machine could have. It caught it
because it has no repository, no toolchain, and — crucially — runs on a
different machine from the one that built the artifact. That property was
worth the trouble.
wheel-consume-macos passed, and that is worth stating plainly rather than
taking as reassurance: it builds on macos-14 and consumes on macos-14,
so build and consumer share a CPU and the defect is invisible there too.
The macOS side is protected by the pin below, not by that job.
The fix, and the guard
build-ffi and build-serve pin --target-cpu to the oldest CPU each
platform must support:
| platform | baseline | dropped |
|---|---|---|
| macOS arm64 | apple-m1 |
+sme, +sme2, +i8mm, +bf16 |
| Linux / macOS x86_64 | x86-64-v2 |
AVX, AVX2, BMI, FMA |
| Linux aarch64 | generic |
x86-64-v2 is SSE4.2 and POPCNT — hardware from 2009 onward, and the
baseline RHEL 9 itself requires. apple-m1 is simply the oldest Apple
Silicon.
The cost is zero, measured rather than assumed. The obvious objection to pinning a baseline is lost performance, and it was worth checking before accepting it — or before offering a tuned build alongside. Compiling the same sources both ways and diffing the disassembly:
| binary | disassembly lines | differing lines, apple-m1 vs apple-m4 |
|---|---|---|
run_benchmarks (m0-core hot paths) |
41,248 | 0 (only the filename in otool's header) |
m0serve |
257,735 | 0 |
The compiler emits byte-identical machine code. That is a stronger result
than a timing comparison — identical code cannot differ in speed — and it
makes sense on inspection: the features apple-m4 adds over apple-m1 are
+sme, +sme2, +i8mm, +bf16, all matrix and ML extensions, and nothing
in an HTTP server auto-vectorizes into them. The hot paths are syscalls,
byte scanning, hashing and memcpy.
So the pin costs nothing here, and a microarchitecture-tuned build has nothing to ship. That conclusion is specific to this code at this commit: add SIMD-heavy work and it could change, in which case the way to find out is to re-run this diff, not to assume either way.
check_docs.py asserts both tasks pass the flag. Note how it asserts it:
the first version searched the whole task body, which contains a comment
explaining what --target-cpu is for, so it passed with the flag deleted —
a guard satisfied by its own documentation. It now parses the mojo build
command itself, with continuations joined, and both tasks were sabotaged to
confirm it fails.
Should there be an M4-tuned build alongside?
No, on two independent grounds.
Nothing to ship. The measurement above: apple-m1 and apple-m4 produce
byte-identical code for both the core benchmarks and the server. A tuned
wheel would be the same bytes under a different name.
And nowhere to put it if there were. A wheel's platform tag has no field
for microarchitecture — macosx_13_0_arm64 is the only arm64 macOS tag
there is. Two differently-tuned wheels for one version would have the same
filename, so only one can exist and pip has no way to choose between them.
Shipping a tuned build would mean a separate distribution name that users
opt into by hand, or a download outside PyPI entirely. Both are real options;
neither is worth it for identical bytes.
If that ever changes, the honest order is: measure the delta first with the diff above, then decide on a mechanism — not the reverse.