M Mozhi v3.7.0 code
v3.7.0 — runtime stability, networking, performance

What's new in v3.7

Every feature on this page is implemented in the interpreter and covered by the automated test suite — the examples below are executed by tests/lang_features_test.sh and tests/http_client_test.sh on every make test run.

Function arity is safe: RUNTIME010

Calling a function with the wrong number of arguments used to read past the end of the argument array (AddressSanitizer measured an 8-byte heap-buffer-overflow) and could crash the interpreter. It is now a controlled diagnostic that names the function, both counts, and the call site:

fn add(a, b, c) {
    return a + b + c
}
print(add(1, 2))
error[RUNTIME010]: wrong number of arguments
  --> example.mz:4:11
 4 | print(add(1, 2))
   |           ^
   |
reason: function 'add' expects 3 arguments, got 2
docs:   https://crossberry-in.github.io/mozhi-doc/errors/RUNTIME010

The same validation covers nested and recursive calls, anonymous fn expressions, constructors, and module-member functions. Too many arguments are rejected too (expects at most 2 arguments, got 4).

Default parameter values

fn greet(name, greeting = "Hello") {
    return greeting + " " + name
}
print(greet("Mozhi"))           # Hello Mozhi
print(greet("Mozhi", "Welcome")) # Welcome Mozhi

Semantics, verified by tests:

  • Defaults are evaluated once per invocation, in parameter order, inside the call environment — so a default may reference earlier parameters: fn f(a, b = a * 2) with f(5) returns 15.
  • Array, map and call-expression defaults work: fn collect(vals = [1, 2], cfg = { mode: "fast" }).
  • An omitted argument is distinct from an explicit null — g("M") uses the default, g("M", null) binds null.
  • A required parameter after a defaulted parameter is a parse error.
  • Missing required arguments remain a RUNTIME010 error.

Variadic parameters: ...rest

fn log(level, ...items) {
    print(level)
    for item in items {
        print(item)
    }
}
log("INFO", "started", 10, true)
log("EMPTY")                    # zero collected arguments is fine

The rest parameter collects surplus arguments into a standard Mozhi array, must be the last parameter, and only one is allowed. It composes with default values: fn f(a, b = 2, ...rest).

Safe navigation ?. and null coalescing ??

let task = req_opts?.task
let model = config?.provider?.model ?? "default-model"

Defined semantics (all covered by regression tests):

  • a?.b returns null when a is null; otherwise it behaves exactly like a.b.
  • A missing map key yields null (consistent with map indexing), so m?.missing is null and cfg?.provider?.model chains safely.
  • left ?? right returns left unless it is null — false, 0 and "" are not null and are returned as-is.
  • The right-hand side is lazy: it is evaluated only when the left side is null (verified with a side-effecting function).
  • ?? binds at the same precedence as or, left associative; ?. binds like . at call precedence. No existing operator precedence changed.
  • Plain . on null still raises RUNTIME004 — safe access is opt-in.
  • A lone ? produces a targeted illegal-character hint naming ?. and ??.

String method aliases

Backward-compatible aliases dispatch to the same implementation, so behavior and error messages are identical:

print("ABC".to_lower())      # abc      (alias of lower())
print("abc".to_uppercase())  # ABC      (alias of upper())
print("hello".length())      # 5        (alias of len())
CanonicalAccepted aliases
lower()to_lower(), to_lowercase()
upper()to_upper(), to_uppercase()
len()length()

Casing is byte-wise (ASCII), matching the existing upper()/lower() implementations; multi-byte UTF-8 letters pass through unchanged. Calling an alias on a non-string receiver reports the same type error as the canonical method.

64-bit integers and numeric semantics

Integers are now 64-bit signed. The classic accumulator sums are exact:

total = 0
i = 1
while i <= 1000000 {
    total = total + i
    i = i + 1
}
print(total)                    # 500000500000 (was 2147483647 on 32-bit builds)

Rules (documented in the CHANGELOG and covered by tests):

  • Integer arithmetic is exact within the signed 64-bit range (−9,223,372,036,854,775,808 … 9,223,372,036,854,775,807).
  • +, - and * results that exceed the range promote to floating point (no silent wraparound).
  • Integer division by zero remains RUNTIME002; the corner case INT64_MIN / -1 also promotes to float instead of trapping.
  • Integer literals beyond the range are a parse error; write -9223372036854775807 - 1 for the minimum.
  • Integral floats print with an explicit .0 (500000500000.0, Python-style) instead of 5e+11; non-integral floats keep the compact %g form.

