agra 2b1307a0dc feat: reclaim fiber + async heap (close the closure-env / Future leaks)
Closes the documented per-spawn closure-env leak and most of the async leak,
using only the existing closure.env / closure.fn_ptr field accessors — no compiler
change. Also names the fat-pointer ABI in core.sx (ClosureRaw / SliceRaw) so the
underlying {fn_ptr, env} / {ptr, len} layout is discoverable in one place.

- Fiber body env: Scheduler.reap_fiber frees f.body.env via f.dctx.allocator (the
  spawn-time allocator snapshotted in dctx) at all three reap sites (run/poll/
  deinit). 1820's 'live after deinit' 3 -> 0.
- Async box + closure envs: sx_run_boxed_closure frees the ThunkBox, the
  completion-closure env, and the worker's env (new ThunkBox.worker_env) the
  instant the worker completes.
- Async Future: two-flag ownership — Future.worker_done (set at the end of the
  completion closure) + consumed (set at the end of await); fut_release frees the
  heap Future (via the captured Future.alloc) when BOTH are set, so the LAST of
  {worker, await} reclaims it. await now CONSUMES the future (single-use; touching
  it afterward is a use-after-free — documented). Residual for an AWAITED future
  is 0 (lock: examples/concurrency/1827); a never-awaited future (fire-and-forget /
  race loser) keeps only its Future struct — the structured-concurrency remainder.

Self-reviewed across orderings (await-after/before-complete, cancel-then-await,
cancel-while-parked, double-free via await+deinit, race residual, blocking impl,
cross-allocator reap) — all deterministic, no UAF/double-free. Suite 855/0;
byte-identical on aarch64-macOS + aarch64-linux; .ir churn is the core.sx +
Future/ThunkBox field additions.
2026-06-28 16:19:04 +03:00
2026-06-15 13:39:39 +03:00
2026-02-09 18:07:41 +02:00
2026-06-17 09:58:43 +03:00
2026-02-09 18:07:41 +02:00

sx

A programming language with compile-time execution, generics, closures, protocols, and an LLVM backend — compiled to fast native code.

At a Glance

#import "modules/std.sx";

Point :: struct {
    x, y: i32;
    magnitude :: (self: *Point) -> f32 { sqrt(self.x * self.x + self.y * self.y); }
}

main :: () {
    p := Point.{ x = 3, y = 4 };
    print("point: {}, magnitude: {}\n", p, p.magnitude());
}

Highlights:

  • Clean declaration syntax: name :: value for constants, name := value for variables
  • Compiles to native code via LLVM
  • Compile-time execution with #run and code generation with #insert
  • Generics via monomorphization
  • First-class closures with value capture
  • Protocol-based polymorphism (traits) with optional inline dispatch
  • Pattern matching on enums, optionals, and type categories
  • C interop via extern / export and #import c
  • Inline assembly as a first-class expression
  • Colorblind async via a pure-sx cooperative fiber runtime (no function coloring)
  • Targets: macOS (ARM64, x86_64), Linux (x86_64, ARM64), Windows (x86_64), WebAssembly

Usage

sx run file.sx           # compile and run
sx build file.sx         # compile to binary
sx build file.sx -o out  # compile with output path
sx ir file.sx            # emit LLVM IR
sx lsp                   # start language server

Options:

--target <triple>   target platform (shortcuts: macos, linux, windows, wasm)
--opt <level>       optimization: none, less, default, aggressive
--cpu <name>        target CPU
-o <path>           output path

Language Overview

Types

Type Description
i8..i64, u8..u64 Signed/unsigned integers (default: i64)
f32, f64 Floating point (default: f32)
bool true / false
string UTF-8 fat pointer {ptr, len}
[N]T Fixed-size array
[]T Slice (fat pointer)
*T, [*]T Single / many pointer
?T Optional
struct, enum, union Composite types
Closure(args) -> ret Closure type

A fixed array [N]T coerces to a slice []T (its length is known); a [*]T many-pointer carries no length, so slice it explicitly with ptr[0..len].

Storing a value into a typed slot (a :-annotated binding, a field, an array element, a deref, an assignment target) requires a coercion to exist. A value with no coercion to the slot type and a different byte width — e.g. x : i32 = "hi" — is a compile error rather than a silent reinterpreting store. Same-width reinterpretations (*T → [*]T, i64 → isize) are allowed, and an explicit xx / cast(T) is always the escape hatch for a deliberate reinterpretation.

