SCUA

News

SCUA 0.28.0 is out

September 23, 2026

A running actor can now be saved to bytes from a script and brought back later, with its state intact and the same identity it had before. This release also lets a web server share your program with background tasks and actors, adds scua serve for serving a folder in one command, makes long-running work take turns wherever it runs, and gives data tables four new verbs. A few changes are worth reading before you upgrade.

#Actors can now be frozen and revived from a script

Each partition keeps its whole state in its own isolated heap, so a running actor can be turned into bytes and turned back into a running actor. The actors module has three functions for it:

import actors

partition Counter
  state n = 0
  on Add(k)
    n = n + k
    print(`count is {n}`)
  end
end

fn park(c)
  let blob = actors.freeze(c)?   -- the bytes
  actors.drop(c)?                -- stop it and free its memory
  return actors.revive(blob)     -- the same actor, back again
end

let c = Counter()
tell c.Add(2)
tell c.Add(3)
wait(0ms)
match park(c)
  Ok(back) -> tell back.Add(10)
  Error(e) -> print(`could not park: {e}`)
end
count is 2
count is 5
count is 15

freeze takes a snapshot and leaves the actor running, which is why drop exists alongside it. The pair lets a long-running server keep only its busy sessions in memory: freeze the idle ones, drop them, and revive one when its next request arrives. A handler that was paused halfway through a turn, waiting on a reply, comes back paused at the same point.

revive returns the same actor. Its identity travels inside the bytes, so a reference you handed out before the freeze still reaches it afterwards. actors.generation(blob) gives a number that goes up with every freeze and keeps going up across a revive. Store it next to the bytes and refuse a write whose number is lower, and a process that woke an older copy cannot overwrite newer state.

None of the three needs a capability. Moving the bytes anywhere still does: write them with fs, or send them with net.

#A server now runs alongside the rest of your program

http.serve shares one loop with everything else in the program. A task you spawn before the call keeps running, and a handler can send a message to an actor that is handled while the server carries on:

import http

partition Audit
  state seen = 0
  on Hit(path)
    seen = seen + 1
    print(`audit: request {seen} for {path}`)
  end
end

let audit = Audit()

fn handle(req)
  tell audit.Hit(req.path)
  return { status = 200, body = b"ok\n" }
end

http.serve(handle, { bind = "127.0.0.1:8080" })

Two requests later, the actor has logged both:

audit: request 1 for /a
audit: request 2 for /b

Put background work before the http.serve line, because that call does not come back. A handler runs to completion: it can spawn work to finish after it has answered, and it can tell an actor, but it cannot wait or ask. One that tries gets a 500 that says why.

Request handlers are compiled by the same optimizing tier as the rest of your code, so a handler that loops over data runs at the speed that loop runs anywhere else. --serve-timeout stops a runaway handler at its deadline whether or not it has been compiled, and it stops only that handler.

#scua serve starts a web server in one command

Run scua serve in a folder and it serves that folder over HTTP:

$ scua serve
scua: serving . on http://127.0.0.1:8000/  (Ctrl-C to stop)

If the port is taken, it moves to the next free one and tells you which port it skipped:

$ scua serve
scua: port 8000 was busy — serving . on http://127.0.0.1:8001/  (Ctrl-C to stop)

It is for previewing a page or a folder of assets locally. It serves files and nothing else, and by default it listens on your own machine only.

#Long-running work now takes turns

The runtime pauses a long-running task every so often so that other work gets a go, and that now happens everywhere work runs: in compiled code as well as interpreted code, in actor message handlers, and in tasks spawned inside a partition. A short task started before a long loop runs while the loop is still going:

spawn(fn() print("the short task ran") end)

let total = 0
for i in 0:50000000 do total = total + i end
print("the long loop finished:", total)
the short task ran
the long loop finished: 1249999975000000

The same applies between partitions, so a partition with a long handler shares the worker with the others. A handler that is paused picks up exactly where it stopped, and a partition still handles one message at a time. --max-ops counts each task on its own, so a limit you set applies to a task however often it is paused, and one task's work never uses up another's allowance. A sandboxed partition's limit covers the whole message, pauses included.

#Data tables gained four verbs

dropna removes rows with gaps, either anywhere in the row or in one named column. fillna fills the gaps in a column, and only with a value that fits it exactly, so an exact money column stays exact. cumsum adds a running total as a new column, and corr measures how strongly two numeric columns move together. group_by now takes several keys, and each key comes back as its own column:

let sales = frame({
  region  = ["north", "south", "north", "north", "south"],
  quarter = ["Q1", "Q1", "Q2", "Q1", "Q2"],
  amount  = [100.00d, 50.00d, 200.00d, nil, 75.00d],
})

print(sales.dropna("amount").group_by(["region", "quarter"], { revenue = "sum:amount", orders = "count" }))
print(sales.dropna("amount").cumsum("amount", "running"))
region  quarter  revenue  orders
north   Q1       100.00   1     
south   Q1       50.00    1     
north   Q2       200.00   1     
south   Q2       75.00    1     
(4 rows × 4 cols)
region  quarter  amount  running
north   Q1       100.00  100.00 
south   Q1       50.00   150.00 
north   Q2       200.00  350.00 
south   Q2       75.00   425.00 
(4 rows × 4 cols)

A printed table now shows six meaningful digits, so a describe() mean reads as 135.958. The values themselves are unchanged, exact columns always print in full, and to_csv() still writes every digit.

#Upgrading

Three changes can make a program that ran before report an error. In each case the program was not doing what it appeared to.

  • Sending a message a partition has no handler for is now an error. tell box.Adld(1) against a partition that handles Add used to do nothing. It is now a compile error where the compiler can see which partition box holds, and a runtime error on the same line where it cannot, and both name the handler you probably meant.
  • ask, wait_for and select now report an error when called outside an actor handler. They used to return nil, so let answer = ask thing.Question() at the top level set answer to nothing. The error says what to use instead.
  • Running with --fast while a server is listening is refused. --fast skips through waits, and a network socket is the one thing it cannot skip.

Two smaller things: a partition that is serving cannot be saved (the attempt names the listener), and wait now works at the top level of a scua test file.

#What's next

Partitions share nothing and talk only by message, so they can run in parallel. Running them across every core of the machine is the next step.

The full changelog has the rest.