SCUA

News

SCUA 0.19.0 is out

August 29, 2026

Six packages got built on SCUA this month, and the authors sent back a list of everything that got in their way. Most of this release is that list.

You can now wait at the top level of a program, build a command line tool from a record declaration, and reach for the checksum and encoding functions that binary formats are made of.

#Waiting works where you write it

spawn do wait(50ms) print("task woke") end
print("main start")
wait(100ms)
print("main resumed")

wait works anywhere, including the main body of a program, which is the first place most people reach for it. The main body shares the clock with the tasks it starts, so the output above arrives in the order it reads.

A coroutine can also resume another coroutine. That matters most inside message handlers, where a generator or an iterator written as a coroutine is a natural way to express the work.

Under the hood the main body of your program became an ordinary task, the same kind spawn creates. One scheduler runs everything, so there is one answer to what can wait where instead of three.

#Command line tools from a record

import sys

record Args {
  file:    string,
  lines:   int  = 10,
  verbose: bool = false,
}

let args = sys.parse_args(Args)

sys.parse_args was built for agent tools reading JSON on stdin. It now handles the command line a person types: positional arguments, short flags, and a generated --help built from the record you declared. Nothing to annotate and no parser to write.

--help and --version also survive a -- separator, which they did not before.

#The pieces binary formats are made of

hash.crc32 and hash.fnv1a take raw bytes now, the way hash.sha256 already did. That one change makes the formats that require CRC-32 reachable: gzip, zip, PNG. Their data is compressed bytes and was never text.

base64.encode takes bytes too, and a trailing options record picks the dialect. { url_safe = true } swaps +/ for -_ and { pad = false } drops the padding, which is what JWT, JWS, JWK, WebAuthn and OAuth PKCE all ask for. Decoding needs no option at all, because the two alphabets use different characters, so the input already says which one it is.

str.find and str.rfind take a starting position, so scanning a large document token by token no longer means re-slicing the tail each time.

Between them these close the gaps that had three separate packages carrying their own base64 and their own CRC-32 table.

#Fanning out over a long list

let results = map_all(rows, fn(r) return score(r) end)

map_all and wait_all cost what the list costs. Eight hundred thousand items finish in about a quarter of a second. Results still come back in the order you passed them, one arm failing still leaves the others alone, and the concurrency limit still queues rather than dropping work.

Measured on an M-series Mac with a ReleaseFast build, pure arms, on 2026-08-29.

#Numbers look like numbers

print(1.0) says 1.0. A float and an int look different everywhere you go looking: printed output, a failing assert_eq, the debugger's variable view, and json.encode.

That last one is the practical win. Decoding a document and re-encoding it gives back what you started with, round numbers included.

An array literal that mixes them shows what it holds, too. [1.0, 2, 3.5] prints as [1.0, 2.0, 3.5], because that middle value is a float at runtime and always was.

#Smaller things worth knowing

int.parse(s) reads a whole number and tells you why not when it cannot, where tonumber guesses. It is the one to reach for when the string came from a user rather than from you.

is(a, b) asks whether two values are the same object. == compares contents, so two tables built separately with the same fields are equal, and now you can also ask the other question.

A reserved word works as a table key in quotes. { "not" = 1 } is what JSON Schema needs, and payloads carrying end, for, in and type no longer need a workaround.

return f() hands back everything f returned rather than only the first value, so a wrapper function stops losing data.

The sandbox's work limit is now enforced. It is the bound that decides how much a piece of untrusted code may do before it is stopped, and setting it had no effect before. Untrusted code that runs out of it stops the sandbox, not your program.

#What changes in your code

Five things that compile today will not compile after upgrading. Each one turned a wrong answer into an error.

An unrecognised string escape is refused where you write it. "\e[1m" used to compile to e[1m with the backslash dropped, which is how a terminal library ends up printing visible garbage where it meant to set bold. \e exists now, and so does \u{HEX}.

A builtin used as a value is an error rather than a silent nil. let f = print used to bind nothing at all.

sys.parse_args reports an unrecognised single dash argument rather than ignoring it. A negative number is still a value.

base64.decode and hex.decode return bytes rather than a string, which is what decoded base64 and hex are. For text, wrap it: bytes.to_string(base64.decode(s)?), and that conversion checks, so input that is not valid text is refused where it happens.

push into a map is an error and says what to write instead. It used to accept the value, discard it, and keep reporting it in len.

Printed output changed with the float rule above, so a test asserting on "1" for a float will see "1.0".

#On the package manager

scua-pkg emits for older versions, so package commands wait for a scua-pkg release that catches up with 0.19. It says so rather than emitting something subtly wrong. Running programs never needs it.

The changelog has the full accounting.