Skip to content

March v0.4.0

Choose a tag to compare

@github-actions github-actions released this 10 Sep 19:46
· 3820 commits to main since this release

Added

  • Compiled to_string/println render a user ADT's constructor, not
    #<tag:N>.
    A boxed constructor cell carries a per-type tag and nothing
    else, so the compiled backend's type-erased formatter had no name, arity or
    field types to work from and printed the raw tag; ~H interpolation of an
    ADT had the same gap. The compiler now emits a constructor descriptor for
    every declared variant and record type it lowers, and the runtime walks it,
    so to_string(Circle(7)) is Circle(7) compiled as well as interpreted —
    nested fields, lists and records included. Values in a genuinely erased slot,
    and Option-shaped or single-field-wrapper types (which have no cell of
    their own), still render #<tag:N>.

  • Per-child restart types on supervise blocks. A child may be declared
    restart transient (a crash restarts it, kill() retires it for good) or
    restart temporary (never restarted); the default stays permanent, so
    existing blocks are unchanged. Retiring a child spends none of the
    supervisor's max_restarts budget, and a temporary child swept up by a
    one_for_all / rest_for_one batch restart is stopped without being brought
    back. Previously every supervised child was permanent and kill() on one
    simply restarted it, with no way to stop a worker that had finished its job.
    Note that March's permanent is deliberately not OTP's: no restart type
    restarts a child that returned normally.

  • Actor.list(): enumerate every live actor. Monitoring code can now find
    the actor that is behind, instead of only the ones it can name in advance:
    mailbox_size(pid) needed a Pid with no way to obtain one, so the
    load-shedding loop documented in the overload-resilience guide could not
    actually be closed. The result is a snapshot in spawn order, and the guide
    now shows it in the shedding loop. Still missing, and still open: a
    growing-mailbox alarm, per-actor state inspection, and tracing.

  • Actor.stop(pid, timeout_ms): graceful shutdown. A stopped actor accepts
    no new messages (send returns None), works off whatever is already
    queued, and then dies a normal death — which no restart type restarts. It
    returns only once the actor has actually stopped, so a shutdown sequence
    reads as straight-line code, and Actor.is_draining(pid) tells "shutting
    down" apart from "dead". Previously the only way to stop an actor was
    kill, which discards the mailbox, so rolling a node lost exactly the
    requests that were waiting. Stopping a supervisor stops its children first,
    in reverse declaration order, each with its own shutdown budget from the
    child spec (Worker w shutdown 5000, or infinity / brutal; the default
    is 5 seconds and kill never consults it). No terminate-style callback
    yet: an actor can finish its queued messages, but cannot run cleanup of its
    own.

  • backoff base <ms> cap <ms> jitter <n>% on a supervise block tunes the
    delay between repeated restarts of the same child. All three are optional and
    default to 25 / 5000 / 25, the constants the runtime previously hardcoded,
    so an existing supervision tree's timing is unchanged. jitter 0% makes the
    delays exactly reproducible for tests.

Changed

  • String interpolation is linear at every operand size. A four-or-more
    operand interpolation now compiles to a single string_concat_n that sums
    every part's length once, allocates once, and copies each byte once, instead
    of a fold of three-way concats that re-copied the accumulated prefix at every
    step. That fold was quadratic with large operands: with 4 KB operands, 32 of
    them went from 0.54s to 0.06s. Short operands, the case the fold was chosen
    for, got faster too rather than regressing, from 0.23s to 0.07s at the same
    count. Nothing changes below four operands, where a single concat is already
    one allocation and one copy.

  • Tail-recursion-modulo-cons is now on by default. A recursive call that is
    the direct argument of a constructor in tail position — the natural way to
    write map, filter or a tree rebuild — compiles to a loop that reuses list
    cells in place, instead of one stack frame and one retained cell per element.
    This is a correctness change as much as a speed one: such a function
    previously overflowed the stack on a long list when compiled (a 500k-element
    natural-style map exited 138), and now runs. On a 20k-element list mapped
    2000 times it is 4.8x faster than the same source compiled without the
    transform. --no-trmc, or MARCH_NO_TRMC=1, restores the old behaviour.
    Existing code is unaffected: the stdlib's list producers are hand-written in
    accumulator form, which the transform does not touch, so every benchmark in
    bench/ emits byte-identical code either way.

Fixed

  • A String read through a nested record projection and captured by a
    constructor was moved instead of dup'd (compiled only).
    h.identity.name
    lowers to let r = h.identity in r.name; Perceus's borrowed-field lookahead
    walked that chain but had no arm for it ending in a projection, so the
    binding was classified as owned and, say, Str(h.identity.name) took the
    string without an inc_rc — the constructor's later drop then released the
    record owner's string. The one-level h.nonce read beside it was always
    correct. This is what made test/native/node_discovery.march die
    intermittently with exit 138 / SIGTRAP: the inlined Handshake.encode_hello
    freed my_id.node_id, which each node later msgpack-encoded from freed,
    reused memory. Pinned by test/native/nested_record_field_capture (exit 138
    3/3 pre-fix, deterministic — heap strings plus allocator churn) and a
    post-Perceus TIR snapshot; node_discovery went from 8/40 to 0/100 crashes
    in an interleaved same-runtime A/B.

  • from_json decodes to the type the caller asked for. It dispatches on
    its RESULT type, which no value at the call site reveals, so with two or
    more derive Json types in one module the interpreter ran whichever
    decoder was derived LAST - every earlier type's decode failed with an error
    indistinguishable from bad input - and the compiled backend refused to
    build the program at all. The typechecker now resolves the target per call
    site and both backends follow it. A call whose result type nothing pins is
    still reported rather than guessed.

  • derive Json's island bridges work. The auto-generated update_json
    and render_json are generated only for a module with both a State and a
    Msg deriving Json, which is exactly the case the bug above broke, so
    update_json silently returned its input unchanged. Its 161-test suite is
    now part of the test run.

  • A missing Show impl is no longer reported as an ambiguity.
    println(x) on a type with no Show failed to compile with "ambiguous
    interface-method call to show: 20 implementations are in scope", listing
    twenty types none of which was the argument's, and never mentioning the
    derive that fixes it. Such a call now renders through the constructor names
    above, matching the interpreter, so it compiles. Where an interface method
    genuinely has no fallback, the diagnostic names the type and how to supply
    the implementation instead of listing every impl in scope.

  • Compiled File / Dir errors are real FileError values again. Twelve
    builtins — file_read, file_write, file_append, file_delete,
    file_copy, file_rename, file_stat, dir_list, dir_mkdir,
    dir_mkdir_p, dir_rmdir and dir_rm_rf — are typed
    Result(_, FileError), but the compiled runtime put a bare string in the
    Err payload instead of a FileError, so a natively compiled program that
    inspected the payload read a string's header as if it were a FileError.
    Interpreted runs were always correct, so the misread only appeared once a
    program was compiled. Each builtin now reports the same error kind its
    interpreted counterpart does, including the kinds a single shared mapping
    would have flattened: writing to a directory reports IsDirectory, and
    removing a non-empty directory reports NotEmpty. This continues the fix
    that landed for file_open alone. (Printing such a payload with
    to_string still shows #<tag:N> rather than the constructor name when
    compiled, and these payloads still cannot be matched by constructor name in
    March source; both remain open.)

  • Actor.call's timeout is enforced in the interpreter. It was bound and
    never read, so a handler that took ten seconds "answered in time" against a
    1ms timeout, while one that did not reply within a single scheduler pass was
    reported as a timeout however large the timeout was. Replies are now
    timestamped and compared against a real wall-clock deadline, and a late reply
    is discarded. This is the path forge test and march file.march actually
    run, so it is where most users meet Actor.call. (The interpreter still
    cannot abort a handler mid-run: a call whose deadline passes waits for the
    handler to finish before returning Err.)

  • scripts/run-tests.sh names the suite that failed. Every runner failure
    collapsed into one FAILED bit and the lone line
    One or more suites FAILED. — no runner name, no exit status, no signal.
    That permits a misleading result, because a runner can print
    Test Successful and still exit non-zero (a launcher failure after the
    summary, a timeout kill, a signal), leaving output with no [FAIL] line
    anywhere. Failures are now attributed at the point of failure and listed in
    the summary with the original status decoded (killed by signal 9 (SIGKILL), TIMED OUT after 2400s), and a suite whose executable is missing
    is reported as NOT RUN instead of counting as a pass. The exit code and the
    One or more suites FAILED. line are unchanged.

  • A stdlib file that fails to parse is now a hard error instead of a missing
    module.
    Loading a stdlib source that did not parse printed one unbannered
    line to stderr and then continued without that module, so what the user
    actually saw was an unrelated Unknown module `Session` from the
    typechecker, pointing nowhere near the cause — and the stderr line was easy
    to miss, since it has no -- ERROR -- banner and is printed before the
    program's own diagnostics. The parse error is now rendered through the same
    banner formatter user files get, naming the file and position, and the
    compiler exits.

  • doc before a type or proof cap says what is wrong. It was a bare
    parse error whose caret landed on the following declaration under the generic
    "I got stuck here". It now reports `doc` goes before a function; use a `--` comment here. with a hint. doc attaches to fn/pfn only; type
    and proof cap declarations have no doc slot.

  • Native arrays carry a header tag. native_arr_alloc left the tag at 0,
    an ordinary ADT constructor index, so every generic walker treated a
    NativeU8Arr as a cell of pointer fields and read its payload as pointers.
    Arrays now carry MARCH_NATIVE_ARR_TAG, and the cross-heap message copier
    (copy_value) copies them by byte length instead of by field count. Latent
    today, because native arrays are barred from actor messages; a prerequisite
    for lifting that bar.