Recoverable HTTP client

Network failures no longer abort the program. Every http.get / http.post / http.request returns one consistent result shape:

import http

let res = http.request({
    url: "http://127.0.0.1:8089",
    timeout: 5,              # total request timeout (seconds, default 15)
    connect_timeout: 3       # TCP connect timeout (default 10)
})

if res.ok {
    print(res.body)
} else {
    print(res.error)         # e.g. "Connection refused (connecting to 127.0.0.1:8089)"
}
print("still running")       # a failed request never stops execution

The result map:

keysuccesstransport failureHTTP 404/500/…
oktrue (status 200–299)falsefalse
statusHTTP status0HTTP status
error""failure reason""
bodyresponse body""response body
headersresponse headers mapempty mapresponse headers map

DNS failure, refused connection, timeouts, unreachable hosts and TLS errors all flow through this schema; a failed request does not affect later independent requests. Non-2xx statuses are responses, not exceptions — status, body and headers are filled. method is now optional and defaults to "GET". Responses are capped at 64 MB.

security
HTTPS is native TLS (when the binary was built with OpenSSL available, the default for source builds): SNI, certificate chain and hostname verification are always on, and trusted roots come from the operating system store. Certificate or handshake failures return the same recoverable { ok: false, ... } result. The downloadable release binaries are built with zig and have no TLS stack — there https:// returns a recoverable error telling you to use http:// or a TLS-enabled build. No curl subprocess fallback is used.

Package registry freshness

mz search and mz install fetch the registry with Cache-Control: no-cache plus a unique nocache query parameter, so packages added to the registry are visible immediately instead of waiting for CDN caches to expire. Registry fetches keep their 8-second timeout and the remote → last-good-cache → bundled-snapshot fallback. This only affects metadata freshness — package downloads and integrity checks are unchanged.

Performance

The evaluator was optimized with measurable, behavior-preserving changes (object shell pooling, immortal null/true/false singletons, reference-shared immutable scalars, interned environment names). The numbers below come from the shipped bench/ suite — median of 7 runs, warm cache, output verified before timing (x86_64 Linux, same algorithm in both languages, Python 3.12.15 as reference):

workload3.4 baseline3.7.0speedupvs python3
float loop, 1M iterations252.2 ms122.4 ms2.1×0.79× (faster)
integer sum, 1M iterations218.7 ms109.4 ms2.0×0.69× (faster)
counter loop, 1M iterations215.2 ms106.2 ms2.0×0.84× (faster)
function calls, 200k89.8 ms45.2 ms2.0×1.02× (level)
recursion, fib(20)9.9 ms6.1 ms1.6×0.48× (faster)
arrays, 100k push+sum43.2 ms23.2 ms1.9×0.67× (faster)
string concat, 20k24.6 ms14.2 ms1.7×0.85× (faster)
hello world (startup)2.9 ms2.8 ms1.0×0.22× (faster)

Against the 1056 ms one-million-iteration float-loop baseline measured at the start of this release cycle, the same benchmark now runs in about 122 ms — roughly 8.6× faster. (After-numbers are the final 3.7.0 tree, which also carries the ecosystem work — reference-counted environments and by-reference returns.) Reproduce with bash bench/run_bench.sh from the source repository.

Known limitations

  • ** evaluates through double-precision math; the result stays an integer while it fits in int64 and promotes to float beyond that.
  • String casing methods are byte-wise (ASCII); multi-byte UTF-8 letters are passed through unchanged (unchanged behavior, now covered by tests).
  • DNS resolution has no portable timeout; a hung resolver can outlast the configured timeout (TCP connect, TLS handshake, and reads all do honor it).
  • Release (zig-built) binaries have no TLS; https:// requests there return a recoverable error. Source builds with OpenSSL have native TLS.
  • There is no bytecode VM yet — the interpreter remains a tree-walking evaluator, now with allocation and lookup costs reduced. Startup, parse, and evaluation remain on one execution path that is validated by the same test suite as before.