Repository navigation
March v0.4.0
Added
-
Compiled
to_string/printlnrender 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;~Hinterpolation 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,
soto_string(Circle(7))isCircle(7)compiled as well as interpreted —
nested fields, lists and records included. Values in a genuinely erased slot,
andOption-shaped or single-field-wrapper types (which have no cell of
their own), still render#<tag:N>. -
Per-child restart types on
superviseblocks. A child may be declared
restart transient(a crash restarts it,kill()retires it for good) or
restart temporary(never restarted); the default stayspermanent, so
existing blocks are unchanged. Retiring a child spends none of the
supervisor'smax_restartsbudget, and atemporarychild swept up by a
one_for_all/rest_for_onebatch restart is stopped without being brought
back. Previously every supervised child was permanent andkill()on one
simply restarted it, with no way to stop a worker that had finished its job.
Note that March'spermanentis 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 aPidwith 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 (sendreturnsNone), 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, andActor.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 ownshutdownbudget from the
child spec (Worker w shutdown 5000, orinfinity/brutal; the default
is 5 seconds andkillnever consults it). Noterminate-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 asuperviseblock tunes the
delay between repeated restarts of the same child. All three are optional and
default to25 / 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 singlestring_concat_nthat 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
writemap,filteror 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-stylemapexited 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, orMARCH_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 tolet 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 aninc_rc— the constructor's later drop then released the
record owner's string. The one-levelh.nonceread beside it was always
correct. This is what madetest/native/node_discovery.marchdie
intermittently with exit 138 / SIGTRAP: the inlinedHandshake.encode_hello
freedmy_id.node_id, which each node later msgpack-encoded from freed,
reused memory. Pinned bytest/native/nested_record_field_capture(exit 138
3/3 pre-fix, deterministic — heap strings plus allocator churn) and a
post-Perceus TIR snapshot;node_discoverywent from 8/40 to 0/100 crashes
in an interleaved same-runtime A/B. -
from_jsondecodes 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
morederive Jsontypes 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-generatedupdate_json
andrender_jsonare generated only for a module with both aStateand a
Msgderiving Json, which is exactly the case the bug above broke, so
update_jsonsilently returned its input unchanged. Its 161-test suite is
now part of the test run. -
A missing
Showimpl is no longer reported as an ambiguity.
println(x)on a type with noShowfailed to compile with "ambiguous
interface-method call toshow: 20 implementations are in scope", listing
twenty types none of which was the argument's, and never mentioning the
derivethat 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/Direrrors are realFileErrorvalues 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_rmdiranddir_rm_rf— are typed
Result(_, FileError), but the compiled runtime put a bare string in the
Errpayload instead of aFileError, so a natively compiled program that
inspected the payload read a string's header as if it were aFileError.
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 reportsIsDirectory, and
removing a non-empty directory reportsNotEmpty. This continues the fix
that landed forfile_openalone. (Printing such a payload with
to_stringstill 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 pathforge testandmarch file.marchactually
run, so it is where most users meetActor.call. (The interpreter still
cannot abort a handler mid-run: a call whose deadline passes waits for the
handler to finish before returningErr.) -
scripts/run-tests.shnames the suite that failed. Every runner failure
collapsed into oneFAILEDbit 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 Successfuland 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 asNOT RUNinstead 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 unrelatedUnknown 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. -
docbefore atypeorproof capsays 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.docattaches tofn/pfnonly; type
andproof capdeclarations have no doc slot. -
Native arrays carry a header tag.
native_arr_allocleft the tag at0,
an ordinary ADT constructor index, so every generic walker treated a
NativeU8Arras a cell of pointer fields and read its payload as pointers.
Arrays now carryMARCH_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 endmay be called inside a predicate
({Int | 0 <= _ && _ < size_x()}) and is checked exactly as the literal
128would 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/*.marchandstdlib/*.marchfinds 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-levelfn's parameter and return, a non-adoptableimplmethod'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.mdand filed asspecs/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;transientaccepts 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, anexternor 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 --contractsnow 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 anexternall 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 allInt,FloatorBool— 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 likefn forward(yaw, pitch) : Vec3can 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
externsignature keeps the boxed representation program-wide (see
docs/ffi.md), andMARCH_NO_UNBOX=1restores 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 orexternwrapper as trusted.
--no-optdowngrades a failure to a warning naming the flag, and a
TRMC-eligible failure under--no-trmcsays so. The language server reports the
failure at the function name, shows✓ no_allocwhen the contract holds,
and offers an "Add@[no_alloc]" quick fix;march --compile --report-contractsandforge fix --contractsinsert the attribute on
functions the compiler has verified.Tagged(_, NoAlloc)andRealtime
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 asBytes— 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 insidewith_capand 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 fromcap_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.Mutis not (everyvault_*
is polymorphic, and a dictionary field would need rank-2 types), and
operations inside actor handlers are not reached. -
forge run FILEruns a single.marchfile, 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 asSystem.argv(); the compiler gainedmarch --argsto make that
work for interpreted runs too. -
MARCH_PIN_MAIN=1runsmainon the OS main thread. Themaingreen
thread is pinned to scheduler 0, which lives on the thread that started the
runtime, while the other scheduler workers keep running Tasks andpmap.
For libraries that require the process main thread (Cocoa, GLFW window
creation on macOS), which previously forcedMARCH_NUM_SCHEDULERS=1and
lost all parallelism. Opt-in; the default is unchanged. Procs spawned by
mainare 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 setMARCH_NUM_SCHEDULERS, and nothing
said so. The default is now one scheduler per online CPU, clamped to
MARCH_MAX_SCHEDULERS(64).MARCH_NUM_SCHEDULERS=Nstill pins a count and
=autostill 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-boundpmap_nprogram 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.
sysconfreports 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=0and--cpus=1
each resolve to 1 scheduler whilesysconfstill 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_SCHEDULERSwas 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=14ran 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_nwork looked like it plateaued at ~4x when it had simply
stopped being given threads. The variable is now a real request, honoured up
toMARCH_MAX_SCHEDULERS(a compile-time bound, default 64, that sizes the
runtime's scheduler table);MARCH_NUM_SCHEDULERS=autoasks 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-boundpmap_ntasks, 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 wholematch— a
borrowed parameter, or any value still needed after the match — and then
used only at borrowed positions was bracketed by aninc_rc/dec_rcpair
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 asChunk(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. -
NativeArrayreads borrow their array.NativeArray.get_*,.length,
.sumand.to_listwere 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 allInt/Float/Booland
whose arity is 2-4 is held in registers rather than on the heap. Building one
inside anifor amatcharm 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- 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 anOption
(or another niche-encoded type) still leaks, with or without a branch.
- The join now releases it. Building the same value straight-line or in a
-
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_rcwas 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) | Zis encoded as a niche, whereW(x)isx— 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. GenericOption(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=1now reportlive_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
superviseblock'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
reachingIO.ConsoleandIO.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-opsprinted every multi-argument operation in the wrong
spelling:(A, B) -> C, which in March is a function of one tuple
argument (Tuple.applycalls itsf : (a, b) -> casf(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 "expectedOpsbut gotOps". A record type declared with type
parameters is reached with zero arguments in some positions (aproof cap X with Tclause, 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 — "Opsis 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 labelledstatic: trueand 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 barealpine:3.21— ~20Error relocating ... symbol not foundlines beforemain. 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 producedmarchandforgeinside bare
alpine:3.21anddebian:stable-slimbefore publishing, so a dynamically
linked archive can no longer ship. One consequence, on the Linux prebuilts
only: a static musl binary has nodlopen, so interpretedexternFFI does
not work there and the REPL's--jitfalls back to the interpreter.
Interpreting,--compileandforgeare unaffected, as are macOS builds and
builds from source. -
march --compileworks on musl (Alpine). Two separate breakages, so the
musl-targetedlinux-aarch64prebuilt could interpret March but not compile
it.runtime/march_runtime.cincluded<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-inMARCH_DEBUG_OOMforensics (March's
own stack trace is built from its own frame table and is unaffected).
Then the link failed ongetcontext/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
matchwas 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,
whereyisx— was treated as structurally smaller and accepted.
Arithmetic reduction on such a binder (f(y - 1), as infib) is still
accepted, since that genuinely decreases. -
A
letwhose 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.mlnever 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
ownperf/non-tail-calldiagnostic 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 buildandforge testno longer share a compilation-cache entry.
--testemits a different program (a test-runner entry point) but was absent
from the cache key, so whichever ran first won:forge buildcould hand you
the test runner, orforge testthe 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 typingBin a module declaring
BTree/Branchoffered all ofBase64andBigIntfirst. 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 answeredtextDocument/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)
becomingSomea)). Only genuine open/close pairs —~Hsigil tags — are
linked now; renaming a symbol remainstextDocument/rename, which is
explicit and already supported. -
The "cannot access field" diagnostic (accessing
.fieldon an
Option/Resultvalue 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 suggestedmatch 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 unresolvedOption/Resultpayload.Option(l2)— meaningless to
the reader, and different from run to run — now prints asOption(_). -
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 ofdid_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.tit 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
destructuringtype 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'sleftfield mentions
Option(Tree), which requires expandingTreeagain, forever. A guard now
stops re-expanding a record already being expanded on the current path. -
The resulting "expected
Treebut gota" 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 likeaory55leaking 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 emptybin/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
withoutblake3_neon.con aarch64, which links but leaves
blake3_hash_many_neonundefined 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_nover 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 apthread_key_tdestructor 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.cgains a multi-threaded case that allocates on
eight threads, lets them exit, and frees each thread's objects from a
different thread. -
unreflectable-predicateno 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 onnreachesn - 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-predicatenow names the specific sub-expression that failed
to reflect (for examplethe 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
letwhose right-hand side is a literal or an arithmetic expression now
carries its value into refinement checking.let n = 0followed by a call
that requires a positive argument reports the same definite violation that
passing0directly already did, instead of going undecided because nothing
constrainedn. Calls,ifexpressions and floats are not admitted. -
A binder in a constructor pattern now inherits the constructor an earlier
unguarded arm's sub-pattern excluded. AfterCons(x, Nil), thetin a
laterCons(_, t)is known not to beNil, 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
singlesolver-undecidedmessage 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) — withsolver-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-reportnow 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
atstdlib/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-aarch64release 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 withproof cap Live with Ops, attach with
cap_impl(cap, dict), and read it back withcap_dict(cap), which yields an
OptionwhoseNoneis "no dictionary, use the ambient implementation" —
what every capability written so far does.cap_narrowcarries a dictionary
across attenuation. Supplying a dictionary is gated exactly likemint_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, sinceOption's niche
encoding makescap_dictthe 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 ahelp:block, carrying a machine-applicable
fix soforge fixrewrites 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
undercap verified. Cost is confined to promotion, which is rare: a program
with no demonstrated failure runs no inference at all. Only evaluator
failures prefixedpanic:,todo:, orunreachable:count as a panic for
this confirmation; any otherEval_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_panicdivision 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, sofn scale(x, y): {Int | _ > 100}reportsbut 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.cguarded
its ThreadSafeContext API choice onLLVM_MAJOR_VERSION >= 19, but the C API
actually swappedLLVMOrcThreadSafeContextGetContextfor
LLVMOrcCreateNewThreadSafeContextFromLLVMContextin 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(andmarch repl/ a baremarchREPL) now build and load
theforge.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 theforge runfix.
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 outsidecap verified, "cannot verify"
inside it). Code likefn f(n : Int) : {Int | _ >= 0} do n endno longer
compiles.@[trusted]continues to rescue unproven-but-unrefuted
contracts; it does not suppress a witness-confirmed violation. -
lib/typecheck/typecheck.mlis 1,824 lines smaller (8,272 -> 6,448):
unification and surface-type conversion, session-type projection, declaration
dependency ordering, and the module-levelcapcheckers (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/tirvalues (emit_main_wrapper,target_arch,
reserved_ctor_tag_limit) left over from an earlier.mli-only pass that
could not edit.mlfiles. No behavior change -- the IR oracle is
an exact byte match across 241 programs. -
bin/main.mlis 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-astwriter now live in their ownbin/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.marchruns 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, somatch Logger.Warn do Logger.Debug -> 0 | Logger.Info -> 1 | Logger.Warn -> 2 | ... endanswered0, andHttp.Postmatched the
Http.Getarm. 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=1was always correct. -
REPL (JIT): stdlib ADT values printed as
#<tag:N>instead of their
constructor name;Http.Postnow printsPost. 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 anyHOMEoverride) where~/.cachedid not already exist:
Unix.mkdiris not recursive and onlyEEXISTwas caught, soENOENTon 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
recursivemkdir_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 /
--jitstdlib 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.tmppath 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_namepointing at the path the
file actually lands at. -
--jit: a whole-program run now prunes functions unreachable frommain
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 usingJsonStreamfailed to start
with "Symbols not found: [ _from_json ]". -
--jitand 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. -
--jitand 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. -
--jitand 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 endthenfn g(x) do f(x) end)
failed with an invalid-IR "redefinition of function" error and the new
function was lost ("I cannot findg"). Calls to prior REPL bindings
(fns andlet-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. Eachfn
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
fnat 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.:resetscroll-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.marchreuses 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) endsegfaulted when compiled and failed at the REPL with
symbol not found in flat namespace '_f'(losing the binding); renamingf
to anything else worked.List.map's own parameter is namedf, 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)) ... endfailed at the prompt
withsymbol not found in flat namespace '_g'. The lifted lambda was
compiled into a separate fragment loaded beforegitself; 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. Withtype Color = Red | Green | Blue, bothGreen
andBlueansweredRed; and, because the same wrong constructor tag
reached pattern matching,match Blue do Red -> 1 Green -> 2 Blue -> 3 end
answered1. 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=1was
unaffected. - REPL: values of Option-shaped and single-constructor types printed as raw
words.Some(1)displayed as3,Noneasnull, andSome("hi")took
the REPL down; a usertype T = X(Int) | YshowedX(7)as15andYas
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 thatMarshalwill not write, and the failure was
swallowed, so each REPL launch re-typechecked the whole stdlib and left a
zero-bytestdlib_tcenv_*.tmpfile 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.mdno longer claimsmarch --compileemits a static
binary. It said a compiled March program "can be copied intoscratchor 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-staticon any path: every compiled
program carriesDT_NEEDEDentries for libssl, libcrypto, libz, libblake3
and (host-dependent) libzstd/libbrotli, plus libucontext on musl — verified on
alpine:3.21aarch64 and, identically, on macOS arm64.libblake3is 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 thatscratchand
distroless-staticdo 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
distributedmarch/forgebinaries 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