Toolchains
One source tree, two Mojo versions
Mojo 1.0 and nightly will diverge over time. Here is what you could do about it.
The pattern
Put the divergence in a type alias, in its own file, one file per toolchain, and
pick between them with -I.
# compat/stable/threads_compat.mojo
from std.atomic import Atomic
comptime Cell = Atomic[DType.int64]
# compat/nightly/threads_compat.mojo
from std.atomic import Atomic
comptime Cell = Atomic[Int64]
Everything else imports Cell and is written once:
from threads_compat import Cell
from std.atomic import Ordering
def atomic_fetch_add(ptr: CellPtr, delta: Int64) -> Int64:
return ptr[].fetch_add[ordering = Ordering.SEQUENTIAL](delta)
Then the build names a directory:
mojo build -I compat/stable -I src … # Mojo 1.0.0
mojo build -I compat/nightly -I src … # nightly
In pixi, set it per feature so no task has to know which toolchain it is on:
[feature.stable.activation.env]
THREADS_COMPAT = "compat/stable"
[feature.nightly.activation.env]
THREADS_COMPAT = "compat/nightly"
Why it collapses to one line
In the general case you may have more than one divergence. Here std.atomic.Atomic takes a DType on 1.0 and a type on nightly:
| toolchain | compiles | rejected |
|---|---|---|
| Mojo 1.0.0 | Atomic[DType.int64] | Atomic[Int64] |
| nightly | Atomic[Int64] | Atomic[DType.int64] |
That looks like it should infect every call site. It does not. Once the alias
exists, fetch_add[ordering = …], load[ordering = …] and store[ordering = …]
are identical text on both toolchains — the difference lives entirely in the
declaration. In threads.mojo that leaves one divergent line per toolchain and
400 shared ones.
Two things that do not work
A conditional alias at module scope is rejected. This is the obvious first attempt, and it fails identically on Mojo 1.0.0 and on nightly:
comptime if NIGHTLY:
comptime Cell = Atomic[Int64]
else:
comptime Cell = Atomic[DType.int64]
error: 'comptime if' must be contained in a function
That is a scope rule with a message written for it, not a parser tripping over
the syntax — which makes it a thing that could simply be allowed. Filed as
modular/modular#7096, asking
for comptime if at module scope when its branches contain only declarations.
-D defines do not route around it. The define itself is fine, and further
along than you might expect — comptime NIGHTLY = get_defined_bool["MOJO_NIGHTLY", False]() evaluates happily at module scope, and mojo build -D MOJO_NIGHTLY=true
flips it. It is the branch that has to live inside a function. A define can
change a value; it cannot choose what a type alias binds to. Useful for
behavioural differences; no help here.
Which leaves the include path, and the include path is enough.
Keep the mechanism dumb
The temptation is to build an abstraction over “toolchain differences.” Resist it. What is described above is file selection and nothing more: two files, same module name, one on the path at a time. It does not know why the files differ.
That matters because the next divergence will not look like this one. This case was one API spelled two ways. When first-class async lands, the difference will be that a module does not exist on 1.0 at all — and a mechanism built to paper over spellings would not survive that, while one that swaps files will.
What it costs a consumer
Nothing, if they take the published tin: it ships precompiled, and the choice was made when it was built.
Consumers building from source paths add one directory to their include list beside the source one. That is worth stating in your changelog under its own heading, because it is a build-time break for exactly the people who will not read past the summary.
The pattern is in
threads.mojo 0.5.0 — compat/
alongside src/, and src/threads/atomic.mojo written once against Cell.
Both toolchains run the same 44 tests.