Numeric limits. A field access on a builtin integer type folds to a compile-time constant: i64.max, u8.min, [u8.max]T (a 255-element array). Floats expose .min / .max plus .epsilon, .min_positive, .true_min, .inf, and .nan. See specs.md → Numeric Limits.

Declarations

// Constants (compile-time when possible)
PI :: 3.14159;
MAX : i32 : 100;

// Variables (mutable)
x := 42;               // inferred type
y : i32 = 0;           // explicit type
z : i32 = ---;         // uninitialized

A typed constant's initializer must be compatible with its annotation (checked at compile time for both literals and constant expressions). Mixed int+float arithmetic promotes to float in either operand order.

Aggregate constants. Array- and struct-typed :: constants are immutable globals — one storage, reads index directly, whole-value uses copy by value, unused tables are dropped from the binary. :: is the one and only const spelling:

K : [4]i64 : .[11, 22, 33, 44];   // typed array const
A :: .[1, 2, 3];                  // untyped — infers [3]i64
M :: .[1, 2.2, 3];                // numeric mix promotes — [3]f64
LIT :: Color.{ r = 255, g = 0, b = 0 };   // struct const

N :: K[0] + K[3];     // 55 — const element reads fold at compile time
D : [K.len]u8 = ---;  // .len folds in dimensions too
K[0] = 5;             // error: cannot assign through constant 'K'

Writes through a constant's name are compile errors; a local copy (k := K) stays writable.

Float → integer narrowing. A float flowing into an integer binding without a cast must be integral: an integral compile-time float folds to its integer, a non-integral one is a compile error (y : i64 = 4.04; y : i64 = 1.5 errors). This is uniform across locals, defaults, arguments, constants, and array dimensions. An explicit xx / cast(i64) is the escape hatch and always truncates.

Reserved names. Builtin type names (i32, u8, bool, string, …) can't be used bare as identifiers at value-binding or declaration sites. Member positions (struct fields, union tags, protocol methods) are exempt, as is any name after a leading .. A leading backtick escapes one into a raw identifier (`i2), usable in every position:

`i2 := 2.5;                  // identifier "i2", distinct from the i2 type
`i2 :: struct { x: i64; }    // a type named with a reserved spelling
v : `i2 = ---;               // referenced as a type
x : i2  = 3;                 // bare `i2` in type position is still the int type

Multiple return values

A function can return several values with a bare-paren return signature — positional -> (A, B) or named -> (x: A, y: B). The empty -> () is void, and a trailing ! is the error channel (always the last slot): -> (A, B, !). A multi-return is not a tuple value — it is a distinct return shape (so a parameter / field / variable annotation x: (A, B) is rejected; use Tuple(…) for an actual tuple value).

divmod :: (a: i64, b: i64) -> (i64, i64) {
    return a / b, a % b;          // bare comma return — no `.( … )` literal
}

stats :: (a: i32, b: i32) -> (sum: i32, big: bool) {
    return sum = a + b, big = a > b;   // named, in slot order
}

Consume the result by destructuring or by binding it once and reaching the value slots by field:

q, r := divmod(17, 5);            // q = 3, r = 2
c := stats(40, 2);               // c.sum = 42, c.big = true

For a failable multi-return, the error rides the separate ! channel — a bound value holds only the value slots, never the error:

classify :: (n: i32) -> (doubled: i32, big: bool, !) {
    if n < 0 { raise error.Bad; }
    return doubled = n * 2, big = n > 10;
}
d, b := classify(7) catch (e) { … };   // error stripped by `catch`; d, b are the values

Named returns as locals. Named slots are in-scope assignable locals; assigning them is the return (no explicit return needed). A slot may carry a default, which exempts it from the must-set rule:

combine :: (a: i32, b: i32) -> (sum: i32 = 0, good: bool) {
    good = a > b;
    sum = a + b;                  // both slots set → implicit return
}

A named slot that is not assigned on every path and has no default is a compile error (definite-assignment) — rather than returning an uninitialized value.

Structs

Vec3 :: struct {
    x, y, z: f32;

    length :: (self: *Vec3) -> f32 {
        sqrt(self.x * self.x + self.y * self.y + self.z * self.z);
    }
}

v := Vec3.{ x = 1, y = 2, z = 3 };
v2 := Vec3.{ 1, 2, 3 };              // positional
print("{}\n", v.length());

Structs support field defaults, #using for composition, and methods defined in the body.

Enums (Tagged Unions)

Shape :: enum {
    circle: f32;
    rect: struct { w, h: f32; };
    none;
}

area :: (s: Shape) -> f32 {
    if s == {
        case .circle: (r) => 3.14159 * r * r;
        case .rect: (r) => r.w * r.h;
        case .none: 0;
    }
}

Flag enums with power-of-2 values:

Perms :: enum flags { read; write; execute; }
rw := Perms.read | Perms.write;

Set a variant by construction (s = .circle(2.0)), which writes the tag and payload together. Direct member assignment to a variant (s.circle = 2.0) is rejected; mutating a sub-field of the active variant in place (s.rect.w = 9.0) is fine.

Optionals

x: ?i32 = 42;
y: ?i32 = null;

val := x ?? 0;          // null coalescing
forced := x!;           // force unwrap (traps on null)

if v := x {             // safe unwrap
    print("{}\n", v);
}

// Optional chaining
node: ?Node = get_node();
name := node?.name ?? "unknown";

// Flow-sensitive narrowing: a `!= null` guard proves the value present.
n: ?i32 = maybe();
if n != null { take_i32(n); }       // `n` is i32 here

A ?T never implicitly unwraps to T in a value position — a bare take_i32(n) without a guard, !, ??, or binding is a compile error.

Generics

max :: (a: $T, b: T) -> T {
    if a > b then a else b;
}

List :: struct ($T: Type) {
    items: []T;          // a slice; items.len is the live count, so a List is
    cap: i64;            // directly iterable: `for xs.items (e) { ... }`

    append :: (self: *List(T), item: T) { ... }

    // `#get` / `#set` property accessors: read/write via field syntax
    // (`xs.len`, `xs.len = n`) rather than method calls.
    len :: (self: *List(T)) -> i64 #get => self.items.len;
    len :: (self: *List(T), v: i64) #set { self.items.len = v; }
}

