REFERENCE.md raw

Moxie Language Reference

Moxie is a compiled systems language for domain-isolated event-driven programs. Programs compile to static native binaries or WebAssembly. Source files use the .mx extension and are UTF-8 encoded.

This document specifies the Moxie language. Familiarity with C-family syntax is assumed.

For canonical formatting rules and the moxiefmt tool, see FORMAT.md.

Notation

The grammar uses EBNF. | separates alternatives. [...] denotes optional elements. {...} denotes zero or more repetitions. (...) groups.

Source Files

Each source file begins with a package declaration followed by zero or more import declarations, followed by top-level declarations.

SourceFile = PackageClause ";" { ImportDecl ";" } { TopLevelDecl ";" } .

File extension is .mx.

Platform-Specific Files

Files are included or excluded by name suffix and build constraints:

Build constraints use //go:build syntax on the first line of the file (this directive syntax is inherited and will be changed in a future release):

//go:build linux && amd64

The following build tags are always set by the compiler:

TagMeaning
moxieMoxie compiler (always set)
moxie.unicoreSingle-core cooperative execution
scheduler.noneNo preemptive scheduler

Standard platform tags (linux, darwin, amd64, arm64, js, wasm) are set according to the target.


Modules

A module is a directory tree of .mx source files rooted at a moxie.mod file. The module file declares the module path:

module example.com/myproject

Each line is a directive. The only directive currently recognized is module.


Lexical Elements

Comments

// line comment
/* block comment */

Keywords

break    case     chan     const    continue
default  defer    else    for      func
goto     if       import  interface  map
package  range    return  select   struct
switch   type     var

Identifiers

Letter or underscore followed by letters, digits, or underscores. Unicode letters are allowed.

All identifiers are accessible cross-package. There is no access control based on case - any package can reference any identifier in any other package.

An identifier is part of the stable API contract if its first character is a UTF-8 uppercase letter (Lu category) or if it carries the # prefix. This includes ASCII A-Z and uppercase letters from any script with case distinction.

For scripts without case distinction (CJK, Arabic, Hebrew, Thai, etc.), the # prefix marks an identifier as part of the stable API:

func #计算(x, y int32) (sum int32) {    // stable API
    sum = x + y
    return
}

func 内部处理() (r int32) {              // accessible, but no stability guarantee
    r = 0
    return
}

The # is an annotation, not part of the identifier name. #foo and foo are the same identifier - declaring both in the same scope is a redeclaration error. The # only appears at the declaration site. Cross-package references use the bare name:

import "example/math"
result := math.计算(1, 2)

