Lyric
A statically typed application language with a bytecode VM — standalone, and embeddable in a host with a capability-based sandbox.
What changed in v4.0.0 — 2026-08-27
The major (the basket is lyric#121, decided 2026-08-25 and delivered whole). One sentence
carries it: yield works from any call depth, and everything the standard library waits
for is built on that. Coroutines became stackful, std.task turned them into cooperative
tasks, and TCP, UDP, child processes and Ctrl+C are all "a task yields a Wait" — no async
syntax, no threads, no second mechanism. Every language rule landed spec-first
(lyric-spec#26–#28); format 3.6 → 4.0; a 4.0 runtime runs every 3.x module unchanged, a
3.x runtime rejects 4.0 modules — which is the correct answer.
Added
Stackful coroutines. A coroutine is a chain of captured frames, and
yieldworks from ANY call depth beneath a runningresume— a helper can wait so its callers do not have to, with no marker on any signature.yieldis legal in every function; its failures are PANICS, the dynamic half of the rule: no running resume, a value whose type is not the running chain's element type, a native or JIT-compiled frame between yield and resume (Lua's C-boundary rule), andresumeof a coroutine that is already running. Inside a coroutine body the static check stays. Format 3.6 → 4.0: three opcodes (mkcoro,resume,yield); a 4.0 runtime runs every 3.x module unchanged, a 3.x runtime rejects 4.0 modules.std.collections.Deque<T>— the double-ended queue:pushFront/pushBack,popFront/popBack,peekFront/peekBack,length,isEmpty,clear, all O(1) ends over one ring. A pop or peek answers?Tandnullmeans "empty" — the whole truth, so no throwing twin. Deliberately without iteration: a queue is drained, not walked. Written in Lyric, like the rest of the collections.std.task— cooperative tasks over coroutine chains, the scheduler written in Lyric. A task is aCoroutine<Wait>;Waitsays what it waits for (Now,Sleep(ms),Readable(fd)/Writable(fd)forstd.io.net's descriptors,Interruptfor Ctrl+C or the programmaticinterrupt()— while a task is parked there, the signal wakes the scheduler instead of killing the process, one interrupt wakes every parked task, and a run left with only Interrupt tasks waits for it quietly).spawnenqueues,run()drives everything to its end — round-robin when ready, blocking in the module's ONE native (poll, time and readiness in one answer) when not. Sleepers wake in deadline order even when one poll covers several overdue deadlines; ties wake in spawn order. The waiting composes through helpers with no marker on any signature — what stackful coroutines were for.spawntakes the non-throwing type: a task settles its own errors. CarriesosAccess.std.io.net— TCP for tasks, and thenetworkAccessbit reserved since 1.x finally gates something.listen/accept,connect,readSome/write,close, with opaqueListener/Sockethandles; every waiting form yields to the scheduler inside the module, so a server is straight-line code in a task. Silent forms answer whether;OrThrowtwins say why (IoErrorKindgainsConnectionRefusedandAddressInUse).readSome: bytes are bytes, an empty array is EOF,nullis a failure.std.task.poll's descriptor half is real now — select over the module's sockets, an errored socket reporting as readable. And UDP:bindaUdpSocket,sendToone datagram (whole or not at all, no delivery promise — that is UDP),receiveFromwaits and answers aPacket— payload plus sender, the address a reply'ssendTotakes. UDP has no EOF, so an empty payload is a real packet;localPortandcloseare the same names TCP uses, chosen by the handle.std.process— child processes for tasks, behindprocessAccess, the FIFTH capability bit and the first new one since 1.0: starting programs is a new power, not anosAccessrefinement, so a host that granted environment questions has not thereby agreed to it.start/startOrThrow(opaqueChild),readSomeOut/readSomeErrspeakingreadSome's three truths, queuedwriteto stdin withcloseStdinas the filter's EOF,waitfor the exit code,kill,close. Every waiting form yields to the scheduler through the child's notify descriptor — the host pumps the streams and posts a datagram per event, so a child is just another thingpollwatches.std.random.secureRandom—countbytes from the operating system's cryptographic source, the ONE non-deterministic draw in an otherwise reproducible module, and marked as such. Capability-free: entropy reaches no file, clock or network, so an embedded guest that can draw good dice costs a host nothing.Randomstays seeded-by-the-caller.std.bytes— slicing and searching overuint8[], the tooling wire protocols stand on:slicewithstring.substring's exact edge behavior,indexOf/indexOfFromanswering-1like their string namesakes. Deliberately absent:concat(a + balready joins arrays — a function would be a second name for the operator) and subsequence search (a line ends in one byte; the protocol that needs more can ask). Written in Lyric, capability-free.Instant.fromIso— the round-trip halfiso()never had:fromIso(x.iso())isx, pre-epoch instants included. Strict on purpose — exactlyYYYY-MM-DDTHH:MM:SS.mmmZ, UTC only, and a day that does not exist (February 31st) is refused rather than carried into March.nullanswers whether;fromIsoOrThrownames the field that broke (TimeError, §9.0's twin doctrine). Written in Lyric, the exact inverse ofiso()'s civil-from-days.
Moved
- The path helpers left
std.io.fileforstd.io.path—joinPath,fileName,parentDir,extension,stem,withExtension,isAbsolute, unchanged in behavior. The move is the point: a path is a string and touches no disk, so it now costs NO capability — a sandboxed guest can assemble the path it hands its host without being grantedfileAccess. The old names inio.fileare gone, not doubled; the 4.0 major pays the rename.
Removed — the clocks came due
listDiris gone, as itsuntil = "4.0"promised since 3.7: it answered an empty array to both "empty" and "unreadable".entriessaysnull,entriesOrThrowsays why. Modules compiled before 4.0 still bind the native.- The inward opaque cast outside the declaring module is an ERROR (
LYR-SEM0093), as 3.8 announced: a handle is issued, not assembled — the declaring module offers a constructor function for values rebuilt from stored numbers.
Fixed
- A module-qualified call sees the whole overload set. Since 3.0,
net.localPort(x)through a module alias bound only the FIRST function of the name and refused the call with a type mismatch, while the selective import chose correctly — overload resolution depended on the import style, against §4.3a. Surfaced by UDP'slocalPort/closesharing their TCP names; the qualified route now collects the same candidate set.