Generic constraints via protocols:

are_equal :: ($T: Type/Eq, a: T, b: T) -> bool { a.eq(b); }

Closures

make_adder :: (n: i64) -> Closure(i64) -> i64 {
    closure((x: i64) -> i64 => x + n);
}

add5 := make_adder(5);
print("{}\n", add5(100));   // 105

Closures capture by value. Bare functions auto-promote to closures when needed.

Protocols

Drawable :: protocol {
    draw :: (self: *Self, x: i32, y: i32);   // receiver is explicit + required
}

impl Drawable for Circle {
    draw :: (self: *Circle, x: i32, y: i32) { ... }
}

shape : Drawable = xx my_circle;   // type erasure via xx
shape.draw(10, 20);                // dynamic dispatch

#inline protocols store function pointers directly (no vtable indirection):

Allocator :: protocol #inline {
    alloc :: (self: *Self, size: i64) -> *void;
    dealloc :: (self: *Self, ptr: *void);
}

Pattern Matching

// On enums
if shape == {
    case .circle: (r) => print("radius: {}\n", r);
    case .rect: (r) => print("{}x{}\n", r.w, r.h);
    case .none: print("nothing\n");
}

// On optionals
if opt == {
    case .some: (val) => use(val);
    case .none: fallback();
}

// On type categories (via Any)
if type_of(val) == {
    case int: print("integer\n");
    case string: print("string\n");
    case struct: print("struct\n");
}

Control Flow

// Chained comparisons
if 0 <= x <= 100 { ... }

// While
while i < 10 { i += 1; }

// For — collections, ranges, and parallel iteration
for items (val) { print("{}\n", val); }
for items, 0.. (val, idx) { print("[{}] = {}\n", idx, val); }
for 1..=5, 0.. (a, b) { print("{}:{}\n", a, b); }   // a: 1..5, b follows
for items (val) => total += val;                    // arrow body
for 0<..<n (i) { }                                  // bound markers: 1 .. n-1
sub := items[1..=3];                                // slices take them too

// Defer
f := open("file.txt");
defer close(f);

// Multi-target assignment (atomic swap)
a, b = b, a;

Pipe Operator

result := data |> parse() |> transform() |> serialize();
// equivalent to: serialize(transform(parse(data)))

Compile-Time Execution

// Evaluate at compile time
FIBONACCI_10 :: #run fib(10);

// Generate code at compile time
#insert #run generate_lookup_table();

C Interop

libc :: #library "c";
printf :: (fmt: [:0]u8, args: ..Any) -> i32 extern libc;
write_fd :: (fd: i32, buf: [*]u8, count: u64) -> i64 extern libc "write";