Removing or changing the signature of a stable API symbol (Capital or #) without bumping the major version in moxie.mod is a compile error when building against a cached .mxh. Lowercase symbols carry no stability guarantee and may change freely between versions.

Predeclared Identifiers

The following identifiers are predeclared and may not be used as variable, function, type, or constant names at any scope. Shadowing a predeclared identifier is a compile error.

Types:

bool    byte    error   float32   float64
int8    int16   int32   int64     rune
string  uint8   uint16  uint32    uint64
comparable

Constants:

true    false   nil     iota

Functions:

cap     clear   close   copy    delete  len
max     min     panic   pop     print   println
push    recover resize  spawn

No Scope Shadowing

Redeclaring an identifier that is already in scope is a compile error. This applies at every level - a variable declared in a function body cannot be redeclared in an if, for, switch, or any nested block:

func f() {
    x := 1
    if true {
        x := 2         // compile error: x already declared in enclosing scope
    }
    for i := range s {
        i := i + 1     // compile error: i already declared in this scope
    }
}

To update a variable in a nested scope, assign to it:

func f() {
    x := 1
    if true {
        x = 2          // legal: assignment, not redeclaration
    }
}

This rule eliminates an entire class of bugs where an inner declaration silently hides an outer one, and the programmer reads the outer name while the compiler reads the inner one.

Operators and Punctuation

+    &     +=    &=     &&    ==    !=    (    )
-    |     -=    |=     ||    <     <=    [    ]
*    ^     *=    ^=           >     >=    {    }
/    <<    /=    <<=    <-    :=    ...   ;    :
%    >>    %=    >>=    &^    &^=         .    ,
~    !

The | operator has dual meaning depending on operand types:

ContextMeaning
Integer operandsBitwise OR (standard)
Slice or string operandsConcatenation - returns a slice containing elements from both operands

The + operator is not defined on string or slice types. Use | for all text and slice concatenation:

msg := "hello " | name | "!"
s |= " suffix"
combined := sliceA | sliceB

The ... token is valid in variadic parameter declarations (func f(args ...T)) and in variadic call sites (f(slice...)). append does not exist - use push(s, v...) for element insertion and a | b for slice concatenation.

Integer Literals

42        // decimal
0600      // octal (leading zero)
0o600     // octal (explicit prefix)
0xBadFace // hexadecimal
0b101010  // binary
1_000_000 // underscores for readability

Floating-Point Literals

3.14
.5
1e10
1.5e-3
0x1p10    // hexadecimal float

Rune Literals

'a'
'\n'
'\x41'
'A'
'\U00000041'

Rune literals have type rune (alias for int32). A rune is strictly a Unicode code point value, not a byte or text fragment. rune.String() returns a hexadecimal representation (e.g. 'a'.String() returns "0x0061"), not a UTF-8 encoding. To produce the UTF-8 byte sequence for a rune, use unicode/utf8.AppendRune or unicode/utf8.EncodeRune.

Appending a rune directly to a string or []byte with | is a compile error. The programmer must explicitly convert the rune to its UTF-8 encoding first.

String Literals

"hello\nworld"    // interpreted string literal
`hello\nworld`    // raw string literal (backslash is literal)

String literals produce values of type string. String constants are stored as UTF-8 encoded byte sequences. The literal "hello" is five bytes of UTF-8; the literal "é" is two bytes (0xc3 0xa9). To tokenize a string into runes or process characters beyond raw bytes, iterate with unicode/utf8.

The string keyword is a representation pragma - it is the same type as []byte at the language level, but on the WASM target the compiler uses the source spelling to choose between a native JS string (string) and a byte-array object ([]byte). See WASM String Pragma.

Slice Size Literals

buf := []byte{:1024}          // slice of length 1024
table := []int32{:0:100}      // slice of length 0, capacity 100

The leading : after { distinguishes size literals from composite literals. This is the only way to allocate a slice by size.

Channel Literals

ch := chan int32{}             // unbuffered channel
ch := chan int32{10}           // buffered channel, capacity 10

This is the only way to create channels.

Composite Literals

p := Point{X: 1, Y: 2}
s := []int32{1, 2, 3}
m := map[string]int32{"a": 1, "b": 2}

Types

Boolean

bool

Values: true, false.

Numeric Types

TypeSizeDescription
int81 byteSigned 8-bit integer
int162 bytesSigned 16-bit integer
int324 bytesSigned 32-bit integer
int648 bytesSigned 64-bit integer
uint81 byteUnsigned 8-bit integer
uint162 bytesUnsigned 16-bit integer
uint324 bytesUnsigned 32-bit integer
uint648 bytesUnsigned 64-bit integer
float324 bytesIEEE 754 32-bit float
float648 bytesIEEE 754 64-bit float
byte1 byteAlias for uint8
rune4 bytesAlias for int32

int and uint are illegal types - they do not exist in Moxie. Always use explicit widths (int32, int64, uint32, uint64). Slice/array indexes and len()/cap() return int32.

uintptr is available only in packages that import "unsafe". It is an alias for uint64.

Complex number types do not exist.

Builtin Type Stringers

All builtin numeric and boolean types implement String() string, satisfying fmt.Stringer. This is how fmt works without reflection or empty interfaces - fmt variadics use ...Stringer not ...any/...interface{}, and each argument's .String() method is called directly:

n := int32(42)
msg := "count: " | n.String() | " items"

Output formats:

TypeExampleOutput
booltrue.String()"true"
int8..int64int32(42).String()"42"
uint8..uint64uint64(255).String()"255"
float32, float64float64(3.14).String()"+3.14000000000000e+000"
bytebyte(0xff).String()"ff" (hex pair)
rune'a'.String()"0x0061" (hex code point)
string"hi".String()"hi" (identity)

Untyped constants resolve to their maximal typed equivalent for .String(): UntypedBool to bool, UntypedInt to int64, UntypedFloat to float64, UntypedRune to rune, UntypedString to string.

Text Type

string and []byte are the same type. They have identical runtime layout (pointer, length, capacity), are mutually assignable, and are interchangeable everywhere. There is no immutable string type - all text is a mutable byte slice.

name := "hello"              // type is string (= []byte)
var s string = []byte{72}    // direct assignment, no conversion
b := []byte("world")         // no-op, same layout
s = b                        // no copy

The keyword string is retained as a representation pragma. Use string in signatures and struct fields for readability and correct WASM codegen:

func greet(name string) (msg string) {
    return "hello " | name
}

range over a string yields individual bytes, not runes. To iterate over UTF-8 code points, use unicode/utf8.DecodeRune or a rune iterator.

Array Types

Fixed-size, value-type semantics.

var a [4]int32
b := [3]string{"x", "y", "z"}

Slice Types

Dynamic-length view over a backing array.

var s []int32
buf := []byte{:1024}          // allocate with size literal
table := []int32{:0:100}      // length 0, capacity 100
items := []string{"a", "b"}   // composite literal

Slice Equality

Any slice with a comparable element type supports == and !=. Two slices are equal if they have the same length and identical elements:

a := []int32{1, 2, 3}
b := []int32{1, 2, 3}
println(a == b)               // true

Slices of comparable types can be used as map keys:

m := map[string]int32{}
m["key"] = 42

Slice Concatenation

The | operator concatenates two slices of the same type:

a := []int32{1, 2, 3}
b := []int32{4, 5}
c := a | b                    // []int32{1, 2, 3, 4, 5}
s := "hello" | " world"
s |= "!"

If the left operand has sufficient capacity, | copies the right operand into the existing backing array. Otherwise it allocates a new backing array. The right operand is never modified.

Struct Types

type Point struct {
    X, Y int32
}

Multiple fields of the same type can share a declaration line with comma-separated names:

type Rect struct {
    X, Y, W, H float64
    Label       string
}

Embedding, tags, and anonymous fields are supported:

type Named struct {
    Point              // embedded
    Label string `json:"label"`
}

Pointer Types

p := &Point{X: 1, Y: 2}     // *Point
var q *int32

The & operator takes the address of any addressable value. Use &T{} or declare a variable and take its address:

p := &MyStruct{Field: value}

Function Types

First-class values. Closures, variadic parameters, and multiple return values are supported.

type Handler func(req Request) Response

fn := func(x, y int32) (sum int32) { return x + y }

Interface Types

Interfaces define method sets. Type assertions, type switches, and dynamic dispatch work as expected.

type Reader interface {
    Read(p []byte) (n int, err error)
}

The error interface is predeclared:

type error interface {
    Error() (msg string)
}

comparable is a predeclared interface constraint satisfied by all comparable types.

Empty Interfaces Are Banned

interface{} and any are compile errors. Every interface must declare at least one method:

var x any              // compile error
var y interface{}      // compile error

type Box struct {
    Value interface{}  // compile error
}

If a function needs to accept multiple concrete types, use a named interface with a method, or use a type switch over the known set of types with a populated interface:

type Stringer interface {
    String() (s string)
}

func display(v Stringer) {
    println(v.String())
}

Empty interfaces erase type information. Moxie requires that the type is always known - either statically at the call site or via an interface that constrains the value to types with specific behavior. "Accepts anything" is not a type contract.

Interface satisfaction is structural - a type satisfies an interface if it has all the required methods. There is no explicit implements declaration. To verify at compile time that a type satisfies an interface, use a typed nil assertion at package scope:

var _ Reader = (*MyReader)(nil)

This is zero-cost (optimized away) and fails at compile time if *MyReader does not implement Reader.

Map Types

Hash maps with comparable key types and arbitrary value types.

m := map[string]int32{}
m["key"] = 42
v, ok := m["key"]
delete(m, "key")

Map iteration is deterministic. range over a map visits keys in ascending lexicographic order of their encoded representation. This means identical maps produce identical iteration sequences across runs and across machines.

Channel Types

chan T          // bidirectional
chan<- T        // send-only
<-chan T        // receive-only

Channels are the primary synchronization mechanism within a domain.


Declarations and Scope

Package Scope Rules

Package-level declarations follow strict rules:

Allowed at package scope:

Not allowed at package scope:

Zero-value var declarations are static storage placed by the linker in the .bss segment. No runtime code executes. Any package-scope variable that requires a non-zero initial value must be explicitly assigned in the package's init() function.

Constant Declarations

const Pi = 3.14159
const (
    A = iota    // 0
    B           // 1
    C           // 2
)
const Size int32 = 256

iota is a monotonically incrementing counter within a const block. It starts at 0 and advances by 1 for each constant specification. The expression applied to iota is inherited by subsequent lines, so shifting, masking, and arithmetic expressions all work:

const (
    FlagRead  = 1 << iota  // 1
    FlagWrite              // 2
    FlagExec               // 4
)

const (
    _        = iota        // skip 0
    KB int64 = 1 << (10 * iota)  // 1024
    MB                           // 1048576
    GB                           // 1073741824
)

Untyped constants (no explicit type) have unlimited precision and are narrowed to the required type at use sites.

Type Declarations

type Duration int64
type Point struct { X, Y float64 }
type Reader interface { Read(p []byte) (n int, err error) }

Named types may only be declared over value types (numeric, bool, string, array, struct) and interface types. Named slice types and named map types are compile errors:

type Events []Event          // compile error
type Index map[string]int32  // compile error

Slices and maps are reference types - a named wrapper over a reference type creates a receiver that cannot mutate its own elements through closures, forces every method to take a pointer receiver for append semantics, and produces ambiguous ownership at every call site. Methods belong on the struct that holds the slice, not on the slice itself.

Variable Declarations

At package scope, only zero-value declarations:

var counter int32
var buf []byte
var ready bool

Inside functions, full declaration syntax is available:

var x int32 = 42
y := compute()

Variables are allocated at the point of declaration in execution order. There is no hoisting. See Memory Management for lifetime rules.

No declaration (var or :=) may reuse a name that is already visible in the current or any enclosing scope. See No Scope Shadowing.

Short Variable Declarations

x := 42
name, err := readName()

Available only inside functions. The same no-shadowing rule applies - every name on the left side of := must be new to the current scope and not declared in any enclosing scope.

Function Declarations

All return values must be named. Unnamed return values are a compile error. The names document what the function produces and are visible at the call site in documentation and tooling - without them the meaning of each return position is hidden inside the function body:

func add(x, y int32) (sum int32) {
    sum = x + y
    return
}

func swap(a, b int32) (first, second int32) {
    return b, a
}

func greet(names ...string) {
    for _, n := range names {
        println(n)
    }
}

func read(p []byte) (int, error) {    // compile error: unnamed return values
    // ...
}

Named return values are implicit variable declarations at the top of the function scope, initialized to zero. They are subject to the no-shadowing rule - no parameter, local variable, or nested declaration may reuse a return value name. A bare return statement returns their current values. An explicit return x, y assigns to the named returns and then returns.

Method Declarations

All methods must use pointer receivers. Value receivers are a compile error.

func (p *Point) Translate(dx, dy float64) {
    p.X += dx
    p.Y += dy
}

func (p Point) String() string {   // compile error: value receiver
    return "..."
}

A type with methods is state. The receiver is a reference to that state, not a copy of it. Value receivers create a hidden copy that silently discards mutations, splitting the caller's mental model between "this method changes the object" and "this method pretends to". Pointer receivers make the reference explicit and uniform - every method call is a mutation site or it isn't a method.


Packages

Package Declaration

Every source file starts with a package clause:

package mypackage

A package main declaration defines an executable program. The entry point is func main().

Import Declarations

import "fmt"
import (
    "bytes"
    "io"
)
import alias "some/package"

import "strings" is a compile error. Use import "bytes" - since string and []byte are the same type, the bytes package handles all text operations.

The moxie Package

The moxie package is force-imported by the compiler (like runtime). It provides the Codec interface and built-in codec wrapper types for spawn boundary serialization. User code imports moxie explicitly when using Codec types.

init Functions

Library packages (non-main) may declare at most one init function:

func init() {
    // package initialization - assign non-zero values to package vars
}

Rules:

select statements. It runs during the sequential package initialization phase before the program's event loop starts. No event dispatch or inter-domain communication is possible during init.

dependency order.

The primary purpose of init() is to assign non-zero initial values to package-scope variables, since var X = expr at package scope is not allowed:

var defaultTimeout int64

func init() {
    defaultTimeout = 5000
}

Expressions

Operands

Operand = Literal | OperandName | "(" Expression ")" .

Primary Expressions

x
x.f            // field or method
a[i]           // index
s[lo:hi]       // slice
s[lo:hi:max]   // full slice
f(args)        // function call
T(x)           // type conversion
x.(T)          // type assertion

Type Assertions

v := x.(int32)          // panics if x is not int32
v, ok := x.(int32)      // ok is false if x is not int32

Type Switches

switch v := x.(type) {
case int32:
    println(v)
case string:
    println(v)
default:
    println("unknown")
}

Operators

Unary operators:

OpMeaning
+Numeric identity
-Numeric negation
!Logical NOT
^Bitwise complement
~Bitwise complement (alias for ^)
*Pointer dereference
&Address-of
<-Channel receive

Binary operators (by precedence, highest first):

PrecedenceOperators
5* / % << >> & &^
4+ - \| ^
3== != < <= > >=
2&&
1\|\|

The | operator at precedence 4 performs bitwise OR on integers and concatenation on slices and strings.

The + operator is defined on numeric types only, not on strings or slices.

Conversions

x := int64(42)
f := float64(x)
s := string(buf)        // no-op (same type)
b := []byte(s)          // no-op (same type)

Conversions between string and []byte are no-ops since they are the same type.


Statements

Assignment

x = 42
x += 1
x |= " more"          // string/slice append-assign
a, b = b, a            // parallel assignment

Send Statement

ch <- value

If Statement

if x > 0 {
    println("positive")
} else if x < 0 {
    println("negative")
} else {
    println("zero")
}

if err := do(); err != nil {
    return err
}

For Statement

Three forms:

// condition-only (while loop)
for x > 0 {
    x--
}

// C-style
for i := int32(0); i < 10; i++ {
    println(i)
}

// range
for i, v := range slice {
    println(i, v)
}

// infinite loop
for {
    select { ... }
}

Range Expressions

Range overKey typeValue type
Array, sliceint32 (index)Element type
Stringint32 (byte index)uint8 (byte value)
MapKey typeValue type
ChannelElement type-
Integerint32-

range over a string yields individual bytes, not runes. To iterate over UTF-8 code points, use unicode/utf8.DecodeRune or a rune iterator.

Switch Statement

switch x {
case 1, 2:
    doOneOrTwo()
case 3:
    doThree()
default:
    doOther()
}

Each case is self-contained. There is no fallthrough statement. To handle multiple values with shared and distinct logic, use comma-separated case expressions with conditional subdivision inside the case body:

switch x {
case 1, 2, 3:
    doCommon()
    if x == 1 {
        doSpecialForOne()
    }
case 4:
    doFour()
}

Expression switches and type switches are both supported.

Select Statement

select is the central event dispatch mechanism of Moxie programming. It is bidirectional - it can both send and receive on channels in the same statement. All event-driven control flow within a domain reduces to select:

select {
case v := <-ch1:
    handle(v)
case ch2 <- x:
    // sent
case <-timer:
    // timeout
default:
    // no channel ready
}

Cases are checked in the order they are written, top to bottom. If multiple channels are ready simultaneously, the first ready case wins. This is deterministic - there is no random selection among ready cases.

select multiplexes channel messages, I/O events, and timer expirations into a single event loop. The idiomatic Moxie program structure is `for { select {...} }` at the core of each domain. Case ordering determines priority - put higher-priority channels first.

select must not appear inside init() functions. Event dispatch begins only after all packages are initialized and main() starts.

Defer Statement

defer f.Close()

Deferred calls execute in LIFO order when the enclosing function returns, after all local variables have been freed but before return values are handed to the caller.

Return Statement

return
return x
return x, nil

Branch Statements

break
break label
continue
continue label
goto label

break and continue with labels reference enclosing for loops only. goto transfers control to any label within the same function - the target label does not need to precede a for loop. A bare label (not preceding a for loop) is legal only if referenced by a goto.

Labeled Statements

Labels on for loops, referenced by break or continue:

outer:
    for i := range items {
        for j := range items[i] {
            if done(i, j) {
                break outer
            }
        }
    }

Labels for goto targets:

func process(data []byte) (err error) {
    if len(data) == 0 {
        goto cleanup
    }
    // ... processing ...
cleanup:
    release(data)
    return nil
}

Built-in Functions

FunctionSignatureDescription
pushpush(s []T, elems ...T)Appends elements to a slice in place. Panics if len+n exceeds cap. Lazy-inits nil slices. Store-back is implicit.
poppop(s []T) TRemoves and returns the last element. Panics if len == 0. Store-back is implicit.
resizeresize(s []T, n int32)Sets the slice length to n. Panics if n > cap or n < 0. Store-back is implicit.
capcap(v) int32Capacity of a slice, channel, or array
clearclear(m)Clears a map or zeroes a slice
closeclose(ch)Closes a channel
copycopy(dst, src []T) int32Copies elements between slices
deletedelete(m, key)Deletes a map entry by key
lenlen(v) int32Length of a string, slice, array, map, or channel
maxmax(x, y...) TMaximum of comparable values
minmin(x, y...) TMinimum of comparable values
panicpanic(v error)Stops execution with an error value
printprint(args...)Prints to stderr (no newline)
printlnprintln(args...)Prints to stderr (with newline)
recoverrecover() errorCatches a panic in a deferred function
spawnspawn(fn, args...) chan struct{}Creates a new isolated domain

len and cap return int32, not a platform-dependent integer.

push/pop/resize vs append

append is a compile error in Moxie. It was removed because its semantics conflate two operations - allocation and element insertion - into one call with unpredictable behavior:

The caller cannot know which without comparing pointers.

prefix and then appends to the original may or may not corrupt the alias, depending on whether capacity happened to be sufficient.

runtime-internal; the programmer never declares intent about expected size.

push resolves this by separating concerns:

to a nil slice, it lazy-inits with a reasonable capacity (min 32). But once a slice has a capacity, exceeding it is a panic - the programmer must know the bounds.

capacity. The compiler emits the store-back to s implicitly (like i++ modifies i).

If you hit the cap, you miscalculated - fix the allocation site, don't hide the error behind growth.

pop and resize complete the set: pop is the inverse of a single-element push, and resize gives direct control over length without touching capacity.

items := []int32{:0:100}      // explicit capacity
push(items, 1, 2, 3)         // items is now [1, 2, 3], len=3, cap=100
last := pop(items)            // last=3, items is now [1, 2], len=2
resize(items, 0)              // items is now [], len=0, cap still 100

Memory Management

Moxie has no garbage collector. Memory management is deterministic and structural: every allocation belongs to an arena, and arenas are released in bulk. There is no individual free, no mark/sweep, no reference counting.

Domain Root

Each domain has a single DomainRoot structure, created once at domain init via mmap. It contains:

active arena is always fnStack[fnTop].

The root arena holds package-level zero-value var declarations and any allocations made by the program's top-level functions (main, event loops).

Arena Structure

Each arena is a bump-pointer allocator backed by one or more mmap regions. The Arena struct is embedded at offset 0 of its primary mmap region - no external allocation needed for metadata.

Arena layout:

    [Arena header | region 0 data ...............]
                   ^off                    ^cap

    If region 0 fills, a new region is mmap'd:
    regions[0]: [header | data...]
    regions[1]: [data................]
    regions[2]: [data....]

Each arena has a flat registry of up to 256 regions. No linked lists. When a region fills, a new 1MB region is mmap'd and added to the table. alloc(size) bumps the active region's offset and returns zeroed memory.

Per-Function Arena Lifecycle

Every function that allocates gets its own arena, pushed onto the domain's function arena stack. The compiler emits:

  1. Prologue: FnArenaPush(capacity) - acquires an arena from the pool

(or creates a new one via mmap) and pushes it onto fnStack.

  1. Body: all allocations go into this function's private arena.
  2. Epilogue: return values are relocated into the caller's arena via a

three-pass mark-compact algorithm, then the function's arena is popped and released back to the pool.

func buildPair(x, y int32) (result *Pair) {
    // compiler-emitted: FnArenaPush(65536)
    result = &Pair{X: x, Y: y}
    // compiler-emitted: relocate result into caller's arena
    // compiler-emitted: FnArenaPop + ArenaRelease
    return
}

The relocation pass walks the return value's type graph, identifies pointers into the function's arena, allocates fresh copies in the caller's arena, and patches all internal pointers. After relocation, the function's entire arena is released - either returned to the pool for reuse, or munmap'd if the pool is full.

Arena Pool

A bounded freelist (8 entries) of reset arenas eliminates mmap/munmap syscalls for short-lived function arenas. ArenaAcquire checks the pool first; ArenaRelease resets and returns the arena to the pool if there's room.

Ownership Boundaries

Function boundaries are ownership boundaries. A function's allocations are invisible outside that function. Data escapes only through return values, which are explicitly relocated. This means:

Three boundaries exist where memory changes hands:

BoundaryMechanismDirection
Function returnRelocate into caller's arenaCallee -> Caller
SpawnCodec serialization over IPCParent -> Child
Domain exitmunmap (bulk release)Domain -> Kernel

Parameters

Function parameters are owned by the caller. The callee receives them as immutable borrows - read-only references into the caller's arena. Writing through a parameter pointer is a compile error. Methods that need to produce modified state return it; the caller assigns the result.

Between Domains

Each domain has its own arena tree. There is no shared memory between domains. Values crossing the spawn boundary are serialized via moxie.Codec - the child receives an independent copy in its own arena. When a domain exits, munmap releases all its address space to the kernel in one operation.

Long-Running Event Loops

The per-function arena model means the outermost function of an infinite event loop (for { select { ... } }) never returns and never frees its arena. Allocations inside the loop body accumulate unless managed:

  1. Factor allocation-heavy work into helper functions - their arena is

released on return.

  1. delete(map, key) when entries are done.
  2. slice = nil releases the backing array reference.
  3. Monotonically growing maps need rotation or bounded-size policies.
func eventLoop(ch chan Request) {
    for {
        select {
        case req := <-ch:
            handleRequest(req)  // allocations in own arena, freed on return
        }
    }
}

spawn

done := spawn(fn, arg1, arg2, ...)

spawn creates a new domain - an isolated execution context with its own single-threaded event loop and memory space. On native targets, the domain is an OS process created via fork(). On the WASM target, the domain is a Web Worker.

spawn is a language builtin. No import is required.

spawn must not appear inside init() functions. Domains can only be created after all packages are initialized and main() starts.

Arguments

The first argument must be a static function name (not a variable, not a closure). Remaining arguments are passed to that function in the child domain. The return value is chan struct{} - a lifecycle channel that closes when the child exits.

import "moxie"

func worker(id moxie.Int32, scale moxie.Float64) {
    println(float64(id) * float64(scale))
}

func main() {
    done := spawn(worker, moxie.Int32(1), moxie.Float64(2.5))
    _ = done
}

The compiler verifies at compile time:

Spawn Boundary Rules

The spawn boundary is a serialization boundary. Every argument is serialized into the child's address space via moxie.Codec. There is no shared memory between domains.

All data arguments and channel element types must implement moxie.Codec:

type Codec interface {
    EncodeTo(w io.Writer) (err error)
    DecodeFrom(r io.Reader) (err error)
}

Built-in Codec Types

The moxie package provides codec wrappers for all primitive types. Default encoding is little-endian. Big-endian aliases exist for network protocols:

Default (LE)Big-endian (BE)Size
moxie.Bool, moxie.Int8, moxie.Uint8-1 byte
moxie.Int16, moxie.Uint16moxie.BigInt16, moxie.BigUint162 bytes
moxie.Int32, moxie.Uint32moxie.BigInt32, moxie.BigUint324 bytes
moxie.Int64, moxie.Uint64moxie.BigInt64, moxie.BigUint648 bytes
moxie.Float32moxie.BigFloat324 bytes
moxie.Float64moxie.BigFloat648 bytes
moxie.Bytes-4-byte LE length prefix + data

User-defined structs implement Codec by defining EncodeTo/DecodeFrom methods that serialize each field.

Allowed Types at the Boundary

TypeTreatment
Types implementing moxie.CodecSerialized via EncodeTo/DecodeFrom
*T where T implements CodecCopy semantics - pointer dissolves at the wire, receiver gets a fresh allocation
Channels (element type must implement Codec)Become IPC channels over socketpair (native) or MessagePort (WASM)

Rejected Types (compile error)

TypeReason
Raw built-in types (int32, bool, etc.)Use moxie.Int32, moxie.Bool, etc.
func(...)Cannot serialize code
Interface typesInterfaces cannot cross the spawn boundary; use concrete Codec types
Raw pointers to non-Codec typesCannot share memory across domain boundary

Move Semantics

Non-constant values passed to spawn are moved to the child domain. Using the variable after spawn is a compile error:

x := compute()
spawn(worker, x)
println(x)            // compile error: variable used after spawn

Exceptions:

Channel Completeness

Every channel created in a function must have both a sender and a listener (receive or select case). Channels that escape the function (passed to calls, returned, stored in structs) are exempt.

ch := chan int32{}
ch <- 42              // compile error: channel has no listener

IPC Channels

Channels passed to spawn become IPC channels. The runtime multiplexes them over a single socketpair with length-prefixed messages. All spawn channel operations are non-blocking with retry-and-yield (1ms sleep, indefinite retry until success or pipe close). Both sides of the socketpair are symmetric peers. Backpressure is expressed by pipe buffer fullness, not by blocking.

Native Implementation

  1. socketpair(AF_UNIX, SOCK_STREAM) creates a bidirectional IPC pipe.
  2. fork() creates the child process (copy-on-write memory).
  3. Child closes parent's socket end, runs the function directly, exits on

completion.

  1. Parent closes child's socket end, registers domain (pid + fd), continues.

Each domain has its own single-threaded event loop and memory space. A child that exits unexpectedly is reaped on the next scheduler tick.

WASM Implementation

  1. A new Worker is created with an isolated microtask context.
  2. Slices are element-copied, maps are shallow-copied.
  3. Each domain gets its own event loop.
  4. IPC uses BroadcastChannel for inter-domain messaging.

Execution Model

Program Startup

  1. All packages are initialized in dependency order. For each package, the

zero-value var declarations establish static storage, then init() (if present) runs.

  1. main() in the main package is called. This is where event dispatch

begins - select and spawn are available from this point forward.

Domains

A Moxie program is a tree of domains. Each domain is an OS process (native) or Worker (WASM) running a single thread.

+-------------------------------------+
|              Program                |
|  +----------+     +----------+     |
|  | Domain 0  |----| Domain 1  |     |
|  | (parent)  | IPC| (child)   |     |
|  |           |sock|           |     |
|  |  select   |pair|  select   |     |
|  |  +- ch1   |    |  +- ch3   |     |
|  |  +- ch2   |    |  +- timer |     |
|  |  +- I/O   |    |           |     |
|  +-----------+    +-----------+     |
|  single thread    single thread     |
+-------------------------------------+

Concurrency Within a Domain

Execution flow within a domain is controlled by three constructs:

ConstructBehavior
Unbuffered channel sendExecution transfers to the waiting select case. Like a function call via the channel.
Buffered channel sendMessage queued for the next select iteration.
selectEvent handler - blocks until a channel message, I/O event, or timer fires.

Because there is exactly one thread per domain, there are no data races. Mutexes are no-ops. Atomics are plain loads and stores.

Concurrency Between Domains

spawn creates child domains. Communication happens exclusively through IPC channels. Values are deep-copied across the boundary via Codec serialization. The parent and child cannot observe each other's memory.

This is not a relaxed memory model - it is no shared memory. The answer to "when does domain A see domain B's write?" is: when B sends it on a channel and A receives it.

I/O Model

All I/O is non-blocking and buffered. Each domain uses epoll (Linux) or kqueue (Darwin) for I/O readiness notification. No I/O operation blocks the domain's thread - all I/O is dispatched through the event loop via select.

Runtime

The runtime provides:

(deferred I/O callbacks).

Unbuffered channels transfer execution directly. Buffered channels use a circular ring buffer.

loop, not blocking the domain.

Stack overflow detected via canary values.

Targets

TargetOutputLibc
linux/amd64Static ELFmusl
linux/arm64Static ELFmusl
darwin/amd64Mach-OlibSystem
darwin/arm64Mach-OlibSystem
js/wasmWASM + JS runtime-

All native binaries are fully statically linked. No dynamic library dependencies at runtime.

The WASM target outputs ES modules with a runtime library. Browser APIs (DOM, WebSocket, IndexedDB, Service Workers) are accessible via bridge packages in jsruntime/.

JS Bridge Packages

BridgePurpose
domElement creation, tree manipulation, events
wsWebSocket dial/send/close
swService Worker lifecycle, fetch/cache, SSE
localstoragelocalStorage operations
idbIndexedDB transactions
cryptosecp256k1 (schnorr / BIP340) via WASM
subtleSubtleCrypto bridge

JS Runtime Shims

Helper modules in jsruntime/ used as WASM host shims. Not importable from Moxie source; wired into WASM bridge code:

ShimPurpose
aead.mjsXChaCha20-Poly1305
p256.mjsNIST P-256 ECDH / ECDSA
ed25519.mjsEd25519 signatures
x25519.mjsX25519 ECDH
poly1305.mjsPoly1305 MAC

Stdlib Crypto

Pure Moxie implementations that compile on both native and WASM:

PackagePurpose
crypto/ec/secp256k1Bitcoin curve operations
crypto/ec/schnorrBIP340 Schnorr signatures
crypto/ec/bech32Bech32 / Bech32m address encoding
crypto/ec/ecdsasecp256k1 ECDSA
crypto/ec/chainhashDouble-SHA-256 helpers
encoding/base58base58 / base58check encoding

External Dependencies

moxie build is network-free. It reads .mxh protocol headers from the cache to type-check against external packages, but never fetches source at build time.

Dependencies are installed separately:

moxie fetch                            # install all deps from moxie.mod
moxie install git.example.com/somepkg  # install a single package

Protocol Headers (.mxh)

A .mxh file describes a package's codec types - types that implement moxie.Codec, their wire layout, and method signatures. No implementation, no unexported types.

Format:

// mxh v1 <fnv1a-hex-hash>
// wire: uint32-length-prefixed EncodeTo/DecodeFrom at cross-binary spawn boundaries
type Int32 int32
func (*Int32) EncodeTo(w io.Writer) (err error)
func (*Int32) DecodeFrom(r io.Reader) (err error)

The hash changes when any type layout changes. The compiler embeds this hash at cross-binary spawn sites and the runtime verifies it at spawn time.

Cross-Binary spawn

When a package is imported from the .mxh cache, spawn forks and execs the cached binary instead of forking the current process:

import iskradb "git.example.com/iskradb"

// same syntax whether iskradb is local source or cached .mxh
done := spawn(iskradb.InsertTriple, key, val, results)

At spawn time, parent and child exchange .mxh hashes (5s timeout). Hash mismatch means the installed binary is stale - run moxie install again.


Runtime Guarantees

  1. Single-threaded per domain. No preemption.
  2. No data races. Single-threaded execution makes races structurally

impossible.

  1. Complete domain isolation. fork() provides OS-level memory isolation.
  2. Deterministic execution. Given the same inputs, execution follows one

path.

  1. Static binaries. No dynamic dependencies. Ship one file.

WASM-Specific Notes

WASM String Pragma

In the WASM target, the compiler preserves the source spelling of string vs []byte as a representation pragma. string tells the code generator to use a native JS string at the WASM/JS boundary. []byte tells it to use a byte-array object.

A []byte field receiving text at the WASM boundary will corrupt multi-byte UTF-8 sequences (CJK, emoji) silently, because the JS runtime treats it as a raw byte buffer rather than encoded text.

Declare all text-holding struct fields as `string` when targeting WASM.

This is not a type distinction - string and []byte remain the same type and are mutually assignable. The spelling is a hint to the code generator only.

Quick Reference

FeatureMoxie
File extension.mx
Module filemoxie.mod
Text typestring = []byte, mutable, string is a WASM representation pragma
Text concatenation\| operator
Integer sizesExplicit widths only; int/uint are illegal
ConcurrencyChannels + select within a domain, spawn for new domains
Process isolationspawn(fn, args...) via fork() + socketpair
Serializationmoxie.Codec interface at spawn boundary
Memory modelPer-function arena (push/pop stack), relocate on return, bulk release at domain exit
Domain memoryNo shared memory between domains
Slice equality== for any comparable element type
Slice literals[]T{:n}, []T{:n:c}
Channel literalschan T{} (unbuffered), chan T{n} (buffered)
Named slice/map typesCompile error
Value receiversCompile error; pointer receivers only
Unnamed return valuesCompile error; all returns must be named
any / interface{}Compile error; interfaces must have methods
Scope shadowingCompile error; no redeclaration of visible names
Package scope varsvar X T only; var X = expr is a compile error
init()Library packages only, max one, no select or spawn
Targetslinux/amd64, linux/arm64, darwin/amd64, darwin/arm64, js/wasm