Changelog
Version history
All notable changes to Festina, starting from version 0.1.
Versions follow major.minor (a
major.minor.patch form is used if a patch-only
release is ever needed); dates are in YYYY-MM-DD.
This changelog starts from the point version tracking was introduced — it is not a reconstruction of the project's earlier history. The full round-by-round design and implementation record predating 0.1 lives in claude.md.
0.442026-09-02 #
Added
ascii— a one-byte-per-character string type, alongsidetextrather than replacing it. Because a character is a byte, the character count is the byte count, so it lives in the value's own header and.length,s[i]andcharCodeAt(i)are all O(1) reads instead of the UTF-8 walkstextneeds. Indexing allocates nothing at all — a one-characterasciicomes from a table of 128 immortal singletons. Supports.length,s[i],.charCodeAt(i),.slice(start, end),+,==/!=, interpolation,.toText()andtext.toAscii(). A quoted literal assigned to anasciiis converted at compile time, so a non-ASCII literal fails to build;toAscii()answersnullat runtime for text that isn't representable. Measured on a character-by-character scan:textis quadratic (50 ms at 10.4 KB, 201 ms at 20.8 KB, 800 ms at 41.6 KB),asciilinear (21.5 ms for 4.16 MB — a hundred times more input thantextneeded 800 ms for). See api.md's "ascii— one byte per character".pool.postMessage(x)— no index — auto-selects an idle instance. Routes to whichever pool instance currently has nothing queued and isn't mid-handler, falling back to plain round-robin when every instance is busy rather than ever blocking the caller..callback(fn)chains onto it exactly as it does on an indexedpool[i].postMessage.pool.giveRequest(r)— no index — same auto-selection aspostMessage. Hands the live connection to whichever instance is idle right now, falling back to round-robin, exactly like barepostMessageabove.on request use NAME. Sugar foron request(req:http?) { NAME.giveRequest(req) }—NAMEcan be a singleton thread or a pool (in which case the bare, auto-selectinggiveRequestabove applies). Desugars entirely at parse time;useis not a reserved word anywhere else. Every other pool method still requires an index.drawImageaccepts animg?source. Every form of the canvasdrawImage(...)and ofimg.drawImage(...)takes a manually-managedimg?where it saysimg— compositing only reads the source for the duration of the call and keeps no reference, so nothing changes hands. A layer painted by a worker thread can now be drawn straight onto the canvas with noclip()copy first. This is the one read-only exception toT?being a distinct type; assignment and every other call site are unchanged.req.send()reuses a keep-alive connection to the same host:port instead of opening a fresh one every call (plainhttp://, POSIX only). One small connection cache per OS thread, no locking needed; entirely transparent otherwise, including a dead reused connection being silently replaced rather than surfaced as a request failure.text.charCodeAt(i)andint.toChar().charCodeAtreads the Unicode scalar value of thei-th UTF-8 code point (not byte, and not a UTF-16 code unit the way JavaScript's owncharCodeAtsometimes is — matching hows[i]already indexes by code point);toCharis the inverse, UTF-8 encoding a code point into a one-charactertext. Both follows[i]'s own "test, don't fail" rule: an out-of-range/negativecharCodeAtindex, or atoCharcode point with no valid encoding (negative, above0x10FFFF, or in the UTF-16 surrogate range0xD800–0xDFFF), answersnullrather than crashing.festina update. Pulls the latest source into this installation's own git checkout and fast-forwards to it (git fetch+git merge --ff-only) — there's no separate release pipeline, the runningfestinais this checkout. Refuses, with no changes made, on a dirty working tree, a detachedHEAD, genuinely diverged history, or an installation that isn't a git checkout at all (e.g. a packaged binary) — it never force-resets over local work.text.lengthandblob.length.text.lengthis the number of UTF-8 code points (the same units[i]/charCodeAt/split('')already use, not bytes) — a real scan, since UTF-8 is variable-width.blob.lengthis the exact byte count, an O(1) stored-field read. Both read-only, likearr[T].length.match EXPR { 'Tag' { ... } ... default { ... } }. Exhaustiveness-checked dispatch on an enum's member (or any expression's own static type) — every tag string is the exact onetypeofalready returns, and leaving one uncovered with nodefaultis a compile error naming it. Pure sugar: desugars entirely, at compile time, into the equivalenttypeof/if/else ifchain, so a compiled program pays nothing for it beyond what a hand-written chain already costs. The subject must be a plain variable or field access (not a call or any other expression that could run code), so it's only ever evaluated once regardless of arm count.- A disk-persisted cache for the lex/parse step of
festina compile, keyed by each imported file's exact content (not mtime) plus a hash of the compiler's own grammar — an unchanged file across two compiles is loaded from cache instead of re-lexed and re-parsed; changing one file's content invalidates only that file's own entry. Any cache failure (missing, corrupt, a festina upgrade) degrades silently to an ordinary fresh parse — correctness never depends on it working.FESTINA_NO_PARSE_CACHE=1disables it entirely.
Fixed
- A JSON-parsed struct is now a valid member of its own enum.
.toStruct(T)/.toArr(T), whereTis one of an enum's members, built the struct without the type tag every other way of building one writes. A successful parse produced a value that crashed the moment it was used as its enum; a failing parse freed the half-built value at the wrong offset. Both are fixed; nothing changes for a struct that isn't an enum member. - A stray character in a source file reports a normal compile error. Any character the lexer doesn't recognise — including an unterminated string, the most likely typo — used to print a Python stack trace instead of
file:line:col: error: .... An unterminated string and a$outside a template string now say which mistake they are. - A query row is reference counted, so every way of using one is now safe. A row used to be a bare borrow into the array that owned it, so a row outliving its array was either a leak or a crash depending on the shape: returning one read out of a function's own local array was a use-after-free, and reading a column off a call-result array (
rows()[0].name) leaked the whole array. Rows now carry the same refcount header every other managed type has, so binding, aliasing, passing, returning, storing in anarr/map, andfreeall behave exactly as they do for a struct. Rows still alias —p.name = 'x'is visible through every binding of that row, unchanged. .lengthoff ablob/text/asciifield no longer leaks the object it came from.make().someBlob.length, and every shape like it — a struct field or a query-row column — kept the whole struct or row alive. Only thearr[T]case ever released it. Answers are unchanged everywhere; only what gets reclaimed afterwards changed.- A
throwout of a.sort()comparator no longer leaks the sort's scratch buffer. The comparator is ordinary Festina code, so it can throw, and the throw jumps straight past the runtime's own sorting frame — skipping that frame'sfree(). Festina-side locals were already released on the way out; this was the one piece of memory the runtime itself was still holding..forEach()was audited too and never leaked (it allocates nothing), and a throw out of a timer or event handler cannot reach atryat all — it ends the program, as before. Error-path only; nothing changes on a sort that doesn't throw. T? x = <a fresh call>now compiles forblobandascii. The fresh-construction escape hatch stripped the?off the declared type only for the manually-manageable dataclasses, missingPrimitiveType— which isblob's category — soblob? x = makeBlob()was rejected while the structurally identicalCircle? c = makeCircle()compiled. Broken since the escape hatch was introduced;ascii?inherited it.T? x = <an existing plain binding>stays rejected, unchanged.- A copied
headersmap no longer duplicatesHost/Content-Length/Connection/Transfer-Encodingon the wire. These four are always computed by this runtime itself; forwarding a real request's ownreq.headersinto an outbound request, or a real response's ownheadersback out (res.headers = upstream.headers— a reverse proxy's most natural shape), used to append the caller's copy on top of the runtime's own, producing the same header name twice. A strict server (Go'snet/http) hard-rejects a request with twoHostlines outright. return <text-expr>from ablob/img/audfunc no longer leaks the intermediate text. The implicit text-to-handle load conversion at areturnsite (blob func f() { return `path${x}` }) passed the fresh path text tofestina_blob_open/ festina_load_image/ festina_load_audio— all three copy what they need internally — and never freed the original afterward. The ordinaryblob b = <text-expr>declaration form was unaffected; only areturnof a computed path was.
Changed
ascii.charCodeAt(i)no longer costs a function call. It compiles to a null check, a header load, a bounds check and a byte load emitted inline where the expression is used, so a character-by-character scan loop contains no call at all. The runtime function it replaced was deleted rather than kept unused. Behavior is unchanged in every case, including thenullanswers for a null receiver, a negative index and an index past the end. On thechar_scanbenchmark this took Festina from 24.8 ms to 13.6 ms — ahead of equivalent Rust (16.3 ms) and Go (14.8 ms) loops indexing raw bytes, where it had been ~1.7x behind both.s[i]still calls into the runtime: its result points into the immortal singleton table, and reaching that from emitted IR would mean hard-coding the C struct's layout.- Building a string one piece at a time is O(n), not O(n²).
s = `${s}...`ands = s + ...(any number of further pieces: literals, other variables, plain field reads) now grows's own buffer in place with a compiler-tracked length instead of copying the whole string into a fresh buffer on every step. Thestring_concatbenchmark's 15,000 appends move a few kilobytes instead of ~112 MB. Nothing observable changes: the pattern is only taken whensis a plain text variable, the pieces cannot run user code, andsappears exactly once, at the front. - A
.wasmthat never touches a database is 31 KB, not 1.47 MB. The wasm32-wasi link now uses link-time optimization across the program and the core runtime and strips the sysroot libc's debug sections. The vendored SQLite was kept alive only bymain()'s closingfestina_db_close()on a null handle — that call is no longer emitted for a program with notable/sqlite(), and the linker drops all of SQLite. A program that does use a database still gets all of it (about 1.1 MB). Smaller modules also load faster. See wasm.md's "Binary size". - Solid shapes are drawn without a rasterizer. An opaque flat-colour
drawRect/drawCircle/drawPixelat an integer position (nofillAlphabelow 1, gradient, border, scale or rotation) is written straight into the pixels, on the canvas and on animgalike; circles come from a per-radius coverage mask Cairo rasterizes once, blended with pixman's own arithmetic so the result is byte-identical to before. The canvas benchmark's 40,000-shape frame went from 35 ms to 7 ms; the layered-canvas benchmark's fourimg?layers from 84 ms to 8 ms on one thread. Everything outside that contract still goes through Cairo unchanged.FESTINA_NO_DIRECT_FILL=1switches the direct path off. - Fresh image surfaces are faulted in when created, not one page at a time on first touch — the reason four threads painting four new
img?layers ran no faster than one (first-touch page faults serialize across a process's threads). The layered benchmark's four-thread run went from 62 ms to 7 ms. img.clip(),saveCanvas()-to-imgandimg.resize()copy their source with Cairo's SOURCE operator instead of blending it OVER a transparent surface: the same pixels, a straight copy.- The per-call colour form of
drawCirclenow produces exactly the same pixels as thefillStyle()form (it used to tessellate where the plain form stamped a cached mask, 1/255 apart on a few edge pixels at larger radii).
0.432026-09-02 #
Added
- A compiled
.wasmruns in a browser tab.runtime/wasm/ships a dependency-free WASI Preview 1 host of this project's own (festina_wasi_browser.js), a Web Worker that runs a program on it, andbrowser.html?wasm=program.wasm, the smallest page that does — stdout/stderr stream into the page andwindow.festinaResultholds the exit code, output and every file the program wrote to its in-memory sandbox.node runtime/wasm/run_wasi_js.mjsruns the same host outside a browser. Tested through the host under Node and in headless Chromium (files, directories, SQLite, timers,argv, exit codes). See wasm.md's "In a browser". - The Windows CI job drives a real window: a new test finds the compiled program's Win32 window and posts mouse, keyboard, resize and close messages to it, asserting every input handler's output.
- A browser-side wasm benchmark.
benchmarks/runs the five wasm.md programs, compiled from Festina, C and Go, inside headless Chromium on the project's own WASI host, timing compile, instantiate and run separately withrun_wasm_browser_benchmarks.py performance.now()inside the worker. Results are in wasm.md's "In a browser: Festina vs C vs Go".
Fixed
try/catch/throwworks on macOS, and no longer crashes after a catch on Windows. Atryis a direct call to libc's ownsetjmpnow and athrowis libc'slongjmp, replacing LLVM's SjLj intrinsics — which have no AArch64 lowering (macOS rejectedtryoutright) and a broken x86_64 Windows one (the catch ran, then every local read as garbage). wasm32-wasi is the one target left without it (wasi-libc has no setjmp/longjmp). AddressSanitizer can instrument atry/catchprogram now, too.- On Windows, a program whose only listening port belongs to a
threadno longer exits the moment its top-level code finishes (WSAPollrejects an empty fd set where POSIXpollsleeps). - On Windows,
blob.append()is an atomic append (FILE_APPEND_DATA), so two threads appending to one file no longer overwrite each other's bytes. - The Windows CI job is green: the four remaining failures were tests assuming
/bin/sh,/bin/echooraptexist, fixed in the tests. - A
throwno longer leaks the locals of the functions it unwinds through. A function that merely called something which eventually threw — notryorthrowof its own — used to skip its scope-exit cleanup entirely (the one documented leak of the try/throw mechanism). In a program containing atry, every managed local is now registered on the runtime's per-thread cleanup stack as it is bound, and every call site's owning argument temporaries for the duration of the call; athrowreleases everything above the catchingtry, newest first. 0 bytes leaked under AddressSanitizer and Valgrind through three frames, every kind of local, a rethrow and a JSON failure. A program with notryis unchanged; one with atrypays about 8 ns per managed local binding. - The JSON nesting cap is the parser's own (1000 levels) rather than a side effect of the cleanup stack's size:
JSON nested too deeply (more than 1000 levels).
See claude.md #235-#238.
0.422026-09-02 #
Added
- An
imgis now a self-contained drawing target (uraikus/festina#93) — three groups of methods mirroring the canvas calls name-for-name, each touching only the image it is called on (so they work from a worker thread too):- A per-image transform and state stack:
img.translate(dx, dy),img.rotate(degrees),img.scale(sx, sy),img.resetTransform(),img.saveState(),img.restoreState(). Identity from creation, independent of the canvas's transform, applied to everything drawn, cleared or composited onto that image. The stack holds the image's transform only; style state stays with the canvas'ssaveState(). - Clearing to transparent:
img.clear(),img.clearRect(x, y, w, h),img.clearCircle(x, y, r),img.clearPixel(x, y)— alpha 0, so a later draw underneath shows through.clear()ignores the transform likeclearCanvas(); the region forms honour it. - Compositing:
img.drawImage(src, x, y)andimg.drawImage(src, x, y, w, h)— through the destination's transform, honouringfillAlpha; drawing an image onto itself copies it first.
clearCanvas,drawImagein, draw,saveCanvas,clip) at two window-sized copies per stamp. - A per-image transform and state stack:
Fixed
- The per-call colour forms (
drawRect(..., color[, border]),drawCircle(..., color[, border]),drawPixel(..., color), canvas andimgalike) no longer write the global fill/border state around each call — a data race when a worker thread painted its own layer with them while main drew anything with a colour of its own. Same results, computed locally.
Documentation
- api.md's Images table lists every
imgmethod (including thegetPixelColorrow it was missing anddrawCircle's colour forms), with a new "An image as a layer" section;examples/layers.fuses.clear()and a per-image transform for its HUD layer.
See claude.md #234.
0.412026-09-02 #
Fixed
.toStruct()/.toArr()work again under--target=wasm32-wasiand on macOS, and their error path works again on Windows. 0.36's partial-parse leak fix put asetjmpcatch frame inside every generated JSON parsing function, which made any program that parses JSON a "usestry" program — rejected outright on the two targets with no SjLj lowering, and silently broken on Windows (the catch never ran). The parsing functions now register what they are building on a per-thread cleanup stack in the runtime, whichthrowunwinds on its way to the catchingtry: plain portable C, nosetjmp, and the per-call overhead 0.36 added is essentially gone (100k-object parse benchmark: median 233 → 225 ms; 223 ms before 0.36).- A duplicate
textkey whose second value fails to parse ({"name":"a","name":5}inside atry) no longer double-frees the first value. - Trailing data after a complete JSON value (
'{"id":1} extra'inside atry) no longer leaks the parsed value. - A self-referencing struct nested pathologically deep (hundreds of thousands of
{"next":levels — reachable throughreq.toStruct()on a network body) now throwsJSON nested too deeplyinstead of overflowing the C stack. - Windows: the HTTP runtime compiles again.
<pthread.h>was only included on POSIX while a mutex added in 0.25 used it on every platform; everyopenPort()program failed to build on Windows. - Windows:
festina compile --target=wasm32-wasi tool.wasm.fno longer names its outputtool.wasm.exe.
Changed
- CI on
mainhad been failing on all three platforms; besides the fixes above, the Linux job now installsx11-apps/x11-utils(four real-pixel tests neededxwd/xprop), the Linux-only/proccheck and the POSIX-signal tests skip cleanly on the platforms that lack them, and one test that asserted the value of a field read through a freed struct (undefined behavior) now asserts the documented contract,c == null.
Documentation
- api.md: what
freepromises (the binding readsnull) versus what it does not (a field read through the freed binding), the JSON cleanup-stack design and its Valgrind coverage,drain()in every Threads method list; wasm.md/macos.md: JSON parsing is unaffected by thetrygate; stale runtime/todo/contract wording refreshed.
See claude.md #233.
0.402026-09-01 #
Changed
NAME.drain()'s bookkeeping no longer costs the worker an extra mutex round trip per message. The "finished dispatching" flag is now cleared inside the lock acquisition the worker already makes when it looks for its next message, and the wake-up broadcast is skipped entirely unless adrain()is actually waiting — a program that never callsdrain()pays one predictable branch per message and nothing else. Measured on a 400,000-message fire-and-forget program: min 208 → 171 ms, median 300 → 225 ms.
Fixed
drain()on a thread that stops while the call is blocked can no longer sleep forever —aliveis part of the wait predicate and a stopping thread wakes any waiter on its way out. (Not reachable from Festina code today, where only the main program can call eitherdrain()orkill()and never concurrently; kept correct rather than relied on.)
Documentation
drain()waits for the thread's own side effects, not for a reply's.callback(fn)to run on main — spelled out in api.md, with a test pinning the order.
See claude.md #232.
0.392026-09-01 #
Added
NAME.drain()blocks until a thread's own inbound queue is fully processed, then returns with the thread still running. The deliberate opposite ofkill()'s own discard-don't-wait choice — exists specifically soon close()/on exit(code:int)can fire off a finalpostMessage(e.g. a database write on a thread with its ownDatabaseURL) and be sure it landed before the teardown that follows a window closing or a graceful shutdown would otherwise discard it, unprocessed, exactly likekill()already does. Same main-only shape askill()/live()/isAlive(), includingpool[i].drain(). See api.md.
See claude.md #231 (uraikus/festina#91).
0.382026-09-01 #
Fixed
- A thread's second-ever
.reply()was silently dropped. A worker's reply/.callback()mechanism delivered successfully only the first time, for that handle's entire process lifetime — every reply after that from the same sender was silently discarded, no error. Root cause: removing the last entry from a sender's own pending-callback list left its tail pointer dangling, corrupting the very next registration (a real use-after-free write) so it became unreachable from the list's own head. Fixed by tracking the previous node explicitly during removal and correcting the tail pointer whenever the removed node was it. api.md's own "reply at most once per message" documentation needed no change — it already described the intended behavior; the runtime just wasn't providing it.
See claude.md #230 (uraikus/festina#89, #90).
0.372026-09-01 #
Documentation
- api.md reorganized. Three sections that had drifted far from their own topic (an artifact of being documented in whatever order they were built rather than the order a reader looks for them) moved to where they belong: "Single-value queries"/"JSON and full-text search" into
Built-in SQLite, "Growing arrays"/"Sorting" intoArrays, andImportsup near the top, right after the compilation pipeline. No headings renamed, so every existing anchor (including external links from this changelog) still resolves. Checked the whole file for stale language left over from this session's earlier removals/fixes — none found.
See claude.md #229.
0.362026-09-01 #
Fixed
toStruct()/toArr()'s partial-parse-failure leak. A JSON value that failed to parse partway through being built — a struct's third field turning out to be the wrong type, having already parsed the first two; an array's fourth element failing, having already collected three — used to leak whatever was already built for that one call. Every generated from-JSON parsing function now installs its own localtry/catcharound its own build loop, and the.toStruct()/.toArr()call site does the same for its own cursor and receiver text. Verified leak-free under Valgrind across a flat struct, a nested struct field, an array, amap[T]field, a self-referencing struct, and malformed JSON syntax itself.
Added
scripts/valgrind_stress.sh+tests/: a permanent home for stress programs that usevalgrind_stress/ try/throw, which cannot run under the existing AddressSanitizer-basedscripts/leak_stress.shin this environment.
See claude.md #223.
0.352026-09-01 #
Fixed
.callback(fn)now fires on MAIN's own OS thread for a send addressed to main. A worker's own barepostMessage(x).callback(fn)(always addressed to main) used to havefnfire back on the SENDING worker's own OS thread once main replied — a real cross-thread-isolation hazard. It's now marshaled onto main, the same mechanismblob/img/aud's own.callback()already uses. A worker messaging ANOTHER worker directly is unaffected —fnstill fires on the sending worker's own thread there.- A previously-latent data race in the async-io worker pool's own outstanding-job counter, exposed by the fix above the first time that path was ever exercised from a thread other than main.
See claude.md #222.
0.342026-09-01 #
Removed
exec(args, callback)— the non-blocking form.exec(args)(blocking) is unaffected. The callback always ran on main's own OS thread regardless of which thread dispatched it — a real cross-thread-isolation hazard for a language whose whole thread story is "no shared mutable state to race on" — so it's gone rather than documented around.
See claude.md #221.
0.332026-09-01 #
Added
thread NAME[] { ... }— empty brackets, no literal size — sizes the pool itself:os.cpu_count()(read on the machine compiling the program) minus every other thread the program declares, floored at 1.thread NAME { ... }(no brackets at all) is unchanged, still a singleton. Two auto-sized pools in the same program each get the full remaining budget rather than splitting it. See api.md.
See claude.md #220.
0.322026-09-01 #
Removed
sqliteInt(),sqliteFloat(), andsqliteText().sqlite()itself,table, andDatabaseURLare unaffected. These three existed to read a single value (like acount(*)) without declaring atable(which creates a real table) just to hold it — but astructused as a query target creates no real table either, soarr[SomeStruct] x = sqlite('SELECT count(*) AS n FROM ...')already gives the identical schema-free round trip throughsqlite()'s own one path. Calling any of the three now fails with the same "no such function" error any other unrecognized name gets. See api.md.
See claude.md #219.
0.312026-09-01 #
Fixed
- An out-of-range thread-pool index no longer registers a dead callback.
pool[99].postMessage(x).callback(fn)registeredfnbefore the bounds check, so it could never fire and its slot was never reclaimed. The whole expression is a clean no-op now. .reply()with no message in flight (a second reply to one message, or athreadvalue stashed in a struct/array/map and replied to later) silently dropped the reply and leaked its payload. It now releases the payload and delivers nothing. Every other path that can drop a reply — including one still queued when its target is killed — releases it correctly too.- Error messages naming a thread type printed the compiler's own internal repr (
ThreadType(None)) instead ofthread.log()and template interpolation of a thread value now give the same specific "has no text form" errorimg/audalready did, and the "thread has no field X" error no longer suggests a method that doesn't exist on a thread value. - A broken intra-document link in api.md (
#http--websocket-servers).
Changed
- Thread messaging is faster. The pending-callback list appends instead of prepending, turning the ordinary in-order reply case from O(N^2) into an O(1) lookup (measured on the identical program built both ways: 0.06s → 0.04s at 5,000 in-flight sends, 0.92s → 0.47s at 20,000), and a worker now takes its inbound mutex once per message instead of twice.
- api.md's Threads section reorganized into ten subsections with working cross-references, plus newly documented behavior: reply at most once per message,
kill()drops pending callbacks, a thread value has no text form, and a pool compiles its body once per instance (a compiled-size cost worth knowing before picking a largeN).
See claude.md #218.
0.302026-09-01 #
Added
t.reply(response)/NAME.postMessage(x).callback(fn)— a general request/response mechanism on top of thread messaging. A thread's (or main's) reply type is fixed by its first.reply(...)call; everypostMessage(x)call site targeting a receiver with a reply type must chain.callback(fn)to receive it, or it's a compile error..reply()never triggerson messageon the receiving end — it's a separate delivery path straight tofn, which runs back on whichever OS thread originally sent the message.
Fixed
festina_thread_kill's leftover-message cleanup was type-confused for a queuedgiveRequesthand-off — it called the wrong release function on the wrong struct shape. Now kind-aware; a killed thread's own pending.callback(fn)registrations are also freed (never invoked) so a laterlive()respawn can't inherit stale ones.
See claude.md #217.
0.292026-09-01 #
Changed
worker:thread(theon message(worker:thread, msg:T)parameter) is nevernullany more. When main is the sender,workeris now a real, singletonthreadvalue — check the new.main:boolfield to tell it apart from an ordinary worker's own handle. Comparing athreadvalue againstnullis now a compile error naming.mainas the replacement. This is Phase 1 of a larger messaging redesign;.reply()/.callback()and the removal ofsqlite()are separate, later changes.
See claude.md #216.
0.282026-09-01 #
Added
examples/threaded_http_server.fandbenchmarks/http_threaded/— a real example and awrk-based benchmark showingthread pool[N]+NAME.giveRequest(r)computing genuine per-request CPU-bound work across more than one OS thread at once, with a single-threaded baseline for comparison. See benchmark.md's new "HTTP: single-threaded vs. thread pool" section for measured numbers.
Fixed
- A
thread pool[N]can no longer declare its ownDatabaseURL. Every instance in a pool shares one declared body, so this would have meantNindependent, uncoordinated sqlite connections into the identical literal file at once — now a compile error naming the fix.
See claude.md #215.
0.272026-09-01 #
Changed
- Docs/tests/release consolidation for the
threadextensions plan (thread pools, thread-private functions, wider builtin access, private per-thread HTTP contexts, live connection hand-off — 0.22 through 0.26). Audited api.md's Threads/HTTP sections end to end; clarified the "Sendable types" paragraph's own wording now that a livehttprequest can be handed off (not cloned, not sent throughpostMessage) viagiveRequest. Full, unfilteredscripts/leak_stress.shandscripts/thread_tsan_stress.shruns (every stress program, not just this plan's own additions) both clean.
See claude.md #214.
0.262026-09-01 #
Added
- Live connection hand-off:
NAME.giveRequest(r). The main program, having accepted a live request on its own port, may hand it directly to a thread — that thread's ownon requestfires for it, on the connection's own live socket. Legal only from main, only onto a thread that has declared its ownon request, and only for a manually-managedhttp?value (reusingT?rather than a new compile-time move-checker). First cut: plain (non-TLS) connections only.
See claude.md #213 for the full design, including a real ThreadSanitizer-caught data race found and fixed during this phase's own verification.
0.252026-09-01 #
Added
- Private per-thread HTTP context. A
thread { }may now declareon request(req:http)/ on upgrade(s:socket)/ on socketMessage(s:socket, msg:blob)/ on socketClose(s:socket)— the identical four handlers the main program's own top-level HTTP/WebSocket support already has — and, once it has declared at least one, callopenPort()/ closePort()/ openSecurePort(). This gives the thread a fully private connection table and listener set, never shared with the main program's own HTTP context or with any other thread's, so a program can serve real, concurrent traffic on more than one port from more than one OS thread with no coordination needed between them. The blocking http client form (req.send()with zero arguments) also works from inside a thread body, targeting any other context's port; a thread must never target its own listener from inside that same thread (a documented, structural deadlock, not a bug) — see api.md's new "A thread's own HTTP context" section.
See claude.md #212 for the full design, including the __thread conversion of festina_runtime_http.c's own connection/listener/handler state and a real leak this phase's own sanitizer verification caught and fixed.
0.242026-09-01 #
Added
- Wider builtin access inside a
thread { }body.regex(),mkdir(), andls()are now callable from inside a thread's own handlers and private funcs, alongside the blocking, 1-argumentexec(args). Each is safe with zero runtime changes:regex()'s memoization slot is a per-call-site codegen global, never shared between threads;mkdir()/ls()are thin, purely local POSIX wrappers;exec(args)'sfork()/execvp()/waitpid()only ever touches the calling thread. The non-blocking, 2-argumentexec(args, callback)form stays rejected inside a thread body — its callback always runs on the main program's own OS thread regardless of which thread dispatched it, a genuine cross-thread isolation violation if allowed.
See claude.md #211 for the full design, including how this was verified against the actual C runtime rather than assumed from names alone.
0.232026-09-01 #
Added
- Thread-private helper functions. A
funcdeclared directly in athread { }body's own top level (a sibling of its state vars/on load/on message/on exit) is now callable from that one thread's own handlers and other private funcs, with direct read/write access to that thread's own state. Two private funcs may call each other regardless of declaration order. An ordinary top-levelfuncremains completely uncallable from inside a thread body, unchanged. Each thread pool instance gets its own independent copy of every private func, closing over that one instance's own state.
See claude.md #210 for the full design.
0.222026-09-01 #
Added
thread NAME[N] { ... }— thread pools. DeclaresNfully independent instances of the same thread body, each its own OS thread, private state, and inbound queue. Addressed withNAME[i](anyintexpression) everywhere a singleton thread's own bareNAMEwould be used —pool[i].postMessage(x)/ .kill()/ .live(callback)/ .isAlive()all work identically to the singleton form, just per-instance. An out-of-range index is a silent no-op, matchingNAME.isAlive()'s own established "test, don't fail" convention rather than crashing or raising.
See claude.md #209 for the full design.
0.212026-09-01 #
Changed
threadmessaging is now a single, unified model — replaces per-threadNAME.onMessage(callback)registration with one global top-levelon message(worker:thread, msg:T)handler that declares its own message type directly. A thread's own inbound handler now uses the identical(worker:thread, msg:T)shape (previouslyon message(p:T));workeridentifies the sender and isnullwhen the message was sent by the main program. Threads may now message each other directly viaNAME.postMessage(x)from inside another thread's own body (lifecycle control —kill()/live()/isAlive()— remains main-program-only). Breaking: any program usingNAME.onMessage(...)or a thread's old single-parameteron message(p:T)needs updating to the new form.- The top-level WebSocket frame handler is renamed from
on message(s:socket, msg:blob)toon socketMessage(s:socket, msg:blob)— frees theon messagename for the unified messaging model above; behavior is unchanged, only the name. Breaking: update anyon message(s:socket, ...)declaration toon socketMessage.
See claude.md #208 for the full design and migration notes.
0.202026-09-01 #
Fixed
- A
threadwith its ownDatabaseURLnow closes its private sqlite handle onkill()— previously, akill()/live()cycle reopened a fresh handle every time without closing the old one (a real, small, per-cycle leak: onesqlite3*plus an open fd). Confirmed via a real LeakSanitizer report before the fix and a clean one after (tests/). See claude.md #207.stress/ thread_db_kill_live_churn.f
0.192026-09-01 #
Fixed
.toStruct(T)/.toArr(T)now decode\uunicode escapes — previously threw"... not yet supported"on any JSON string containing a\uXXXXescape, including a UTF-16 surrogate pair (astral-plane codepoints); raw, un-escaped UTF-8 bytes were always fine, but a producer that specifically\u-escaped non-ASCII text could never be parsed. Now decodes both BMP codepoints and surrogate pairs into their real UTF-8 encoding, with a clear, catchable throw for malformed input (an unpaired surrogate, invalid hex, a truncated escape). See claude.md #206.
0.182026-09-01 #
Fixed
- A real heap-use-after-free: assigning an ordinary, automatically-managed struct value into a manually-managed (
T?) enum binding (enum Shape = Circle; Shape? shape; ...; shape = cfor an existingCircle c) compiled without error and leftshapedangling oncecwent out of scope and its own automatic release freed it —check_assignable's enum member-coercion rule never accounted formanually_managed. Now correctly rejected as a type mismatch; a fresh member value (Shape? shape = makeCircle()) is unaffected. - A manually-managed
blob?/regex?thread-message parameter's own method calls (p.write(...),p.test(...)) insideon messagecould fail to compile, or compile to invalid LLVM IR — a handful of exact-equality type checks in codegen.py (predatingT?) never accounted for the flag, andregex/http/socketwere never taught that a manually-managed instance can now reach code paths an ordinary one never could. Fixed; see claude.md #205.
Added
- Real per-type test coverage for a manually-managed value crossing a
threadboundary —arr[T]/ map[T]/ enum/ img/ blob/ aud/ regex/ urleach get a dedicated compile-and-run round-trip proof (tests/), alongside struct's own existing one.test_manually_managed.py ::TestThreadReferenceSharingPerType
0.172026-09-01 #
Fixed
- A manually-managed (
T?) declaration's own initializer can now be a fresh construction of the matching plain type — aregex/arr[T]/map[T]literal, aregex()call, or any function call at all (including a struct-returning factory function). Previouslyregex? r = /pattern/,arr[int]? xs = [1, 2, 3], andCircle? c = makeCircle()were all compile errors, since a fresh literal or a call's own return value always infers as the plain, unflagged type andT?/Tare genuinely non-interchangeable. Safe because a freshly-constructed value has no other binding referencing it yet — reading an existing plain binding into aT?position is still, correctly, rejected. See api.md.
0.162026-09-01 #
Added
postMessage/on messagenow share the raw reference for a manually-managed (T?) value crossing athreadboundary, instead of deep-cloning it like every other value type — a mutation made on one side is visible on the other, since it's the identical underlying value, not a copy. Sound because nothing on either side's automatic bookkeeping ever touches a manually-managed value's refcount, so there is nothing for two threads to race on.- A manually-managed parameter can now be
freed — it was never "borrowed" the way an ordinary parameter is, since nothing auto-manages it on either side of a call.
See claude.md #203 for the full design and implementation record.
0.152026-09-01 #
Added
T?— a trailing?after a type at a variable/parameter declaration opts that one binding out of automatic memory management entirely (no retain on alias, no release at scope exit or reassignment).free/deletework unchanged and become the only release it ever gets. A genuinely distinct type fromT(mirroringamor arr[T]'s own relationship to plainarr[T]) — no implicit decay either direction. Applies to struct/arr[T]/ map[T]/ enum/ blob/ img/ aud/ http/ socket/ url/ regex; accepted but inert onint/ float/ bool/ text/ color/ font/ table.arr[T?], aT?struct field, aT?return type, andconst T? xare all compile errors this round. See api.md.
Fixed
- A user-defined function with a manually-managed parameter (
func f(p:Circle?)) was permanently uncallable — the call-site argument check re-derived the parameter's type without its own?, rejecting the one argument type that could ever match it. blob?/img?/aud?failed to parse at all, misrouted into the unrelated anonymous.callback()form..test()/ .play()/ .playLoop()/ .stop()/ .isPlaying()/ .send()/ .clip()/ .resize()/ .getPixelColor()/ .save()/ .saveCopy()and thetext -> blobcoercion stopped recognizing a manually-managed receiver of the matching type, via several pre-existing exact-equality type checks never meant to distinguish more than one shape of blob/regex/img/aud/http/socket.- A grammar-ambiguity disambiguation helper (
Circle? cvs. a bare ternary statement) used an absolute token index where a relative offset was passed in, silently misrouting aT?declaration found anywhere but the very first statement of a file.
See claude.md #202 for the full design and implementation record. Crossing a thread boundary with a manually-managed value (on message/postMessage) is not yet supported — planned follow-up work, not a permanent restriction.
0.142026-08-31 #
Documentation
- Final consolidation pass closing out
thread NAME { ... }(claude.md #195-#201): api.md's own "Threads" section no longer calls itself "an early phase" and now documents the thread-private-helper-function restriction it was missing;todo.mdgains three previously-untracked, already-true open items (singleton threads, no thread-private helper functions, a thread's own sqlite handle not explicitly closed onkill());README.md's own stale top-level test count corrected. No functional changes. See claude.md #201.
0.132026-08-31 #
Fixed
- Documented that a
thread's ownon exit(code:int)always receivescode0— including when torn down by main-thread death — never the process's own real exit code. Not a behavior change (this is what the runtime already did); found and clarified while verifyingthreadprocess-exit interaction end to end. See api.md and claude.md #200.
0.122026-08-31 #
Added
- A
thread's own first statement may beDatabaseURL = '<literal>', giving it a private SQLite handle — never shared with the main program or any other thread — so it may callsqlite()/ sqliteInt()/ sqliteFloat()/ sqliteText()(a thread that didn't declare one still may not). A compile-time check rejects two contexts (a thread and the main program, or two threads) that would resolve to the same database file, main program's own default included. See api.md and claude.md #199.
Fixed
- A real (if narrow) data race, found while building the above: the media-decoder registration in
main()'s own prologue used to run after every declaredthreadwas already spawned, and the literal-SQL prepared-statement cache (claude.md #113) had no synchronization at all — both harmless as long as only the main thread ever queried SQLite, which per-threadDatabaseURLis what first breaks. Fixed before either was ever reachable; verified race-free under ThreadSanitizer.
0.112026-08-31 #
Added
thread's own message types widen to includeblob/img/aud/url— each deep-cloned (never shared) across the boundary, the same guaranteestruct/arr[T]/map[T]/enumalready got. Drawing/clip/resize/pixel methods on animgvalue (e.g.pic.drawRect(...)) work from inside a thread body, verified race-free under ThreadSanitizer. See api.md and claude.md #198.
0.102026-08-31 #
Added
thread's own message types widen tostruct/arr[T]/map[T]/enum— each deep-cloned (never shared) across the boundary, built recursively from any mix ofint/float/bool/text/color/font. A self-referencing (cyclic)struct/arr[T]/map[T]type is rejected with a clear error, not a hang;blob/img/aud/urlmessage types remain not yet implemented. See api.md and claude.md #197.
Fixed
- A fresh, with-initializer
enum-typed local variable —Choice c = someExprdeclared directly inside a loop or function body, as opposed to reassigning an already-declared one — was never freed at scope exit, leaking its own box (and, for a boxedtextmember, that buffer too) on every declaration. The identical gap is fixed forhttp/socket/url-typed locals, found by the same audit. NAME.postMessage(x), whenxcoerces into a compound message type (e.g. atextliteral posted against anenuminbound type), no longer frees the coerced result with the wrong release function.
0.92026-08-31 #
Added
thread NAME { ... }: an isolated background worker with its own OS thread, its own private state, and message queues to and from the main program.on load()/ on message(p:T)/ on exit(code:int)handlers;NAME.postMessage(x)/ NAME.onMessage(callback)for message passing (int/float/bool/texttoday);NAME.kill()/ NAME.live(callback)/ NAME.isAlive()for lifecycle control. Every message crossing the boundary is a deep, independent copy — no two threads ever share a mutable value. A thread's own body can see only its own state/locals, function names, and type names, never a global variable/constant or an ordinary top-level function call. See api.md and claude.md #195/#196.
0.82026-08-31 #
Security
- The HTTP server no longer hangs or aborts on two malformed inputs reachable from a single unauthenticated request: a chunked
chunk-sizenear 2^64 (which overflowed size arithmetic into an infinite buffer-growth loop) and a WebSocket frame declaring a ~16-exabyte payload (which reached a failingmallocthat aborted the process). Both are now rejected against the existing 8MB cap. The chunked-decoder fix also protects thereq.send()client parsing a hostile server's response. .toStruct()no longer overflows the stack on deeply nested JSON in an unknown field (reachable viareq.toStruct()on a network body); nesting past 1000 levels now throws the same catchable error every other malformed input does.
Fixed
- A value assigned to a global (or otherwise escaping) only inside a
tryorcatchbody is no longer freed while still referenced — escape analysis didn't look inside try/catch bodies, so such a value was stack-allocated and reclaimed at scope exit (a use-after-free). - A refcounted local (array, map, struct, text) declared before a
trythat throws is no longer double-freed. A throw caught in the same function freed that local, and the normal scope exit after the catch freed it again (glibc aborted with "double free detected"). - The two branches of a
?:are now required to have the same type; a mismatch (e.g.c ? 'text' : someBlob) used to compile and render garbage at runtime.nullis still allowed in either branch. &&and||now require bool operands, matchingif/whileconditions;1 && 2used to compile and printnull.- A
?:with anullbranch (c ? 1 : null,c ? null : 7) now compiles — it produced invalid IR or crashed the compiler before. - Passing a text literal or template to an
img/blob/audparameter (show(`sprite${n}.png`)) no longer corrupts the heap or leaks the loaded handle — the argument coercion mishandled the freshly minted handle's ownership. - An
arr[blob],arr[img], orarr[aud]no longer leaks its elements when the array is released — element handles were freed as a plain buffer with no per-element release. .push()/.unshift()/.indexOf()of a path string into anarr[blob]/arr[img]/arr[aud]no longer leaks the loaded handle.- Interpolating a freshly built container into a template (
`${make()}`,`${[1,2,3]}`) no longer leaks the container — only the rendered text was being freed. - Integer fields in
.toStruct()keep full 64-bit precision. They were parsed through adouble, silently corrupting any value past 2^53 — andINT64_MAXin particular read back asnull. - A finite float equal to nearly
DBL_MAXrenders as its own value in JSON output instead ofnull(the NaN/Infinity guard used a literal slightly belowDBL_MAX). img.getPixelColor()on a JPEG-loaded image returns the real color instead ofnullfor every pixel (JPEG surfaces store no alpha channel; the reader had treated the unused byte as alpha 0).- A malformed WAV file (sample-rate 0) is now a normal load failure instead of cascading into a shutdown of all other playing audio with a misleading "no audio device" error.
- Several catchable-error paths no longer leak: a failed
fetchresponse (up to 8MB per failed request, once per retry), an invalidparseURLport (~5 allocations per call), and a corrupt JPEG decode (its decoded surface, via asetjmp-clobbered local). - Long-running loops that declare locals (a struct, an array, any variable) no longer overflow the stack. Codegen emitted each
allocaat its declaration site — inside the loop body — so every iteration permanently grew the stack until the function returned; a loop declaring a six-field struct segfaulted at roughly 150,000–300,000 iterations with flat heap usage. Every static alloca is now hoisted to its function's entry block: one slot per declaration, reused each iteration (verified to 3,000,000 iterations). Locals are still re-zeroed at their declaration site every iteration, so behavior is otherwise unchanged.
See claude.md #191 for the full diagnosis.
0.72026-08-29 #
Changed
.toText()JSON-style rendering (log()/template interpolation of a struct, table row,arr[T], ormap[T], and explicit.toText()calls on any of them) is faster, with no change in output. Every compile-time-known literal the renderer appends (JSON punctuation, a struct field's own baked"name":key) now skips a runtimestrlen()rescan of a length the compiler already knew, and string escaping now bulk-copies runs of bytes that don't need escaping instead of handling one byte at a time. Measured ~2.5x faster (~215ms → ~85ms median over 5 runs) on a 100,000-iteration text-heavy struct-rendering benchmark.
See claude.md #190 for the full measurement methodology.
0.62026-08-29 #
Added
getPixelColor(x, y): reads one pixel back off the canvas as acolor—nullfor a coordinate outside the canvas, or a fully transparent pixel. Correctly undoes Cairo's premultiplied alpha, so a pixel painted underfillAlphareads back as the color that was actually painted, not one darkened by the alpha in effect at the time.img.getPixelColor(x, y): the same, reading animg's own surface.
Fixed
color == null(and!=) generated invalid LLVM IR and failed to compile at all, for any program that tried it — entirely independent of the above.coloris ani64-shaped value, and the barenullliteral was routed through the "null" pointer keyword unconditionally. Now resolves tocolor's own existing-1/'none' sentinel, the same value an uninitializedcoloralready reads as. See api.md.
0.52026-08-27 #
Added
Math.floorDiv(a, b): integer division rounding toward negative infinity (unlike/'s own truncate-toward-zero), for tile/grid calculations that previously neededMath.floor(a / b)spelled out by hand.blankImage(w, h): a fresh, fully-transparentimgat a given size, with no existing image or canvas needed to derive it from.row.rowid: a table row's own SQLite identity, read-only — only populated when the query's own SQL explicitly selectsrowid.drawRect/drawCircle(and theirimg-method equivalents) accept a further optional trailingborderColorargument, after the fill color — overrides it for that one call only.drawCirclealso gains the fill-only override it previously lacked entirely.
Documentation
0.42026-08-27 #
Added
arr[T].sort(cmpFn): an in-place, stable, comparator-based sort —cmpFn:func[T,T]:int, JavaScript's/Cqsort()'s own convention. See api.md.drawImage(img, x, y, w, h): scales the whole image to fit aw×hbox, without mutating the source image the wayimg.resize()does.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh): the full canvas-style form — a source rect cut out of the image, scaled into a destination rect. See api.md.map[T].keys()/map[T].values(): a plain, independent snapshot array (arr[text]/arr[T]), no callback needed — sidesteps.forEach()'s bare/no-closures callback restriction for the common "collect entries matching a condition" case.
0.3.12026-08-27 #
Fixed
fillAlphahad no effect ondrawImage— two images drawn back to back under differentfillAlphavalues came out pixel-for-pixel identical.drawImagenow respects it, blending the image into whatever's underneath exactly like every other draw call. See api.md.
0.32026-08-27 #
Added
on mouseWheelUp(x:int, y:int)/on mouseWheelDown(x:int, y:int): scroll wheel events, split by direction. Fixes a real pre-existing bug on X11/Linux where scrolling the wheel silently also fired a spuriousmouseDown+mouseUppair.devicePixelRatio:float: a read-only global reporting the display's pixel density (1.0normally, ~2.0on Retina/HiDPI).showCursor()/hideCursor(): toggle the mouse cursor's visibility over the canvas.- Real right-click and middle-click support on macOS and Windows — previously only the left button worked on either platform.
Changed
0.2.32026-08-27 #
Added
enterFullscreen()/exitFullscreen(): toggle true OS fullscreen on the graphics window. See api.md.
Changed
- The graphics window is now fully decorated — a title bar and the OS's normal minimize/maximize/close controls, resizable by dragging an edge — instead of the previous borderless, canvas-only look.
0.2.22026-08-27 #
Fixed
- A program calling
setClientWidth/setClientHeightnear the top of its own boot sequence briefly opened a real, on-screen window at the hardcoded 800×600 default before correcting itself, since the window used to open (and its size reset back to that default) before the program's own top-level code ever ran. The window now opens lazily, after any such call has already taken effect, directly at the requested size. See api.md.
0.2.12026-08-26 #
Fixed
- The macOS and Windows windowing backends could present stale or blank pixel content — most visibly,
img.clip()(including thesaveCanvas().clip(...)idiom) reliably showing the clipped region only on its first use in a process. Both backends now flush the surface before reading its pixels directly, as Cairo's own API requires. - Assigning a field on a manually-declared table row (not obtained from a query) segfaulted. A table row is a borrowed handle onto one row of a query result, not an independently constructible value — declaring one with no initializer is now a clear compile-time error, pointing at
structas the way to build a value by hand (see api.md).
Documentation
- Clarified that
on ...event handlers are active as soon as they're declared, regardless of position in the file — the same hoistingfuncdeclarations already get. See api.md.
0.22026-08-26 #
Added
exec(args, callback): a non-blocking counterpart toexec(args). Dispatches the same spawn to a background worker thread and returns immediately;callback:func[int]:voidreceives the real exit code once the child process exits. See api.md.
0.12026-08-26 #
Added
- Version tracking:
festina.__version__, andfestina --versionon the CLI. - This changelog.
Changed
- Project documentation (
README.md,api.md, and every other.mdfile exceptclaude.mdandtests/CONTRACT.md) rewritten to describe the software as it stands today, rather than narrating how it got there.