extern imports a symbol defined elsewhere; export is its dual — define a function in sx and expose it under the C ABI so C can call back in. Both imply abi(.c) and take an optional [LIB] ["csym"] rename tail:

abs       :: (x: i32) -> i32 extern;             // import an external C symbol
sx_square :: (x: i32) -> i32 export { x * x }    // define + expose to C
__stdinp  : *void extern;                        // extern data global

Direct C header import:

#import c {
    #include "vendors/mylib/api.h";
    #source "vendors/mylib/impl.c";
};

Inline Assembly

asm is an expression. The body is a brace block: a template string first, then operands and an optional clobbers(.…) clause. Each operand is [name]? "constraint" <role>, where the role is -> Type (a value output) or = expr (an input). It compiles to an LLVM inline-asm call (AT&T syntax).

// one value output, two register-class inputs
add :: (a: i64, b: i64) -> i64 {
    return asm { "add %[out], %[a], %[b]", [out] "=r" -> i64, [a] "r" = a, [b] "r" = b };
}

Outputs decide the result: 0void (asm must be volatile); 1 → that type; N → a destructurable Tuple named by each operand. A top-level asm { … } block is global (module-level) assembly. See docs/inline-assembly.md for the full guide.

Modules

#import "modules/std.sx";              // flat import
math :: #import "modules/math";        // namespaced import (directory: all .sx files merged)

A flat import makes a module's top-level names bare-visible; a namespaced import binds only its alias, reached as m.name. Visibility does not chain — a flat import of a flat import is not bare-visible two hops away; qualify it or #import the module directly. Bare names that two flat imports both provide are ambiguous and must be qualified. When a module declares its own same-name symbol, that wins over any import.

A facade can re-export another module's members as its own declarations (ordinary aliases), which its direct importers then see bare:

// facade.sx
r :: #import "rich.sx";
helper :: r.helper;       // fn re-export
Thing  :: r.Thing;        // struct re-export
Box    :: r.Box;          // generic head re-export — same template

The stdlib prelude uses exactly this: std.sx is a pure re-export facade, so #import "modules/std.sx" gives every bare prelude name (print, List, Context, …) plus carried namespaces (mem, fs, process, socket, json, cli, hash, xml, log, test):

#import "modules/std.sx";

main :: () {
    gpa := mem.GPA.init();          // mem :: #import — carried from std.sx
    log.warn("count = {}", 3);
    s := xml.escape("<a & b>");
}

Implicit Context

Every program gets an implicit context with a default allocator:

// No boilerplate needed — context is auto-initialized
main :: () {
    list := List(i64).create();   // uses context.allocator
    list.append(42);
}

// Override allocator for a scope
push Context.{ allocator = my_arena } {
    do_work();  // all allocations use my_arena
}

Quick Sort Example

#import "modules/std.sx";

quick_sort :: (items: []$T) {
    partition :: (items: []T, lo: i64, hi: i64) -> i64 {
        pivot := items[hi];
        i := lo - 1;
        j := lo;
        while j < hi {
            if items[j] < pivot {
                i += 1;
                items[i], items[j] = items[j], items[i];
            }
            j += 1;
        }
        i += 1;
        items[i], items[hi] = items[hi], items[i];
        i;
    }

    sort :: (items: []T, lo: i64, hi: i64) {
        if lo < hi {
            pi := partition(items, lo, hi);
            sort(items, lo, pi - 1);
            sort(items, pi + 1, hi);
        }
    }

    sort(items, 0, items.len - 1);
}

main :: () {
    arr : []i64 = .[333, 2, 3, 5, 2, 2, 3, 4, 5, 6, 6, 1];
    quick_sort(arr);
    print("{}\n", arr);
    // [1, 2, 2, 2, 3, 3, 4, 5, 5, 6, 6, 333]
}

Standard Library

The standard library (modules/std.sx) provides:

  • I/O: print(fmt, args...), out(str)
  • Collections: List($T) (dynamic array)
  • Strings: concat, substr, int_to_string, uint_to_string, float_to_string, cstring
  • Memory: Allocator protocol, GPA (general purpose), Arena (bump allocator)
  • Math: sqrt, sin, cos
  • Introspection: type_of, type_name, size_of, align_of, field_count, field_name, field_value, and more

Atomics (modules/std/atomic.sx)

Opt-in import. Atomic($T) is a transparent wrapper over an integer/pointer-sized T; the memory Ordering is an explicit compile-time value parameter:

#import "modules/std/atomic.sx";

