Lyric
A statically typed application language with a bytecode VM — standalone, and embeddable in a host with a capability-based sandbox.
What changed in v2.4.0 — 2026-08-21
M31: two additive answers to what an embedder found in production. A host can bound how long
foreign code runs, and an attribute argument may name its value instead of repeating it. The
bytecode format stays 3.3, and a module compiled by 2.3 loads unchanged. Two conformance
cases activate with this release (//! since: 2.4.0); the suite stands at 90.
Added
An instruction budget for embedded code.
new ExecutionBudget(2_000_000), handed toInstantiateor to a call, stops a script that will not stop by itself:try { instance.CallVoid("onUpdate", budget, 0.016); } catch (ScriptBudgetException) { mods.Disable(instance); }A capability decides what a script may REACH;
while (true) { }needs none, and until now nothing bounded it. The budget counts instructions, not milliseconds, so the same script under the same limit stops at the same instruction on every machine — a wall clock cannot promise that, and a replay needs it.Consumedafter a call that fits is how a host arrives at a number worth setting;Reset()refills; one budget passed to several calls bounds a whole frame across several scripts, and a host function calling back in draws from the same one.Instantiatetakes one because the constant initializer runs there — a module-levellet x = spin();used to hang the load itself, before the host had called anything.The stop is
LYR-CAP0002and arrives as a panic: the script cannot catch it and nodeferruns behind it, which is what makes it worth having against code you do not trust.ScriptBudgetExceptionderives fromScriptPanicException, so a host written before this keeps catching what it caught — the separate type is there to tell "this script is broken" from "this script was still working". What it does NOT bound is host time: a native of yours that blocks for a second is charged one instruction.An attribute argument may name its value. Beside a literal, an argument may be a
letwhose initializer is one — through a chain of them, across modules, imported selectively or written module-qualified, and astatic leton a type the same way:pub let CLEARED = "tetris.cleared"; @On { event = CLEARED } pub fn onCleared(): void { }This is what lets a program publish a vocabulary — event names, kinds, versions — and have its consumers checked. Repeating the raw string was the last place where a typo produced a handler nobody ever calls, which is the fault class attributes closed for entry points.
It is not constant folding:
let LIMIT = 1 + 2;stays rejected, because the value has to stand in the source and this language folds nothing anywhere. One edge worth knowing: the named form is slightly stricter than the written one, since@A { n = 5 }adapts the literal to a narrow field whilelet N = 5;is already anint— a narrow field wantslet N: int32 = 5;.