SCUA

News

SCUA 0.21.0 is out

September 2, 2026

SCUA can now read and write a file of any size. fs.open gives you a handle you pull a file through a line or a block at a time, so the memory you need is the size of one piece instead of the size of the file. Alongside it: a way to tell a symlink from a file before you replace it, a deadline on served requests, long jobs that run to completion, and faster string searching.

#fs can now read and write files of any size

import fs

match fs.open("access.log")
  Ok(f) -> do
    let n = 0
    let more = true
    while more do
      match fs.read_line(f)
        Ok(line) -> if line == nil then more = false else n = n + 1 end
        Error(why) -> do
          print(`read failed: {why}`)
          more = false
        end
      end
    end
    print(`{n} lines`)
    fs.close(f)
  end
  Error(why) -> print(`could not open: {why}`)
end

fs.read_line gives you text a line at a time. fs.read_chunk(f, max) gives you raw bytes a block at a time, for binary files or when lines are not the unit you want. fs.close releases the handle.

The two readers signal the end of the file differently, and it is the one thing to remember here. fs.read_chunk is finished when it hands back an empty block. fs.read_line is finished when it hands back nil, because an empty line is a real line: if "" meant finished, a reader would stop at the first blank line in your file and not tell you.

Writing works the same way. fs.open(path, { mode = "write" }) starts a new file and { mode = "append" } continues an existing one, or starts it if it is not there yet. fs.write_chunk(f, data) adds a piece and returns how many bytes it wrote. fs.write remains the one-shot verb for content you already hold; fs.write_chunk is for output you produce as you go.

Writes go straight to disk rather than sitting in a buffer, so a failure is reported by the call that caused it rather than turning up later when you close the file. If one fails partway, the error says how many bytes got through, and the file is exactly its previous length plus that many. You know what state it is in instead of having to assume the worst.

Close what you open. Nothing enforces it during a run, and a loop that leaks handles will exhaust the operating system's file descriptors, with the failure landing somewhere that looks unrelated to the file you forgot. A program holding an open file also cannot be snapshotted, so close it before a handler that needs to survive a snapshot ends.

There is more on this in reading a file too big to load, and a runnable example in examples/streaming_files.scua.

fs.lstat(path) returns what fs.stat returns plus is_symlink, and it does not follow the link.

This matters when you replace a file. The crash-safe way to rewrite one is to write a temporary file and rename it over the original, which is a single step that either happened or did not. If the original is a symlink, that rename replaces the link and leaves the file it pointed at untouched. The write reports success and the file you meant to change is unchanged. fs.stat cannot see the difference because it follows the link. fs.lstat can, so you can check before you commit to the swap.

#Served requests now have a deadline

Granting --allow-serve arms a 30 second limit on each request, adjustable with --serve-timeout. A handler that runs past it gets a located error naming the limit, rather than holding the connection open while the caller waits.

Handlers can take as long as the deadline allows, so a request that parses a payload, queries something and renders a response is ordinary work.

#Long jobs run to completion

A program that simply has a lot to do runs to the end. Work is bounded by the budget you set with --max-ops, which still stops a runaway loop, and by nothing else.

That is what makes streaming a large file practical: reading a million lines is a million ordinary calls, and a job that size should finish rather than being cut off for its size alone.

#Searching a string from a position is much faster

str.find(s, needle, from) resolves the offset in constant time on plain ASCII, and returns its answer the same way. Searching from an offset is cheaper than scanning the whole string, which is what you would expect it to be. str.rfind, str.slice, str.index, str.code, str.chars and the padding functions all take the same path.

Text with accents, CJK or emoji returns exactly what it always has. This is a cheaper route to the same answer, not a change to any answer.

A scanner that walks an offset through a large document is the shape this helps most, and it is a common one: tokenizers, log parsers, anything that advances through text a token at a time.

#Also in this release

Arrays hold twice as many elements. Building a value past the per-value ceiling is an ordinary error you can catch. A refused snapshot names the handle blocking it, so an embedder can see that the answer is to close a stream first. The --jit-stats and --mem-stats flags print however the program ends, including through sys.emit and sys.fail, which is the shape most agent tools use. And a match arm with a stray second statement tells you to wrap the body in a do … end block.

The changelog has the complete list.