counter : Atomic(i64) = .init(0);
counter.store(0, .relaxed);
n    := counter.load(.acquire);
prev := counter.fetch_add(1, .seq_cst);             // + fetch_sub/and/or/xor/min/max
old  := counter.swap(42, .acq_rel);

// compare-exchange returns ?T — null = SUCCESS; a present value is the actual
// current value on failure (for a retry loop).
got := counter.compare_exchange(old, 99, .acq_rel, .acquire);
if got == null { /* swapped */ } else { /* retry with got! */ }

fence(.seq_cst);   // standalone memory fence

Ordering = relaxed/acquire/release/acq_rel/seq_cst. Invalid combinations are compile errors. The same operations run at compile time (#run) under single-threaded semantics.

Async / Concurrency (context.io, modules/std/sched.sx)

A pure-sx cooperative fiber runtime — colorblind async, with no function coloring. The async API rides the Io capability carried implicitly in context: context.io.async spawns a worker, await suspends until it completes. The SAME code runs under the default blocking Io (workers run inline) or under the fiber Scheduler installed as context.io (workers are real fibers that interleave). A Scheduler drives any number of stackful fibers, each on its own guard-paged stack:

#import "modules/std.sx";
sched :: #import "modules/std/sched.sx";

main :: () {
    s := sched.Scheduler.init();
    ps := @s;   // closures capture by value — capture a pointer to the scheduler

    // Install the fiber scheduler as `context.io`; the coordinator runs as a
    // fiber so `await` has a fiber to park.
    push .{ io = xx s } {
        ps.spawn(() => {
            a := context.io.async(() -> (i64, !) => { try context.io.sleep(30); 100 });
            b := context.io.async(() -> (i64, !) => { try context.io.sleep(10); 20  });
            c := context.io.async(() -> (i64, !) => { try context.io.sleep(20); 3   });

            sum := (a.await() or 0) + (b.await() or 0) + (c.await() or 0);  // 123
            print("sum: {}\n", sum);
        });
        ps.run();   // drive the scheduler until all fibers finish
    }
}

Workers complete in deadline order, not spawn or await order. The runtime offers:

  • context.io.async(worker) -> *Future($R) / await() -> (R, !IoErr) / cancel() — the async layer over the Io protocol. await rides the ! error channel; a cancel makes the worker abandon its body at its next suspend (true cancellation) and surfaces as error.Canceled.
  • context.io.race(.(a = fa, b = fb, …)) — structured first-wins over a named tuple of *Futures; returns a synthesized tagged-union of the winner, cancels the losers (which stop at their next suspend, so race returns at winner-time).
  • context.io.sleep(ms) / context.io.now_ms() — timer-driven suspension on a virtual clock (deterministic, no real wall time).
  • Scheduler.spawn, yield_now, suspend_self, wake, run — the raw fiber primitives + driver loop the async layer is built on.
  • Scheduler.block_on_fd(fd, want_read) — suspend until a file descriptor is ready, backed by kqueue (darwin) or epoll (linux).

It's an M:1 model (cooperative, no preemption — so no data races between fibers and no atomics needed across them), built on abi(.naked) context switching over guarded mmap stacks. Currently aarch64-pinned (macOS + Linux).

Command-line interface (modules/std/cli.sx)

std.cli builds command-line front-ends over an explicit logical argv: os_args reads the real process argv, and parse(args, commands, diag) does subcommand dispatch + --flag parsing, with named exit codes (EX_OK, EX_USAGE, EX_UNAVAILABLE) and a --json machine-output convention.

Cross-Compilation

sx build app.sx --target linux          # Linux x86_64 (glibc, dynamic)
sx build app.sx --target linux-musl     # Linux x86_64 (musl, static)
sx build app.sx --target macos-arm      # macOS ARM64
sx build app.sx --target windows        # Windows x86_64 (MSVC)
sx build app.sx --target windows-gnu    # Windows x86_64 (MinGW)
sx build app.sx --target wasm           # WebAssembly

Self-contained builds

sx can link with a bundled toolchain instead of the host's system linker — it supplies lld, the CRT, and libc (musl/glibc/mingw), so no cc/SDK needs to be installed. The default Linux output is statically-linked musl, which runs on any Linux.

sx build app.sx --target linux-musl --self-contained   # static, portable ELF
sx build app.sx --self-contained                       # host target, hermetic link
sx build app.sx --no-self-contained                    # force the system toolchain

License

MIT

Description
No description provided
Readme MIT 91 MiB
Languages
Zig 99%
Shell 0.5%
C++ 0.4%