Mojo 1.2.0 notes for Cinder
Source of truth: https://mojolang.org/docs (manual and std reference for 1.2.0) and the release notes for
1.0.0, 1.1.0 and 1.2.0. Everything below was read from those pages on 2026-11-08 and confirmed by compiling
spikes/hello/ under the pinned toolchain. When the compiler disagrees with this file, the compiler and the
current docs win; update this file.
Forms we use #
Functions and conventions #
defis the only function keyword.fnwas removed in 1.1.0.- Functions are non-raising by default. Add
raisesafter the parameter list; typed errors are writtendef f() raises MyError -> Int:. - Argument conventions: default (immutable borrow, spelled
immwhen explicit),mut,var(take ownership, caller passesx^),ref(mutability follows the referenced value),out(uninitialised, must be initialised; used forselfin__init__and for named results),deinit(consumed; used forselfin__deinit__). readas a convention is a hard error since 1.1.0; useimm.- C ABI:
def f(a: Int32) abi("C") -> Int32:. Function pointer type:def(Int32) thin abi("C") -> Int32.thinmeans a function pointer, not a closure. Never combineabi("C")withraises. - Closures with capture lists replace
@parameterclosures.@parameteris deprecated;@__parameterexists only for legacy nested closures.
Compile time #
comptimedeclares compile-time values:comptime pi = 3.14159.aliaswas removed in 1.1.0.comptime if/comptime forreplace@parameter if/@parameter for(removed in 1.1.0).comptime matchevaluates patterns at compile time (1.2.0).- Parameters:
def repeat[count: Int](msg: String):,struct FixedBuffer[length: Int]:. - Constraints: trailing
where <cond> else "<message>"after the signature (preferred form in 1.2.0).
Variables #
- Every variable is declared with
var. Implicit declaration is an error since 1.1.0.letdoes not exist. :=only assigns to existing variables.
Structs, traits, lifecycle #
struct Name(TraitA, TraitB):; conformance is always explicit.@fieldwise_initgenerates the field-wise initialiser.- Structs are
Movableby default and not copyable. DeclareCopyablefor a generated copy initialiser (__init__(out self, *, copy: Self)),ImplicitlyCopyableto allow implicit copies. Preferx.copy(). - Destructor:
def __deinit__(deinit self):.__del__still compiles but warns; we treat it as banned. - Values are destroyed ASAP after their last use.
- C-layout structs:
struct DivT(RegisterPassable):with C-compatible fields in C order;@align(n)for alignment.@register_passableas a decorator is not the current form. @exportmarks a function for export from a built library.@inline(.always | .never | .nodebug | .automatic)is preferred over@always_inline/@no_inline.- Core traits used:
Copyable,Movable,ImplicitlyCopyable,Deinitable,Defaultable,Writable,Stringable,Hashable,Equatable,Comparable,Boolable,Intable,Sized,Iterable,Iterator,EnumLike,Reflected,RegisterPassable,AnyType.
Strings and formatting #
String(mutable, UTF-8),StringSpan(immutable view;StringSliceis a deprecated alias),StringLiteral,CStringSpan(C strings;as_c_string_span()).- Formatting uses template strings:
t"value={x}"(TString, lazy).String.formatexists but we use t-strings. MutStringSpan/MutStringSlicewere removed in 1.2.0.- Iterating a
Stringyields grapheme clusters; use.bytes()or.codepoints()explicitly.
Pointers and memory (std.memory) #
Pointer[T, origin]is the raw pointer.UnsafePointeris a deprecated alias (1.0.0).MutPointer,ImmPointer,OptionalPointer,OpaquePointer(=Pointer[NoneType]).- Every unsafe operation is prefixed:
ptr[unsafe_offset=i],unsafe_load(),unsafe_store(),unsafe_offset(n),unsafe_bitcast[T](),unsafe_write(value^)/unsafe_write(copy=v),unsafe_take_pointee(),unsafe_deinit_pointee(),unsafe_free(). Unprefixedload/store/freeare deprecated and banned here. Pointer(to=value)borrows an existing value.Pointer[T, MutUntrackedOrigin](unsafe_from_address=addr).- Allocation:
alloc[T]({count = n})oralloc(Layout[T](count=n))returns anAllocation;allocation.unsafe_ptr(),into_thin()for a field-sizedThinAllocation,dealloc(allocation^),unsafe_leak().Layouttakesalignmentas a keyword-onlyAlignment(Alignment.of[T](),Alignment.of_bytes[n]()) since 1.2.0. OwnedPointer(into_inner(), nottake()),ArcPointer,MaybeUninit.- Raw memory helpers are the
unsafe_-prefixed forms;memcpy,memset,memcmpwere removed in 1.1.0.
Collections #
List[T](capacity()is a method;resize(new_length=, fill=)),Array[T, length](replacesInlineArray;.length),Span(Span(unsafe_ptr=, length=);ImmSpan,MutSpan),Dict,Set,Optional(iterable),Variant(unwrap[T]()),Tuple(Tuple[]is the empty tuple type).- A list literal defaults to
Array, notList; annotatevar l: List[Int] = [...]when aListis wanted. - Negative slice indices and out-of-range
insertabort; always spell indices out.
SIMD and numerics #
SIMD[dtype, length];Scalar[dtype];Float32,Int32, etc. The parameter islength, notsize.SIMD.from_bytes()/as_bytes()takeendian: Endian; compare againstEndian.native().std.simdholdsSIMD(moved out ofstd.builtinin 1.1.0).
FFI (std.ffi) #
external_call["symbol", ReturnType](args...)for symbols linked into the binary; variadics neednum_fixed_args=.OwnedDLHandle(path)for runtime loading;get_function[ReturnType]("name"),check_symbol("name"). Argument types are not checked against C, which is why Cinder has layout and signature tests.c_int,c_uint,c_char(target signedness since 1.2.0),c_uchar,c_short,c_long,c_long_long,c_size_t,c_ssize_t,c_float,c_double.- Large by-value struct returns are not directly callable; the C side takes an out-pointer. This is exactly the subject of spike 3.
mojo buildhas no-l/-L; use-XlinkerandMODULAR_MOJO_MAX_SYSTEM_LIBS, orOwnedDLHandle.
Testing (std.testing) #
- Tests are module-level
def test_*() raises:functions. Each test file ends withdef main() raises: TestSuite.discover_tests[__functions_in_module()]().run()and is run withmojo run -I <src> <file>. There is nomojo testsubcommand in 1.2.0. - Assertions:
assert_true,assert_false,assert_equal,assert_not_equal,assert_almost_equal,assert_is,assert_is_not,assert_raises(context manager),assert_abortsmodule,testing.propfor property tests. - Filtering:
--only,--skip,--skip-all; programmaticsuite.skip[test](). Cinder never skips a test to make a run pass (section 10 of the mission).
GPU #
std.gpuis private; GPU compute lives inmax.gpu. Cinder does not use it (ADR-0002). All GPU work is WGSL through wgpu-native.
Build #
mojo build --Werror -O3 -g1 -o out file.mojo;--sanitize address|threadis experimental;--emitfor other outputs;-D KEY=VALUEread throughstd.defines..mojopkgis gone; precompiled packages are.mojoc.
Facts established by compiling under 1.2.0 (spikes/api_probe, 2026-11-08) #
- ASAP destruction applies to
OwnedDLHandle. The handle is destroyed, anddlcloseruns, right after the handle's last syntactic use. A function pointer obtained from it then dangles and the next call segfaults with no diagnostic. This cost half a day. Rule: function pointers live only as fields of the struct that also owns the handle (cinder/ffi/*generates exactly that), they are never copied out into locals, and the owning struct is held by a long-lived engine object. In amainthat uses a handle directly, reference it after the last call (print(Bool(lib))). - Untracked pointers do not keep their pointee alive. Once
Pointer(to=x)is cast toMutUntrackedOrigin(which every raw C binding requires), the compiler no longer sees reads ofxthrough it. For a trivially destructible struct (every C descriptor), ASAP destruction then lets the stack slot be reused after the last visible read, and_ = xis not a read. Symptom: C sees garbage (wgpu readbackendType = 0x7F19from a deadWGPURequestAdapterOptions; naga read a garbage string length). Rule: the safe layer owns descriptors in engine-lifetime storage, and any stack value handed to C by pointer is followed bystd.benchmark.keep(value)after the last C call that can read it. Spikes 2 to 4 are written this way. Pointeris non-nullable. Nullable C pointers areOptional[Pointer[T, origin]], andsize_of[Optional[Pointer[...]]]() == 8, so they pass throughabi("C")as plain pointers. The layout test asserts this size.Optional[def(...) thin abi("C") -> R]is 16 bytes, not 8. A nullable C function pointer field is therefore bound asOptional[Pointer[NoneType, MutUntrackedOrigin]], never as an optional function type. Function-pointer parameters are bound as the non-optional thin function type.- A 96-byte struct (
CnBig:Array[Float64, 8],Array[Int32, 4],UInt8, padding) passes by value into C correctly withoutRegisterPassable; returning it by value is verified in spike 3's test. - A Mojo
def ... abi("C")function is callable from C on the calling thread and from a freshly created native thread (spike 4). reflect[T].field_offset[index=i](),size_of,align_ofwork on these structs at compile time.obj.field(args)calls a function pointer stored in a struct field correctly.MutUntrackedOrigin/ImmUntrackedOriginare prelude names, notstd.memoryexports.- An empty struct body is an error; opaque C types get one
var _opaque: UInt8field and are never instantiated.
Compiler limits met so far #
- Large
comptime Array[StaticString, N]literals exhaust the compiler (N ≈ 2,000 already fails under a 13 GB cap; N = 370 compiles but costs ~90 s per program). Reproducer and measurements:bugs/comptime_array_literal_oom/. Workaround in the generated bindings: one newline-joinedStringLiteral, split at load time. Keep compile-time collections small; put bulk data in a single literal or in a file.
Legacy forms that are banned in this repository #
A CI grep (tools/lint_legacy_mojo.sh) fails the build on any of these in *.mojo files:
fn (as a keyword), alias , inout, borrowed, owned , let , @parameter, __del__,
InlineArray, StringSlice, UnsafePointer, MutUnsafePointer, ImmUnsafePointer, std.gpu,
.load(/.store(/.free( on pointers, memcpy(, memset(, read as a convention, @parameter if,
@parameter for, @register_passable, __comptime_assert, steal_data, .size on SIMD/Array.
Open questions to verify by compiling #
- Whether
abi("C")Mojo functions can be passed to C and invoked from a foreign thread (spike 4). - How
@exportinteracts withmojo build --emit shared-lib(needed only if Mojo ever exports to C; the design keeps all callbacks in C, so this is not on the critical path). - The exact
std.runtime/ threading primitives available for the job system (Atomic[T],std.runtime.parallelism_level()); verified in Phase 2.
wgpu-native behaviour worth knowing (verified 2026-11-08) #
- A validation error raised inside
wgpuQueueSubmit(for example a texture-to-buffer copy whosebytesPerRowis not a multiple of 256) is fatal: wgpu-native panics and aborts the process instead of reporting through the uncaptured-error callback. Resource creation errors do go through the callback. Tests therefore keep copy strides 256-aligned and the engine's copy helpers take the stride explicitly so the caller can see it. - Line rasterisation follows the diamond-exit rule: a line placed exactly on a pixel boundary lands on one of the two rows depending on the backend. Pixel tests put lines through pixel centres.
More lifetime pitfalls seen in Phase 4 (verified 2026-11-08) #
- Early returns skip the
keep. A function that creates aNativeLibrary, callslib.ptr()[]...and returns from one branch destroyslib(dlclose) right after.ptr()on that branch, so the C call jumps into unmapped memory (SIGSEGV with a?? ()frame under gdb). Structure such functions as if/else with onekeep(lib)at the end, or keep on every path. _uptr(list)-style helpers that return an untracked pointer end the list's lifetime at the call site. Every list whose pointer is passed to C must be fenced withkeep(list)after the call (MeshData.build_lodsfencesoptimised,current,destandcache).SIMD[DType.bool, 4](True)is rejected ("must be a scalar; use thefillkeyword"); writeSIMD[DType.bool, 4](fill=True), or avoid boolean vectors and reduce a float margin instead.Float32.from_bitsdoes not exist; usebitcast[DType.float32, 1](bits)fromstd.memory.- Struct fields are destroyed in declaration order. A struct holding both a
NativeLibraryand objects whose destructors call into it must declare the library last (see the renderer test harness).
Pitfalls seen in Phase 6 (verified 2026-11-09) #
- A method taking
mut selfcannot also takemut field_of_self(passingself.currentto amut layer: _Layerparameter is an exclusivity violation). Return the new value and assign it, or branch on which field to touch. pose.blend(pose, other, t)withmut selfanda: Posealiasing the same object is rejected; write in-place variants (self = lerp(self, other, t)).T: Copyable & Movableis flagged as a redundant composition:CopyableimpliesMovable.- Single-field enum-style structs (
struct Kind: var value: UInt8) need@fieldwise_initforKind(0)to resolve; a struct with an explicit__init__must not also carry it. Int(phase)truncates towards zero: counting loop wraps from an unwrapped phase in [0, inf) isInt(after) - Int(before), not a<comparison of wrapped values (0.25 -> 2.25 wraps to 0.25 and compares equal).- joltc:
JPH_Initis idempotent; shape handles upcast toJPH_Shape*by bitcast; block-free query filters need C procs (native/src/physics_shim.c). - miniaudio: the sound-end callback fires on the processing step after the one that reached the end (the sound is already stopped inside it), so headless tests must read one more buffer before draining the queue.
- A destructor that aborts on leaks hides the real failure when a test raises: count and
assert in tests instead (
live_objects() == 0).
Pitfalls seen in Phase 7 (verified 2026-11-09) #
- Inside a parametric struct, the parameter must be written
Self.Tin field and method signatures (var _storage: Allocation[Self.T]). unsafe_deinit_pointee()needs the pointee type bound toDeinitable(T: Deinitable & Movable).- A struct whose constructor stores pointers to its own fields breaks when the struct moves;
box such subsystems (
cinder/core/boxed.mojo) and keep raw pointers to the box. Stringhas inline small-string storage: a pointer fromas_c_string_span()dies when the owning list reallocates. Hand C APIsList[UInt8]buffers (heap data stays put) when the string must outlive the call.- Passing a field of
selfas an immutable argument to amut selfmethod is an aliasing error (self._inspector(..., self.entities[i])); copy the value into a local first. - dcimgui
float v[3]parameters are generated asArray[Float32, 3]by value and cannot be written back; use scalar widgets. - ImGui enum constants have their own types (
ImGuiCond_); the bindings' parameters areInt32, so wrap them:Int32(ImGuiCond_FirstUseEver). - Every field must be initialised before any method call in
__init__(self.scenehad to be set before_create_offscreen_target()logged throughself).
Allocation facts measured in Phase 8 (verified 2026-11-09 with libcinder_alloccount) #
- Mojo heap allocations go through
KGEN_CompilerRT_AlignedAllocinlibKGENCompilerRTShared, served by the runtime's own TCMalloc overmmap: glibcmallochooks never see them. Count them by interposing that entry point (LD_PRELOAD,dlsym(RTLD_NEXT)). String("literal"), t-string formatting of short strings, small-String copies,Optional[Int]assignment andas_c_string_span()on short strings do not allocate;Listgrowth does (andList[...] = [x]literals allocate every time).List.clear()keeps capacity, so clear-and-refill scratch fields are allocation-free after warm-up;resize(n, fill)only allocates when growing.- A
Listmoved out of a struct and back (var p = passes.pop(); ...; spares.append(p^)) does not allocate, which is how the frame graph recycles its pass records. Array[T, N]locals are stack storage: use them for small fixed-size per-call data (cascade splits, frustum corners, render-pass attachments) instead ofList.- Passing
self.fieldmutably to a method ofselfis an exclusivity error; give the method the field's name and let it touchself.fielddirectly, or keep the scratch in the caller.
Phase 9 (defect sweep) #
- Stale compile cache. The compiler's module cache under
.pixi/envs/default/share/max/cacheoccasionally served an earlier version of an edited module (seen after reverting and restoring a file, and once as "value has no attribute" for a method that was present in the source).tools/run_tests.shdeletes the cache before compiling; delete it by hand when a build result contradicts the source. - Self-aliasing with handles.
self.method(self.handle_field)wheremethodtakesmut selfis the same exclusivity error as with lists: copy the handle into a local first (handles areImplicitlyCopyable). String(from_utf8_lossy=...)/String(from_utf8=...)build Strings from byte spans; never rebuild text one byte at a time withchr(Int(byte)), which double-encodes multi-byte UTF-8. C strings go throughcinder.core.cstring.string_from_c.