Added

  • Refinement predicates can name a zero-argument constant function.
    fn size_x() : Int do 128 end may be called inside a predicate
    ({Int | 0 <= _ && _ < size_x()}) and is checked exactly as the literal
    128 would be, including derived constants (2 * size_x()), Bool
    constants, and qualified spellings (16 * World.size()). A program can now
    name an array dimension once and keep static bounds checking on everything
    indexed by it instead of freezing the literal into every refinement. A
    zero-argument function whose body does not fold to a literal draws a warning
    at the predicate that says why.

  • --refine-audit: a refinement coverage audit, answering "does the
    checker even look at this declared refinement?" as a separate question
    from --refine-report's "was this obligation proved?". Every declared
    refinement in a module is classified Enforced, Inert (warned), or
    Unenforced, with a per-position reason for every Unenforced site,
    including a refinement a multi-head function's clause merge drops
    entirely or a default-argument function relocates to a mangled name no
    plain call can reach (compared against the pre-desugar declaration list,
    not just the post-desugar one). A swept baseline over the corpus
    test/native/*.march and stdlib/*.march finds 63 declared refinements
    today, all Enforced (test/refine_audit/corpus.baseline, ratcheted in CI
    beside the existing refinement obligation ratchet, plus a ceiling on the
    corpus's own Unenforced count that a baseline regeneration cannot
    bypass); a second, deliberately non-empty fixture set at
    test/refine_audit/holes/ guards against the audit itself silently going
    vacuous. Known unenforced positions (a lambda's own parameter, a
    block-level fn's parameter and return, a non-adoptable impl method's
    parameter, an actor's state field and handler parameter, a multi-head or
    default-argument function's parameter) are documented in
    docs/refinement-types.md and filed as specs/todos/ entries with
    reproducers.

  • @[no_alloc(transient)] — a weaker allocation contract that states
    "nothing this function allocates SURVIVES the call". A frame loop that
    allocates a dozen cells and frees all of them before returning has that
    property and cannot state it with the bare form; transient accepts it,
    including when the allocation happens in a callee whose result the annotated
    function drops. It fails when a function returns something it allocated,
    writes one into an object it did not allocate, or hands one to an actor, a
    Vault, a spawned task, an extern or an unknown closure — and when
    anything it calls does. An amortized growth path (a buffer that reallocates
    its storage and keeps it) is retained and so still rejected. The language
    server reports it at the function name and shows ✓ no_alloc(transient) when
    it holds; forge fix --contracts now inserts whichever form actually holds,
    preferring the stronger one.

  • Stack promotion through a call. Escape analysis used to treat every call
    argument as escaping. It now promotes a value whose only use is an argument
    to a function in the same program that provably does not retain the pointer
    it receives — one that destructures it, reads its fields and returns
    something else. Storing it, returning it, capturing it in a closure, sending
    it to an actor and handing it to an extern all still count as escaping, and
    a closure passed to its own apply function is never promoted. Borrow
    inference also stopped marking a parameter owned just because a SCALAR
    field extracted from it met a builtin: for a variant with no heap-carrying
    field anywhere there is no aliasing hazard for that rule to guard against,
    and being owned is what made the callee free a cell the caller could have
    kept in its frame.

  • Unboxed small scalar aggregates. A variant with exactly one constructor
    whose fields are all Int, Float or Bool — two to four of them — is now
    represented inline: Vec3(Float, Float, Float) is three doubles in
    registers, with no heap cell, no object header and no reference counting.
    Constructing one is not an allocation, so a function like fn forward(yaw, pitch) : Vec3 can carry @[no_alloc], which was impossible before (nothing
    was dying for reuse to take over, and the value escapes through its return).
    Semantics are unchanged: pattern matching, equality, Show, and passing such
    a value to a closure, an actor or a generic all behave exactly as before, and
    wherever the value is stored (a constructor or record field, a tuple, a
    closure capture, a message) it is boxed into the same cell it always was —
    reported by @[no_alloc] as the allocation it is. A vector-math benchmark
    (bench/vector_math.march, 3M iterations building five vectors each) runs in
    20.8 ms against 828.3 ms for the boxed representation. That win is for
    vectors that stay in locals, parameters and returns; a program that stores
    its aggregates inside other heap values pays a boxing there instead and sees
    no change in allocation count. A type named in an
    extern signature keeps the boxed representation program-wide (see
    docs/ffi.md), and MARCH_NO_UNBOX=1 restores it everywhere.

  • Allocation contracts (@[no_alloc]). A per-function contract checked on
    the compiled program, after reference counting and escape analysis, so a
    constructor the compiler reuses in place or a value it promotes to the stack
    passes. Transitive over callees with no annotation on them;
    @[no_alloc(warn)] reports a warning instead of an error and
    @[no_alloc(assume)] marks a closure or extern wrapper as trusted.
    --no-opt downgrades a failure to a warning naming the flag, and a
    TRMC-eligible failure under --no-trmc says so. The language server reports the
    failure at the function name, shows ✓ no_alloc when the contract holds,
    and offers an "Add @[no_alloc]" quick fix; march --compile --report-contracts and forge fix --contracts insert the attribute on
    functions the compiler has verified. Tagged(_, NoAlloc) and Realtime
    policies now use the same check, which only widens what they accept.

  • Session: a session-transport capability. Cap(Session.Live) carries
    the transport as a dictionary — register, emit, suspend, close,
    protocol-agnostic, messages as Bytes — so the same endpoint code runs
    against a real transport in production and a deterministic in-process one
    under test, where a FIFO run queue fixes the interleaving and the trace is
    reproducible run to run. Session.attach(io, ops) is the only way to obtain
    one. There is deliberately no ambient transport: a session with nothing
    attached panics rather than silently doing nothing.

  • Capability mocking now reaches actor handlers. A handler runs on the
    scheduler rather than under the caller that spawned it, so there was no
    parameter to thread a capability through and an actor's IO stayed real while
    everything around it was mocked. The capability is now captured at the
    spawn site, so a mock reaches an actor spawned inside with_cap and not
    one spawned outside it — regardless of when either actually runs. One
    capability per actor for now; an actor whose handlers reach two keeps the
    previous behaviour rather than a partly-wrong one.

  • Mock an IO effect in a test. with_cap(mock, fn _ -> code_under_test())
    swaps a capability's implementation for a lexical region, and the code under
    test needs no capability parameter and names no capability — in a --test
    build the compiler threads one in and routes the operation through the
    capability's dictionary. An IO capability's dictionary shape is derived from
    the compiler's own tables (march --emit-io-ops), so there is nothing to
    hand-write: start from cap_ops_empty(c) and override the one operation you
    care about. Release builds are untouched. Console, clock, randomness,
    filesystem and network are fully mockable; IO.Mut is not (every vault_*
    is polymorphic, and a dictionary field would need rank-2 types), and
    operations inside actor handlers are not reached.

  • forge run FILE runs a single .march file, interpreted or (with
    --compiled) through the LLVM pipeline. A file inside a project still
    resolves that project's modules and FFI shims. Arguments after -- reach the
    program as System.argv(); the compiler gained march --args to make that
    work for interpreted runs too.

  • MARCH_PIN_MAIN=1 runs main on the OS main thread. The main green
    thread is pinned to scheduler 0, which lives on the thread that started the
    runtime, while the other scheduler workers keep running Tasks and pmap.
    For libraries that require the process main thread (Cocoa, GLFW window
    creation on macOS), which previously forced MARCH_NUM_SCHEDULERS=1 and
    lost all parallelism. Opt-in; the default is unchanged. Procs spawned by
    main are not pinned; with a single scheduler the variable is a no-op.

Changed

  • The default scheduler-thread count now tracks the machine. March ran
    4 OS scheduler threads by default on every machine — four on a four-core
    laptop and four on a 96-core server — so 4 was the de-facto parallelism
    limit of any program that had not set MARCH_NUM_SCHEDULERS, and nothing
    said so. The default is now one scheduler per online CPU, clamped to
    MARCH_MAX_SCHEDULERS (64). MARCH_NUM_SCHEDULERS=N still pins a count and
    =auto still asks for one-per-CPU explicitly; both behave exactly as
    before. A build may still pin the default with
    -DMARCH_NUM_SCHEDULERS=N, and a pin wins over auto — the C scheduler test
    harnesses depend on that, since their premise is a specific thread count and
    an auto default would make them vacuous on a single-core box. Measured on a
    14-core M3 Max: the same CPU-bound pmap_n program goes from 5 OS threads
    and 4499 ms to 15 threads and 2487 ms with no source or flag change.

    The count follows the container, not the host. sysconf reports the
    machine's CPUs and ignores both ways a container narrows them, so the
    default is derived from the smallest of the online count, the CPU affinity
    mask (docker --cpuset-cpus, k8s CPU pinning) and the cgroup CPU quota
    (docker --cpus, k8s CPU limits, cgroup v2 and v1). Without that, a program
    pinned to one CPU would have started one thread per HOST core — worse than
    the flat 4 it replaced. Verified in Docker: --cpuset-cpus=0 and --cpus=1
    each resolve to 1 scheduler while sysconf still reports 14.

Fixed

  • A closure environment was freed shallowly, so every captured value
    leaked.
    Releasing a lambda's environment freed the cell and left what the
    lambda had captured allocated forever. A thousand closures each capturing a
    1 MB array, called once and dropped, held 1,073 MB resident; they now hold
    9.5 MB. A capturing closure's own release now records whether it was the one
    that freed the environment, and releases the captures at the function's tails
    on that path — so the captures stay valid for the body that borrows them, and
    the environment's own lifetime is unchanged. The release is emitted only for
    closures whose environment provably owns what it captured: a closure that is
    only ever invoked locally (a join point, an immediately-applied lambda)
    borrows its captures, and releasing those would be a double free. A closure
    released without ever being applied is still freed shallowly.

  • @[measure] on a function no predicate could ever translate is now an
    error at the annotation.
    A zero-argument or multi-parameter @[measure]
    used to be accepted by the predicate-vocabulary check and dropped by the
    measure preamble, so following the "annotate the function @[measure]"
    warning on a constant silenced the warning and changed nothing else: every
    call site kept filing a "has no SMT translation" hint. The two gates now
    agree; the error names the remedy (a constant needs no annotation).

  • MARCH_NUM_SCHEDULERS was a silent ceiling, not a setting. The
    environment variable could only ever lower the OS scheduler-thread count:
    a request above the compile-time default (4) was dropped without a word, so
    MARCH_NUM_SCHEDULERS=14 ran four threads and reported nothing. Every
    parallel-scaling measurement taken on a machine with more than four cores
    was therefore comparing four threads against four threads, and CPU-bound
    pmap/pmap_n work looked like it plateaued at ~4x when it had simply
    stopped being given threads. The variable is now a real request, honoured up
    to MARCH_MAX_SCHEDULERS (a compile-time bound, default 64, that sizes the
    runtime's scheduler table); MARCH_NUM_SCHEDULERS=auto asks for one
    scheduler per online CPU. A request the build cannot satisfy is still
    clamped but now warns on stderr naming both the request and the maximum, and
    a malformed or non-positive value is reported instead of being silently
    reinterpreted. The compile-time default is unchanged at 4, so no program's
    behaviour changes unless it sets the variable. Measured on a 14-core M3 Max
    (64 CPU-bound pmap_n tasks, medians of 3): 1984 ms at 1 thread, 342 ms at
    4, 118 ms at 10, 105 ms at 14, 109 ms at 20. The old code resolved every
    request of 4 or more to 4, i.e. it produced the 342 ms row and never left
    it — a ~5.8x ceiling where the real span is ~18.9x. (Measured under load
    average ~170; the ratios are inflated by contention for the machine, so the
    shape rather than the magnitude is the result.)

  • Reading a field of a borrowed value no longer refcounts it. A field
    projected out of a scrutinee that is alive for the whole match — a
    borrowed parameter, or any value still needed after the match — and then
    used only at borrowed positions was bracketed by an inc_rc/dec_rc pair
    that nothing needed: the parent already holds the reference, and the
    projection never escapes the arm. Under the scheduler every refcount
    operation is atomic, so a one-field wrapper such as Chunk(NativeU8Arr)
    turned a read-only access to data shared between workers into a contended
    cache line, and the same reads scaled negatively with thread count where
    the unwrapped array scaled 5.8x. A projection that does escape — returned,
    stored in a constructor, captured by a closure, or passed at an owned
    argument position — still takes its dup, now emitted at the escaping use
    rather than at the projection.

  • NativeArray reads borrow their array. NativeArray.get_*, .length,
    .sum and .to_list were absent from the extern borrow table, so every
    array read looked like an ownership transfer. That flipped the enclosing
    parameter to owned, which forced a dup at a self-call that the tail-call
    back-edge never balanced: a loop reading a heap parameter grew its refcount
    by roughly one per element read and never freed the array.

  • A small scalar aggregate built inside a branch no longer leaks. A
    single-constructor variant whose fields are all Int/Float/Bool and
    whose arity is 2-4 is held in registers rather than on the heap. Building one
    inside an if or a match arm leaked one object per construction, because
    the value has to be boxed to cross the branch join and nothing freed the box
    afterwards; a 5 000-iteration loop ended with 5 001 live objects instead of

    1. The join now releases it. Building the same value straight-line or in a
      called function was never affected, and still reaches the allocator zero
      times. One related case remains open: an aggregate stored into an Option
      (or another niche-encoded type) still leaks, with or without a branch.
  • A supervisor nested under another supervisor now passes its
    capabilities on to its own children, including the fresh children it
    creates each time it is restarted. Previously only a top-level supervisor's
    direct children were captured, so a mock stopped one level down.

  • Records and tuples are now reference-counted and freed. Previously
    needs_rc was false for both, so Perceus never decided an aggregate was
    dead: every record and tuple cell leaked, and so did every heap value it
    owned. A 200,000-iteration loop rebuilding a { n : Int, s : String }
    leaked ~200k strings and ~200k cells (15.2 MB peak) where the equivalent
    two-field variant leaked nothing (2.4 MB). Aggregates now get a synthesized
    deep drop (__drop$R / __drop$T) that releases their fields behind the
    same shared-cell guard variants use. A variant holding a record or tuple
    field is fixed by the same change.

    A record passed to a function that reads its fields is fixed too: the
    projection no longer dups the record (2001 live objects over a 1000-iteration
    loop, now 1).

  • An aggregate rebuilt each iteration of a self-tail-recursive function no
    longer leaks.
    Records and tuples are now owned parameters. A borrowed one
    left the caller holding the release, and in a tail-recursive loop that release
    sits after the tail call — where TCO folds the call into a back-edge and the
    release is discarded, leaking one cell per iteration. Owned parameters let
    each iteration release the aggregate it was handed before jumping with a new
    one, so such a loop now runs in constant space. All six aggregate leak
    fixtures — record, record-with-heap-field, record update, and tuple — are flat
    across a 100x change in iteration count.

  • A non-generic Option-shaped type with a heap payload was freed twice.
    type Wrap = W(String) | Z is encoded as a niche, where W(x) is x — one
    cell — but the deep-drop pass classified it as boxed and synthesized a drop
    that released the cell and then released its "payload", the same pointer.
    Crashed 6/6 in a loop, variously as heap-allocator freelist corruption, an
    RC-underflow abort, or SIGBUS. Generic Option(String) was never affected, so
    this needed a user-declared non-generic type. The two representation
    predicates now have to agree before a drop is synthesized.

  • Compiled binaries built with MARCH_STRING_STATS=1 now report live_objs,
    the runtime's exact live-heap-object count. Unlike peak RSS it does not vary
    with machine load, so it can be asserted directly in leak regression tests.

  • Capability mocking now reaches a supervised child. A supervise block's
    children are spawned by the supervisor itself, not by user code, so a mock
    that reached a plain actor never reached one under a supervisor. The
    capabilities in scope where the supervisor is spawned now reach each of its
    children, and a child restarted after a crash keeps them. Compiled --test
    builds only, as before.

  • An actor whose handlers reach two or more capabilities (it logs and
    reads the clock, the ordinary shape) can now be mocked; previously only
    one-capability actors were captured at spawn, and an actor over that limit
    kept its real IO with no warning. The spawn site now captures a record of
    every capability the actor needs, and a partial mock works: an actor
    reaching IO.Console and IO.Clock, spawned inside a Console-only
    with_cap, gets the mocked Console and the real Clock. Compiled --test
    builds only, as before.

  • march --emit-io-ops printed every multi-argument operation in the wrong
    spelling: (A, B) -> C, which in March is a function of one tuple
    argument (Tuple.apply calls its f : (a, b) -> c as f(t)). Copying that
    shape into a hand-written dictionary type then failed to typecheck against a
    two-parameter lambda. Multi-argument functions are spelled curried,
    A -> B -> C, and the output now says so. The compiler-built dictionaries
    were never affected. Tuple.apply's doc string, which described the same
    type as "two separate args", is corrected too.

  • A type mismatch between two types that print with the same name no longer
    says "expected Ops but got Ops". A record type declared with type
    parameters is reached with zero arguments in some positions (a proof cap X with T clause, for one), and both sides then rendered as the bare declared
    name. The record side is now spelt out structurally, and when the name is a
    parameterised record the note says so — "Ops is declared with 1 type
    parameter (Ops(m)), but here it is used with none" — instead of blaming a
    global-namespace collision that isn't there.

  • The Linux release binaries are now actually statically linked. Both Linux
    archives were labelled static: true and promised "zero runtime
    dependencies", but nothing ever passed -static: they shipped needing
    libLLVM, libblake3, libz, libzstd, libbrotli{enc,dec} and libc, and the
    aarch64 binary would not start on a bare alpine:3.21 — ~20 Error relocating ... symbol not found lines before main. libblake3 is built from
    source by the release workflow and is packaged by no distro, so it was on no
    user's machine. Both Linux legs now build inside Alpine and link statically
    for real, and a new CI step runs the produced march and forge inside bare
    alpine:3.21 and debian:stable-slim before publishing, so a dynamically
    linked archive can no longer ship. One consequence, on the Linux prebuilts
    only: a static musl binary has no dlopen, so interpreted extern FFI does
    not work there and the REPL's --jit falls back to the interpreter.
    Interpreting, --compile and forge are unaffected, as are macOS builds and
    builds from source.

  • march --compile works on musl (Alpine). Two separate breakages, so the
    musl-targeted linux-aarch64 prebuilt could interpret March but not compile
    it. runtime/march_runtime.c included <execinfo.h> unconditionally — a
    glibc extension musl lacks — so compiling the bundled runtime died at the
    first header; it is now guarded, and the only thing lost on musl is the
    symbolic C backtrace inside the opt-in MARCH_DEBUG_OOM forensics (March's
    own stack trace is built from its own frame table and is unaffected).
    Then the link failed on getcontext/makecontext/swapcontext, which musl
    declares but does not define; on a musl host the compiler now links
    -lucontext, where Alpine keeps them.

  • Multi-head function heads now count as structural recursion, so an ordinary
    recursive tree walk written with function heads compiles. Desugar merges
    multiple parameters into a tuple scrutinee, which the tail-call checker did
    not recognise as a parameter, so every head binder lost its
    structurally-smaller status and the same code that compiles as an explicit
    match was a hard error in head form. The two spellings now reach the same
    verdict.

  • The tail-call checker no longer accepts recursion that cannot terminate. A
    match arm binder naming the WHOLE scrutinee — match x do y -> f(y) end,
    where y is x — was treated as structurally smaller and accepted.
    Arithmetic reduction on such a binder (f(y - 1), as in fib) is still
    accepted, since that genuinely decreases.

  • A let whose right-hand side is a call with a refined return type, and which
    rebinds a name that return refinement mentions, no longer proves impossible
    obligations. The scalar and record cases of a hole closed for ADTs on
    2026-08-04 are now closed the same way.

  • The formatter no longer deletes compiler attributes. format.ml never read
    fn_attrs, so every @[...] was silently dropped — and that is not
    cosmetic: @[no_warn_recursion] suppresses a hard error, so format-on-save
    turned a compiling file into one that no longer builds.

  • A non-tail recursive call is no longer reported twice. The typechecker's
    tail-call checker already reports every one of them, and the LSP emitted its
    own perf/non-tail-call diagnostic at the same span, so the editor showed
    two near-identical messages in one hover. The compiler's is kept — it is the
    authority on error-vs-warning and it honours @[no_warn_recursion].

  • Both non-tail-recursion hints now mention @[no_warn_recursion] as the
    option when the depth is bounded and the stack use is acceptable.

  • forge build and forge test no longer share a compilation-cache entry.
    --test emits a different program (a test-runner entry point) but was absent
    from the cache key, so whichever ran first won: forge build could hand you
    the test runner, or forge test the plain binary.

  • Completions now offer what you defined in the file you are editing before
    anything from the standard library. Ranking was by category alone — locals,
    keywords, values, types, constructors — so every stdlib function outranked
    your own types and constructors, and typing B in a module declaring
    BTree/Branch offered all of Base64 and BigInt first. Ranking is now
    by origin first, then category.

  • Typing in the editor no longer silently rewrites the same identifier
    elsewhere in the file. The LSP answered textDocument/linkedEditingRange
    with a symbol's definition and every use, but that request is not a
    query: the editor applies each keystroke to all returned ranges at once,
    with no prompt. Putting the cursor on a variable therefore turned ordinary
    typing into an un-asked-for rename, and ranges that were even slightly out
    of date landed mid-token and ate neighbouring characters (Some(a)
    becoming Somea)). Only genuine open/close pairs — ~H sigil tags — are
    linked now; renaming a symbol remains textDocument/rename, which is
    explicit and already supported.

  • The "cannot access field" diagnostic (accessing .field on an
    Option/Result value directly, before unwrapping it) now says how to fix
    it instead of only naming the type. It names the variable the reader
    actually wrote (`l` may be `None`) rather than "this expression", and
    the suggested match l do Some(x) -> x.value None -> ... end /
    Option.map(l, fn x -> x.value) is copy-pasteable as-is rather than an
    <expr> template to re-instantiate by hand. It also explains why — the
    value may be absent — rather than restating that the type is not a record.

  • Diagnostics no longer show an inference-internal fresh-type-variable name
    for an unresolved Option/Result payload. Option(l2) — meaningless to
    the reader, and different from run to run — now prints as Option(_).

  • Editor diagnostics no longer hide their notes. The LSP put a diagnostic's
    notes after a newline, and editors that render only the first line of the
    message in the hover popover — where diagnostics are actually read — showed
    the note nowhere except the separate, rarely-open project-diagnostics
    panel, so guidance like the unwrap hint above was invisible in practice.
    Notes are now flattened onto one line for LSP clients; the CLI keeps its
    indented multi-line layout.

  • The LSP could publish the exact same diagnostics twice for one edit,
    showing as the same message stacked twice in the editor (e.g. hovering a
    line with a type error). The TIR-pass phase of did_change/did_open
    skips re-running when the source already has errors — correctly, since
    the TIR pipeline can't run on broken input — but returns the SAME
    Analysis.t it was given rather than a new one, and the calling code
    published its diagnostics again regardless, republishing the identical
    list already sent moments earlier. Now skipped whenever the TIR pass was
    a no-op.

  • Typechecking a self-referential record type used without an annotation
    (e.g. a multi-head function whose only parameter is a record pattern
    destructuring type Tree = { left : Option(Tree), ... }) no longer hangs
    the compiler at 100% CPU. Expanding the record's fields for type inference
    recursed into itself with no base case — Tree's left field mentions
    Option(Tree), which requires expanding Tree again, forever. A guard now
    stops re-expanding a record already being expanded on the current path.

  • The resulting "expected Tree but got a" style message — for the same
    self-referential-type case, when it doesn't hang — pointed at the wrong
    line and gave no indication of what was actually wrong (an internal,
    unresolved type-variable name like a or y55 leaking into the text).
    This is an occurs-check failure (the type would have to contain itself),
    not an ordinary mismatch; it's now reported as such, with a hint to add an
    explicit type annotation naming the recursive type.

  • The LSP's completion-resolve request (completionItem/resolve, which
    computes the auto-import edit for an accepted completion) could insert
    that edit at the wrong place in the buffer if it fired against a document
    version that had since changed underneath it — the import's insertion
    point is the file's first declaration line, not the cursor, so a stale
    computation landed text on an unrelated line while the user kept typing
    elsewhere. It now checks the document version and refuses to answer once
    the buffer has moved on, instead of answering against stale state.

  • The LSP's diagnostic messages could show an internal, ever-growing
    fresh-type-variable name (y55, b53, …) for an unresolved type,
    instead of a short, stable one (a, b, …). The display-name counter
    backing these was never reset for the server's lifetime, so it climbed
    for as long as the process stayed open — a one-shot compiler run never
    hit this since it exits after a single pass. It's now reset once per
    analysis, so names stay small and predictable.

  • --fmt (and the LSP's formatting request) no longer corrupts record
    patterns whose binder differs from the field name, e.g.
    { left: l, right: r }. It reprinted them using record-construction
    syntax ({ left = l, right = r }), which the parser rejects — every
    reformat of such a pattern broke the file. Record-literal shorthand fields
    ({ x, y }, where the binder matches the field name) were unaffected.

  • The non-tail-recursion warning states the condition instead of a verdict.
    It has been wrong in both directions: it used to end "when the recursive call
    is the direct argument of a constructor, the compiler turns it into a loop",
    which was false while tail-recursion-modulo-cons was opt-in, and was then
    reworded to say the transform is off by default, which became false when the
    default flipped in this same release. The typechecker runs before the IR and
    cannot tell which case a function is, so the warning now names the shape that
    becomes a loop, says the transform is on by default, and still warns that
    anything else uses O(depth) stack. It no longer recommends a flag. The
    language server's copy of the message and the allocation-contract note were
    corrected the same way.

  • A return-contract counterexample no longer names an input the parameter's
    own type excludes when the refinement sits below the top of the type (a
    refined record field such as { v : {Int | _ > 0} }, a refined type
    argument, or a refinement under a linear wrapper). Such witnesses are now
    declined, the same rule the call-site precondition path already applied;
    a refinement at the top of the parameter type still confirms as before.

  • ARM Linux (linux-aarch64) has a working release artifact for the first
    time. Every v0.2.0 and v0.3.0 ARM archive shipped with an empty bin/ and no
    compiler at all. Three defects were stacked behind one another: git refusing
    the bind-mounted checkout inside the Alpine build container
    (safe.directory), the LLVM 19/20 guard bug noted below, and libblake3 built
    without blake3_neon.c on aarch64, which links but leaves
    blake3_hash_many_neon undefined in every executable that uses it.

Changed

  • The live-object gauge (march_live_allocs) no longer serialises parallel
    allocation.
    The gauge is always on, so its single process-wide atomic sat
    on the two hottest paths in the runtime — march_alloc, and every RC
    free-on-zero branch — making every thread in the program read-modify-write
    one cache line there. That was enough on its own to make allocation-heavy
    parallel code scale negatively: measured on an M3 Max (14 cores) with a
    List.pmap_n over 64 cons-allocating tasks, the same program took 3022 ms
    on 14 scheduler threads against 1875 ms on 1. It is now a per-thread
    counter summed on read — an exiting thread folds its residual into a
    process-wide total via a pthread_key_t destructor and releases its slot
    for reuse, so the sum stays correct across thread lifetimes even though
    allocation and freeing are not thread-affine. The same program now runs in
    471 ms on 14 threads (6.4x faster than before, and 4.8x faster than its own
    1-thread time), matching a build with the counter compiled out entirely.
    march_live_allocs() keeps its existing semantics and single-threaded
    results; test/test_ffi.c gains a multi-threaded case that allocates on
    eight threads, lets them exit, and frees each thread's objects from a
    different thread.

  • unreflectable-predicate no longer misattributes a subject failure to the
    predicate. An arithmetic actual (n - 1, i + 1) now reflects through the
    same scope as the variable it uses, so a guard on n reaches n - 1
    instead of being reported as an untranslatable predicate. A call actual
    whose own value cannot be translated to SMT (an opaque call, a plain
    arithmetic expression over a non-Int sort) is now filed as
    unreflectable-subject, naming the actual, and the same rule applies to a
    postcondition's own return expression. What remains under
    unreflectable-predicate now names the specific sub-expression that failed
    to reflect (for example the predicate's `_ / 2` has no SMT translation)
    instead of a generic "uses vocabulary the checker cannot translate"
    message; naming the sub-expression also lets that reason print at every
    call site instead of once per module. A predicate containing /, %, or a
    string literal now prints its real spelling in every message instead of
    the <predicate> placeholder.

  • A let whose right-hand side is a literal or an arithmetic expression now
    carries its value into refinement checking. let n = 0 followed by a call
    that requires a positive argument reports the same definite violation that
    passing 0 directly already did, instead of going undecided because nothing
    constrained n. Calls, if expressions and floats are not admitted.

  • A binder in a constructor pattern now inherits the constructor an earlier
    unguarded arm's sub-pattern excluded. After Cons(x, Nil), the t in a
    later Cons(_, t) is known not to be Nil, which is what makes
    List.last's recursive call verify. That is the one site in the standard
    library this reaches.

  • Refinement obligations the checker cannot discharge now report why. The
    single solver-undecided message has been split into three diagnosed causes —
    an unconstrained subject (no fact the checker derived constrains the
    value the predicate talks about), a partially established conjunction (naming which
    half holds and which does not), and an opaque application (the goal
    depends on a call the checker cannot see through) — with solver-undecided
    kept as the honest residual for "the solver returned unknown". A diagnosed
    cause reports at every call site that has one; the undiagnosable residual
    keeps its previous once-per-module hint, so the common case gains detail
    without the rare one gaining noise. --refine-report now groups its skipped
    counts by cause rather than by message payload, so a bucket is one line
    instead of one line per distinct variable name.

  • Promotion of a call-site precondition failure to a warning is not attempted
    at stdlib/ spans. Diagnostics at stdlib spans are filtered out of the
    printed stream, so a promotion there paid the reachability cost to produce a
    count nobody could explain. Compiling a stdlib module directly still reports
    its own sites, because then they are user spans.

  • The linux-aarch64 release leg builds on a native arm64 runner
    (ubuntu-24.04-arm) instead of QEMU emulation on an x86_64 host. It still
    builds inside Alpine, which is what makes the artifact musl. Building the
    OCaml 5.3.0 switch alone took 52 minutes under emulation; the entire leg now
    takes under 5 minutes.

Added

  • Capabilities can now carry a runtime dictionary — a record of the
    operations they authorize — so a capability's implementation can be swapped at
    a binding site. Declare one with proof cap Live with Ops, attach with
    cap_impl(cap, dict), and read it back with cap_dict(cap), which yields an
    Option whose None is "no dictionary, use the ambient implementation" —
    what every capability written so far does. cap_narrow carries a dictionary
    across attenuation. Supplying a dictionary is gated exactly like mint_cap
    (a public function of the declaring module), because deciding what a
    capability does is at least as much authority as minting one.
    Works on both backends, with compiled/interpreted parity pinned by a native
    golden; the compiled representation costs nothing, since Option's niche
    encoding makes cap_dict the identity function. Mocking an IO capability is
    not yet reachable — IO builtins do not take their capability as an argument,
    so a dictionary attached to one would never be consulted.

  • A function that propagates a refinement requirement it does not declare is
    now reported, and the report ends in the signature to write rather than the
    panic to fear. When a call-site precondition failure can be demonstrated —
    the enclosing function is executed, a real panic observed, and repairing the
    argument shown to remove it — March infers the precondition that discharges
    the obligation and prints it as a help: block, carrying a machine-applicable
    fix so forge fix rewrites the parameter list for you:

    `go` propagates a requirement it doesn't declare.
    
    `head` requires  len(_) > 0
    but go([]) panics — "empty"
    
    help: declare what `go` actually needs —
            fn go(ys : {List(Int) | len(_) > 0}) : Int
    `forge fix` can apply this.
    

    The suggestion is offered only when the checker itself proved it discharges
    the whole debt, so applying it really does silence the warning. A warning by
    default (propagating an undeclared requirement is a design choice); an error
    under cap verified. Cost is confined to promotion, which is rare: a program
    with no demonstrated failure runs no inference at all. Only evaluator
    failures prefixed panic:, todo:, or unreachable: count as a panic for
    this confirmation; any other Eval_error (unbound name, arity mismatch,
    desugar residue, …) declines to confirm, so an internal evaluator error can
    never be reported as a demonstrated failure.

  • Refinement failures now come with concrete, interpreter-validated
    counterexamples rendered in source terms. A return contract violated for
    some input reports the executed failing call — but clamp(0) returns -1. —
    with the witness shrunk to a canonical small value; call-site precondition
    errors show validated source-syntax examples ((e.g. ys = []) instead of
    len(ys) = 0); cap no_panic division errors name the concrete admissible
    zero divisor ((e.g. d = 0)); and contracts outside the solver's fragment
    (x * y) are probed with a small input battery, so fn scale(x, y): {Int | _ > 100} reports but scale(1, 1) returns 1. instead of staying silent.
    Every reported witness was actually executed and observed to fail, and must
    satisfy the parameters' own refinements — spurious solver models are
    rejected, never shown.

Fixed

  • The REPL JIT now compiles against LLVM 19 and 20. jit_orc_stubs.c guarded
    its ThreadSafeContext API choice on LLVM_MAJOR_VERSION >= 19, but the C API
    actually swapped LLVMOrcThreadSafeContextGetContext for
    LLVMOrcCreateNewThreadSafeContextFromLLVMContext in LLVM 21 — so a build
    against 19 or 20 took the newer branch and died with "implicit declaration of
    LLVMOrcCreateNewThreadSafeContextFromLLVMContext". Building against LLVM 18
    or 21+ was unaffected, which is why it went unnoticed.

  • forge interactive (and march repl / a bare march REPL) now build and load
    the forge.toml [ffi] shim, so a project whose dependencies declare native
    FFI sources no longer fails at the first extern call with "symbol not found for
    interpreter FFI". This is the REPL sibling of the forge run fix.

Changed

  • A function whose declared return refinement is violated for some
    admissible input is now a compile error when the violation is confirmed
    by execution (previously: silent outside cap verified, "cannot verify"
    inside it). Code like fn f(n : Int) : {Int | _ >= 0} do n end no longer
    compiles. @[trusted] continues to rescue unproven-but-unrefuted
    contracts; it does not suppress a witness-confirmed violation.

  • lib/typecheck/typecheck.ml is 1,824 lines smaller (8,272 -> 6,448):
    unification and surface-type conversion, session-type projection, declaration
    dependency ordering, and the module-level cap checkers (no_panic, pure,
    no_extern, deterministic) now live in four new modules beside it. No
    behavior change -- the typecheck, IR and refinement oracles match to the byte
    across 601 fixtures, 241 programs and 298 fixtures respectively, and
    typecheck.mli, the public contract, is unchanged.

  • Removed three uncalled lib/tir values (emit_main_wrapper, target_arch,
    reserved_ctor_tag_limit) left over from an earlier .mli-only pass that
    could not edit .ml files. No behavior change -- the IR oracle is
    an exact byte match across 241 programs.

  • bin/main.ml is 1,231 lines smaller (5,429 -> 4,198): host/stdlib/runtime
    discovery and link flags, the command-line flag cells, the --check-migration
    tool and the --emit-core-ast writer now live in their own bin/ modules. No
    behavior change -- the IR and typecheck oracles match to the byte across 240
    programs and 600 fixtures.

  • Codegen builtin dispatch names a closed variant (Builtin_name.t) rather than
    comparing raw strings, so a typo in an emitter guard is a compile error, and a
    builtin added to the variant without an emission arm is caught by a
    non-exhaustive-match error instead of silently falling through to the generic
    application path and emitting plausible-but-wrong code.

Added

  • march --jit file.march runs a whole program through the in-process ORC JIT: near-compiled speed without the clang/link step (experimental; actors fall back to the interpreter); programs that read command-line arguments see an empty argv under --jit.

Fixed

  • REPL (JIT, the default): a stdlib constructor evaluated as the FIRST variant
    of its type, so match Logger.Warn do Logger.Debug -> 0 | Logger.Info -> 1 | Logger.Warn -> 2 | ... end answered 0, and Http.Post matched the
    Http.Get arm. A wrong answer, not just the = #<tag:0> rendering it was
    reported as. The prelude's constructor numbering is now cached alongside the
    compiled prelude and loaded on the warm-cache startup path (and kept on the
    cold one), so REPL expressions are compiled with the same tags the prelude
    was. MARCH_REPL_INTERP=1 was always correct.

  • REPL (JIT): stdlib ADT values printed as #<tag:N> instead of their
    constructor name; Http.Post now prints Post. A type declared inside a
    module is registered under its qualified name but referred to by its bare
    one; the printer now resolves the two when the bare name is unambiguous.

  • The stdlib AST and typecheck-env caches silently disabled themselves on any
    machine (or under any HOME override) where ~/.cache did not already exist:
    Unix.mkdir is not recursive and only EEXIST was caught, so ENOENT on the
    missing parent escaped and every invocation re-parsed and re-typechecked the
    stdlib while printing a [warn] could not save the stdlib typecheck cache
    line ahead of its own output. All four cache-directory sites now share one
    recursive mkdir_p.

  • Compiled code: a top-level function with a name that collides with a compiler
    builtin the code generator dispatches specially (int_mod, int_not,
    int_and, int_abs, record_get, vault_*, chan_*, …) was never called:
    every call site compiled into the builtin instead, while the interpreter ran
    the user's function. Shadowing such a name is a supported feature, and it now
    behaves the same compiled as interpreted.

  • macOS: the REPL / --jit stdlib prelude cache could never be reused across
    sessions, so every start silently paid a full stdlib precompile (~9.5s) instead
    of a ~0.01s cache hit. Cached .sos are built at a pid-suffixed .tmp path and
    renamed into place, and the macOS linker marked that now-dead path as the
    library's install name, so the prelude recorded an unloadable dependency on the
    runtime .so. They are now linked with -install_name pointing at the path the
    file actually lands at.

  • --jit: a whole-program run now prunes functions unreachable from main
    before emitting code, as the ahead-of-time pipeline already did. Without it,
    only calling one function of a stdlib module dragged in every other
    function of that module, and a program using JsonStream failed to start
    with "Symbols not found: [ _from_json ]".

  • --jit and the REPL JIT: mutually tail-recursive functions are now compiled
    into a loop, as they already were when compiled ahead of time. Previously a
    JIT fragment emitted each function separately, so a mutual tail-call cycle
    became real native recursion and burned one stack frame per iteration;
    enough iterations exhausted the green thread's stack and killed the process
    with a bare exit 138 and no output. Reached by any code shaped like
    JsonStream's byte-at-a-time tokenizer.

  • --jit and the REPL JIT: when the cached stdlib prelude could not be loaded,
    the compiler printed "stdlib cache load failed …, recompiling" and then did
    not recompile, silently continuing with no prelude. That produced untyped
    stdlib signatures and miscompiled code (a crash, not just a slowdown). The
    fallback now really does recompile.

  • --jit and the REPL JIT: the stdlib prelude cache is now keyed on the
    compiler as well as on the stdlib source, so upgrading the compiler no longer
    reuses a prelude built by the previous one.

  • march --jit --debug (and --jit --debug-tui) no longer silently drops the time-travel debugger and JIT-compiles instead; it now falls back to the interpreter with a notice, the same as the existing actor and stdlib-shadowing fallbacks.

  • REPL JIT: referencing the same top-level function as a value across multiple lines no longer fails with "duplicate definition of symbol '$clo_wrap'" (ORC backend).

  • JIT REPL (both backends): defining a function that calls a previously
    REPL-defined function (fn f(x) do x + 1 end then fn g(x) do f(x) end)
    failed with an invalid-IR "redefinition of function" error and the new
    function was lost ("I cannot find g"). Calls to prior REPL bindings
    (fns and let-bound lambdas alike) now dispatch through the binding's
    persistent slot, so they also follow the slot's current value rather than
    pinning the version seen at definition time.

  • REPL, experimental ORC JIT backend (MARCH_JIT_BACKEND=orc): defining a
    second function in a session crashed the process with SIGSEGV. Each fn
    fragment re-emitted its prior-binding slot loaders with external linkage,
    colliding in ORC's single shared JITDylib with the real definition of the
    same name; and the ORC binding double-freed the module on the add-module
    error path, turning that recoverable error into a hard crash. The default
    clang backend was unaffected; each REPL/JIT session owns its own LLJIT
    instance (multiple sessions per process no longer collide).
    clang backend was unaffected.

  • REPL: redefining a fn at the prompt now takes effect under both JIT
    backends (clang and ORC), matching interpreter mode's rebinding: the new
    body was previously silently ignored and calls kept answering with the
    first definition. :reset scroll-replay of unchanged cells still skips
    recompilation.
    recompilation. Combined with the slot-routed call fix above, this matches
    interpreter mode exactly: a direct call gets the new body, while a function
    defined earlier keeps calling the definition it was compiled against.

Changed

  • Interpreter: variable lookup no longer scans the builtin environment per reference (monomorphic comparison + hashed global scope). Interpreted programs run ~11x faster (fib(25): 16.6 s -> 1.5 s); REPL interpreter-mode lookups stay fast across prompts.
  • REPL: expressions now compile in-process through LLVM ORC when libLLVM is installed (~200x lower per-line compile latency, whole-session ~3x); set MARCH_JIT_BACKEND=clang to restore the previous clang-subprocess backend. Unrecognized MARCH_JIT_BACKEND values fall back to clang as before.
  • march file.march reuses the cached stdlib type environment; interpreted startup drops from ~1.0-1.2 s to ~0.28-0.36 s on a warm cache.

Fixed

  • A function with a lambda in its body miscompiled when its name collided
    with a stdlib higher-order function's parameter.
    fn f(xs) do List.map(xs, fn y -> y + 1) end segfaulted when compiled and failed at the REPL with
    symbol not found in flat namespace '_f' (losing the binding); renaming f
    to anything else worked. List.map's own parameter is named f, and
    defunctionalization mistook it for the user's top-level function, so the
    lambda that closes over it captured no variables and called the wrong code.
  • REPL: defining a function with a lambda that calls the function being defined.
    fn g(n) do ... List.map([n], fn y -> g(y - 1)) ... end failed at the prompt
    with symbol not found in flat namespace '_g'. The lifted lambda was
    compiled into a separate fragment loaded before g itself; it now shares one
    fragment with the function it belongs to.
  • REPL: a constructor of a type declared at the prompt evaluated as the
    type's FIRST variant.
    With type Color = Red | Green | Blue, both Green
    and Blue answered Red; and, because the same wrong constructor tag
    reached pattern matching, match Blue do Red -> 1 Green -> 2 Blue -> 3 end
    answered 1. A type declared at the prompt was registered with the REPL's
    JIT for pretty-printing only, never as an input to code generation, so every
    constructor of it was compiled with tag 0. MARCH_REPL_INTERP=1 was
    unaffected.
  • REPL: values of Option-shaped and single-constructor types printed as raw
    words.
    Some(1) displayed as 3, None as null, and Some("hi") took
    the REPL down; a user type T = X(Int) | Y showed X(7) as 15 and Y as
    null. These representations are not heap cells (the value IS the payload
    word), and the REPL's printer was reading them as though they were.
    Constructor payloads of ordinary types now also print at their declared type
    rather than assuming every field is a tagged scalar (P1(7) was showing as
    P1(3)).
  • The REPL now actually caches its stdlib typecheck env. Saving it had been
    failing on every start since the cache was introduced: the env contains an
    import-tracker closure that Marshal will not write, and the failure was
    swallowed, so each REPL launch re-typechecked the whole stdlib and left a
    zero-byte stdlib_tcenv_*.tmp file behind in ~/.cache/march (1,132 of them
    had accumulated on one machine). Start-up now hits the cache on the second and
    later launches, a save that fails reports it on stderr instead of degrading
    silently, and both the REPL and the CLI sweep stale staging files left by
    processes that crashed mid-write.

Documentation

  • specs/docker_images.md no longer claims march --compile emits a static
    binary.
    It said a compiled March program "can be copied into scratch or a
    distroless image, producing a deploy image of a few megabytes with zero
    runtime dependencies", and built its whole recommended deploy pattern on that.
    In fact the link command passes no -static on any path: every compiled
    program carries DT_NEEDED entries for libssl, libcrypto, libz, libblake3
    and (host-dependent) libzstd/libbrotli, plus libucontext on musl — verified on
    alpine:3.21 aarch64 and, identically, on macOS arm64. libblake3 is the
    worst of them: March's own release workflow compiles it from source because
    there is no package to install, so a user's program depended on a library they
    had no packaged way to obtain. The spec now carries a table of what is
    actually linked and where each entry comes from, a deploy pattern with a real
    base image and package list, and an explicit note that scratch and
    distroless-static do not work today. Static output is filed as its own item
    (specs/todos/2026-09-02-march-compile-output-is-not-static.md), with
    acceptance criteria that require running the artifact in a bare container.
    This is separate from the release-archive static defect, which concerns the
    distributed march/forge binaries rather than the output of --compile.

Installation

curl -fsSL https://github.com/march-language/march/releases/latest/download/install.sh | sh

Or if you already have forge:

forge toolchain install v0.4.0
forge toolchain use v0.4.0

Checksums

Verify your download:

sha256sum -c march-v0.4.0-checksums.txt