Skip to content

0.26.0-rc3

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 19 Oct 09:33
Immutable release. Only release title and notes can be modified.
32a2222

basic-cli 0.26.0-rc3

Release candidate for 0.26.0. The APIs are still being tested before the final release.
This follows 0.26.0-rc2
and adds the release feedback from PR #529:

  • Catch Ctrl-C and Unix SIGTERM with Signal.catch!/poll!, and handle cooperative Interrupted results so applications can clean up before exiting.
  • Use compact Cmd.call!, a reusable Cmd.Command builder, and Unix Cmd.exec! for process replacement.
  • Add OS witnesses, enforced file permissions, symlinks, Unix stream sockets, and opt-in HTTP redirects.
  • Fix Linux musl allocation syscall churn (#530) and Windows console cancellation.

The draft 0.26.0 notes and migration guide follow. Please report behavior that differs from these notes.

app [main!] { pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.26.0-rc3/72LPrJNPhao1zzEbawpgzKnkwAPRKy6Xm8jM1rPchQ7o.tar.zst" }

Requires the Roc new-compiler nightly nightly-2026-10-12-2664a27 or later.

One way to do each thing

Until now each basic-cli module had grown its own habits. A timeout was a bare U64 of milliseconds in one place and a tagged value in another, and zero meant "fail at once" for TCP but "no deadline" for a command. "No more input" was Err(EndOfFile) from stdin, an empty list from a file and an empty string from a socket. Errors arrived in a different shape from every module, and some could not be compared with == at all.

0.26.0 settles these questions once and applies the answers everywhere:

  • A timeout is a Duration, and Duration.zero always expires immediately.
  • The end of input is Err(EndOfInput), on stdin, files and sockets alike. An empty Ok is real data.
  • Anything you can read, you can loop over: lines() and chunks() return a Stream on stdin, file readers, TCP connections and HTTP responses, and rows does the same for SQLite.
  • A module's failures arrive under one tag (TcpErr(IOErr), CmdErr(Cmd.Err), SqliteErr(Sqlite.Err)), and every error can be compared with ==.

The result is that code written against one module reads like code written against another:

conn = Tcp.connect!("example.org", 7, { connect_timeout: Duration.seconds(1), io_timeout: Duration.seconds(5) })?

for! item in conn.lines() {
    match item {
        Ok(line) => Stdout.line!(line)?
        Err(BadUtf8(_)) => {}
        Err(ReadLimitExceeded(_)) => {}
        Err(TcpErr(err)) => { return Err(TcpErr(err)) }
        Err(Interrupted) => { return Err(Interrupted) }
    }
}

This is a large release with many breaking changes. The upgrade guide below lists every one with its replacement. The rules themselves are written down in CONTRIBUTING.md, so future modules follow them too.

Steady allocation performance on Linux

The statically linked Linux hosts now use mimalloc for Rust and Roc general
allocations. Repeated small allocations reuse freed pages, avoiding musl
mallocng's heap-layout-dependent mmap/munmap churn (#530).
The Roc allocation ABI and resource-handle lifetime behavior are unchanged.

Time has types

start = Monotonic.now!()
build!()?
Stdout.line!("Completed in ${start.elapsed!().to_str()}")?   # "Completed in 1.5s"

stale = Utc.now!().since(path.time_modified!()?) > Duration.minutes(5)
  • Duration is a non-negative length of time with nanosecond resolution: Duration.millis(250), Duration.seconds(5), Duration.minutes(2). It supports +, -, ==, the ordering operators and List.sort, and prints as "250ms" or "1.5s". Nothing overflows or goes negative: results are clamped, so budget - start.elapsed!() is simply zero once the budget is spent.
  • Utc is now a real type holding a signed instant. Utc.now!() no longer returns 1970 when the clock cannot be read, and times before 1970 work, including in to_iso_8601.
  • Monotonic is an opaque clock reading. start.elapsed!() and later.since(earlier) return a Duration, so a reading can no longer be mistaken for a length of time.
  • File times (time_accessed!, time_modified!, time_created!) return Utc and succeed for files older than 1970.
  • Sleep is now fallible: use Sleep.millis!(250)? or duration.sleep!()?. A caught stop request returns Err(Interrupted).

Loop over input

Reading lines used to mean hand-written recursion. Now it is a loop, and the same loop works on every source:

var $count = 0
for! item in reader.lines() {
    match item {
        Ok(_) => { $count = $count + 1 }
        Err(BadUtf8(_)) => {}
        Err(FileErr(err)) => { return Err(FileErr(err)) }
    }
}
  • Stdin.lines(), Stdin.chunks(), reader.lines() and reader.chunks() are new. Lines never include the \n, and one preceding \r is removed. A final line without a terminator is still a line.
  • File.Reader.read_until!(byte) returns bytes through and including a delimiter.
  • A line that is not valid UTF-8 is one Err(BadUtf8(_)) item and the loop carries on. A failure of the source itself is the last item.
  • Streams buffer nothing in Roc. You can break out of Stdin.lines() after a header and hand the rest to Stdin.read_to_end!() without losing a byte, and mix reader.lines() with seek! and read_exactly!.

Write files piece by piece

Path.write_utf8! replaces a file with one payload, so output had to be built in memory first. File.Writer opens a file once and writes to it as you go:

File.with_writer!("report.csv", Truncate, {}, |writer| {
    writer.write_utf8!("name,total\n")?
    for row in rows {
        writer.write_utf8!("${row.name},${row.total.to_str()}\n")?
    }
    Ok({})
})?
  • The mode is Truncate, Append or RequireNew (fails with AlreadyExists if anything is at the path).
  • writer.write_stream!(stream) writes any chunk stream as it arrives, so a file can be copied or transformed in bounded memory: writer.write_stream!(reader.chunks()).
  • File.with_writer! flushes when your callback returns and reports a failed flush. With File.open_writer!, call flush! yourself after the last write.
  • Path.append_bytes! and Path.append_utf8! are also new, for adding to a file in one call (#516).

Create private files and symbolic links

The writer options choose permissions for a new regular file, with the same access policy on Linux, macOS and Windows:

File.with_writer!("credentials.json", RequireNew, { create_permissions: OwnerOnly }, |writer| {
    writer.write_utf8!(credentials_json)
})?
  • OwnerOnly grants the file's owner read/write access and grants others no access. PublicRead additionally grants everyone read access. Both replace inherited discretionary access grants and remove execute permission; {} keeps the operating system's default creation policy.
  • Permissions are enforced before a writer is returned or any content is accepted. If the filesystem cannot enforce the requested policy, opening fails. A failed creation can leave an empty file. Existing handles and privileged operating system access remain outside these policies.
  • Existing files keep their permissions, including with Append or Truncate. Use RequireNew with OwnerOnly for secrets: an existing file or symbolic link fails with AlreadyExists, before its contents can change.
  • path.set_permissions!(OwnerOnly) and path.set_permissions!(PublicRead) replace an existing regular file's access grants while preserving its owner and contents. They follow symbolic links and reject directories and special files. On macOS the target currently must be readable so the platform can open a handle before replacing its ACL; a mode-000 file returns PermissionDenied. Linux can restore access to a mode-000 file through a held metadata handle and requires /proc/self/fd for that repair.

Create a link with Path.symlink!(target, link, File) or Path.symlink!(target, link, Directory), and read its stored target with link.read_link!(). A relative target resolves from the link's parent directory and is returned unchanged. Dangling targets are allowed; the declared kind must match the target when it exists. Creating a link never replaces an existing path. Windows requires Developer Mode or permission to create symbolic links.

Download without holding the body in memory

Http.send_streaming! returns as soon as the status and headers arrive, and hands you the body as a stream:

request = Request.from_method(GET).with_uri(url.to_str())
response = Http.send_streaming!(request, {})?
if response.status() != 200 {
    return Err(UnexpectedStatus(response.status()))
}
File.with_writer!(destination, Truncate, {}, |writer| writer.write_stream!(response.chunks()))?
  • You see the status before any of the body is read, so you can decline a 404's body.
  • idle_timeout (30 seconds unless you set it) bounds each single wait, not the whole download. A slow but healthy download keeps going; a stalled one stops. After an idle timeout the response is still usable.
  • response.read_to_end!(MaxBytes(1_048_576)) reads the rest with a bound, and response.content_length() is Known(n) or Unknown.
  • Http.send!, send_json!, get! and get_utf8! now take trailing options: pass {} to return the first response, as before.
  • Redirects are opt-in: Http.send!(request, { max_redirects: 5 }) follows 301, 302, 303, 307 and 308. Streaming accepts the same max_redirects option. Intermediate bodies are closed without collecting them; the final status, including a 404, is still a response.
  • Redirects use one overall request deadline, including the final body. Cross-origin hops remove authorization and cookie headers, and HTTPS-to-HTTP redirects fail with HttpErr(InsecureRedirect). A POST becomes GET after 301/302, and a non-HEAD request becomes GET after 303; 307/308 preserve the method and body.
  • A positive redirect budget that runs out is HttpErr(RedirectLimitExceeded(n)). Duplicate Location headers and invalid destinations return AmbiguousRedirectLocation and InvalidRedirectUrl(Url.ParseErr) under HttpErr; a missing Location returns the original 3xx response. With the default budget of zero, the host returns the first response without validating redirects.
  • A caught pending signal returns bare Interrupted. An interrupted body read keeps its unread bytes, so after acknowledging the signal you can read the same response again. Its overall deadline continues to run.

TCP: typed errors and a timeout the connection remembers

# 0.25
stream = Tcp.connect!("127.0.0.1", 8085, 1_000) ? |err| ConnectFailed(err)
stream.write_utf8!("${out_msg}\n", 5_000) ? |err| TcpWriteFailed(err)
in_msg = stream.read_line!(1_048_576, 5_000) ? |err| TcpReadFailed(err)

# 0.26
conn = Tcp.connect!("127.0.0.1", 8085, { connect_timeout: Duration.seconds(1), io_timeout: Duration.seconds(5) })?
conn.write_utf8!("${out_msg}\n")?
in_msg = conn.read_line!()?
  • Tcp.Stream is now Tcp.Connection. It holds its I/O timeout and line limit; conn.with_timeout(d) and conn.with_line_limit(n) return the same connection with another setting.
  • Every failure is TcpErr(IOErr), so result == Err(TcpErr(TimedOut)) and Err(TcpErr(ConnectionRefused)) can be matched directly. Windows takes about two seconds to report a refused connection, so a shorter connect_timeout gives TimedOut there. IOErr gains TimedOut, ConnectionRefused, ConnectionReset, AddrInUse and AddrNotAvailable.
  • A read that times out or is interrupted consumes nothing. The bytes that had arrived are returned by the next read, so a client that tolerates idle periods just calls conn.lines() again. Caught pending signals return bare Interrupted; a failed write may have sent a prefix.
  • A line longer than the limit is Err(ReadLimitExceeded(n)); the rest of it is discarded and the next read starts at the next line.
  • listener.accept!({ accept_timeout: NoTimeout, io_timeout: Duration.seconds(30) }) separates how long to wait for a client from the timeout that client's connection gets.

Catch stop requests and finish cleanup

Signal.catch! lets applications handle Ctrl-C and Unix SIGTERM, including the default systemctl stop request. Register before acquiring a resource that needs cleanup:

match System.target!().os() {
    Linux(os) => Signal.catch!([Interrupt, Terminate(os.unix())])?
    MacOS(os) => Signal.catch!([Interrupt, Terminate(os.unix())])?
    Windows(_) => Signal.catch!([Interrupt])?
}

match run_service!() {
    Err(Interrupted) => {
        events = Signal.poll!()  # acknowledge before effectful cleanup
        cleanup!()?
        Ok({})
    }
    other => other
}
  • Registrations add to the process's caught events for its lifetime. Repeated events coalesce; they remain pending until Signal.poll!() acknowledges them. Repeated Ctrl-C does not force exit.
  • Sleep, standard input/output, command waits and pipe I/O, TCP/Unix-socket operations, HTTP, and SQLite preparation/execution/busy waits return bare Interrupted. A stream reports it once and ends; its handle can be used again after acknowledgement.
  • Interrupted foreground command calls terminate and reap their owned child before returning. An interrupted spawned child's wait/read/write leaves that child under the application's control. Child close/kill and transaction rollback remain available while an event is pending.
  • Interrupted reads preserve bytes already buffered by the platform. A terminal may discard an unsubmitted line on Ctrl-C. Interrupted writes may have written a prefix. Once cancellation returns, that operation performs no later writes. Cleanup of remote resources is the application's responsibility.
  • If Interrupted reaches main!, the platform restores terminal raw mode and exits 130 for Interrupt or 143 for Terminate. Catching a request alone does not run application cleanup. Poll and handle it explicitly as above.
  • Windows supports Ctrl-C, and Unix supports SIGINT/SIGTERM. SIGKILL, Windows forced termination, and systemd's eventual TimeoutStopSec escalation cannot be caught. Blocking native filesystem calls and process creation/reaping may delay observation; bounded DNS work already in progress may finish in the background.

Discover the target and keep OS evidence

System.target!() replaces Env.platform!(). Its pure .arch() and .os() methods describe the native target. Matching Linux(os) or MacOS(os) yields an opaque witness whose .unix() method enables Unix-only operations; Windows(os) has no such method. Witnesses have no public constructors or codecs and can be passed to helpers. Portable effects keep the same API on each supported target.

Local stream sockets

UnixSocket.connect!(path, { connect_timeout, io_timeout }) and UnixSocket.listen!(path) add pathname AF_UNIX stream sockets on Linux, macOS and Windows 10 version 1803 (build 17134) or later, including Windows 11. Connections use the same buffered byte, line, chunk and write operations as TCP; listeners accept with separate accept and I/O timeouts. Failures are UnixSocketErr(IOErr), with bare Interrupted for caught pending signals.

Pathnames keep their native bytes on Unix, subject to the filesystem's accepted names. Windows requires a UTF-8-representable path. Each operating system's pathname length limit applies; abstract sockets and NUL-containing names are rejected. Listening never removes an existing path, and closing leaves the path in place: remove it explicitly with path.delete!() before binding it again.

Commands: four ways to run, one result, one error

Cmd had seven ways to run a command, which disagreed on whether a non-zero exit was an error. There are now four:

Cmd.call!("cargo", ["build", "--release"])?              # inherit the terminal; must succeed
sha = Cmd.new("git").args(["rev-parse", "HEAD"]).capture!()?.trim()  # stdout as text; must succeed
output = Cmd.new("cat").arg("notes.txt").run!()?                     # status and output as data
child = Cmd.new("server").stdout(Pipe).spawn!()?                     # run in the background
  • run! returns a Cmd.Output with status, stdout_bytes and stderr_bytes, plus require_success(), stdout_utf8(), stdout_utf8_lossy() and stderr_utf8_lossy(). It prints its output as text, not as a list of byte numbers.
  • Command failures use CmdErr(Cmd.Err); cooperative cancellation is bare Interrupted. Causes include: StartFailed(IOErr, program), TimedOut(partial_output), OutputLimitExceeded(partial_output) or IOErr(IOErr). A missing program is Err(CmdErr(StartFailed(NotFound, _))).
  • .timeout(Duration.minutes(5)) bounds the whole run, including a spawned child, which is killed at the deadline.
  • Errors and to_inspect no longer contain environment values, so a secret passed with .env cannot leak into a log.
  • Compact Cmd.call!(program, args), capture!, run!, and spawn! complement the reusable Cmd.Command builder. Builder execution uses .call!() too.
  • cmd.inherit_stdio().run!()?.status waits without recording output in memory. .stdout(InheritStderr) sends child stdout directly to parent stderr.
  • .stderr(MergeWithStdout) and .output_limit(NoLimit) say directly what used to be encoded in a Bool or a zero.
  • Unix Cmd.exec!(unix, executable_path, args) replaces the current process and preserves its PID. It uses an exact path with no PATH lookup or shell fallback; success never returns. Finish buffered writers, transactions, terminal raw mode and child cleanup first. A failed replacement leaves the Roc process running.

SQLite: a connection you hold, rows you can stream

decode_todo : Decoder({ id : I64, task : Str }, Sqlite.DecodeErr)
decode_todo = { id: Decoder.i64("id"), task: Decoder.str("task") }.Decoder

db = Sqlite.open!("app.db", {})?
todo = db.query_one!("SELECT id, task FROM todos WHERE id = ${Integer(id)}", decode_todo)?
  • Sqlite.open! returns a Db that you pass around and can close!. Open read-only with { mode: ReadOnly }. A locked database is now waited on (five seconds unless you set busy_timeout) instead of failing at once. Sqlite.open_in_memory!() gives a private database on every call.
  • ${…} in a query is a bound parameter, never pasted into the SQL. Each value names its storage class (Integer(id), String(name)), and putting an interpolation inside quotes or a comment is a compile error.
  • Decoders are ordinary values built with record-builder syntax. You can annotate them, share them between queries, and name a column by name or by position (Decoder.i64(0)).
  • db.rows(query, decoder) is a lazy stream, so a large table can be read row by row and abandoned with break. query_one! and query_all! cover the common cases.
  • db.transaction!(|| { … }) commits if the body returns Ok and rolls back if it returns Err.
  • Errors say what went wrong. A UNIQUE violation is Err(SqliteErr(Failed({ code: Constraint(Unique), .. }))), with no message text to search. A wrong number of parameters is WrongParamCount instead of a silent NULL, and running two statements where one is expected is MultipleStatements instead of the second being ignored. execute_script! runs several statements on purpose.

Paths and OS strings compare by content

A path or OS string written as a literal never used to equal the same text returned by the platform, so these were silently always false:

verbose = args.contains("--verbose")
is_vim = Env.var!("EDITOR")? == "vim"
home = Dict.from_list(Env.dict!()).get("HOME")

They now work. Path and OsStr compare and hash by their content, whichever representation holds it. Paths are not normalised: a/b and ./a/b remain different.

Terminal

Tty.enable_raw_mode! and Tty.disable_raw_mode! now return Try({}, [TtyErr(IOErr)]), so a failure reaches your program. Enabling twice is harmless, and one disable undoes it. The host restores the terminal when your program returns, exits with a code, crashes, or is stopped by a signal such as Ctrl+C (#515).

Windows

The Windows x64 host keeps the MSVC ABI and now supplies the startup and runtime inputs that current Roc nightlies require, using the C runtime that ships with Windows 10 and 11 (#512). Windows execution is tested on Server 2025 in CI; reports from clean Windows 10 and 11 installations without the Visual C++ redistributables are still wanted.

Upgrading from 0.25

Update the platform URL and rebuild: the native host interface changed, and mixing hosts and platform sources from different versions is not supported. Then work through the modules you use.

Time

0.25 0.26
Utc.now!() returns U128 returns Utc; epoch integers are I128
Utc.delta_as_millis(a, b), delta_as_nanos later.since(earlier), a Duration. It is directional: zero if later is the earlier one
Monotonic.now!() returns U64 returns Monotonic; use start.elapsed!() or later.since(earlier)
path.time_modified!() returns U128 returns Utc
Sleep.millis!(n) / duration.sleep!() add ? or handle Interrupted

Stdin and files

0.25 0.26
Err(EndOfFile) Err(EndOfInput)
Err(FileUnexpectedEOF) Err(UnexpectedEndOfInput)
reader.read_line!() returns bytes with the \n, [] at the end returns a Str without it, Err(EndOfInput) at the end. For bytes, reader.read_until!('\n')
reader.read_up_to!(n) returns [] at the end Err(EndOfInput)
reader.chunks(n)? reader.chunks(); for a chosen size, call read_up_to!(n) in a loop
Stdin.line!() strips every trailing \r strips one, and reports invalid UTF-8 as Err(BadUtf8(_))

When upgrading an earlier 0.26 draft, add writer options: File.open_writer!(path, mode, {}) and File.with_writer!(path, mode, {}, callback!). The callback stays last. Choose { create_permissions: OwnerOnly } for new private files.

Http

0.25 0.26
Http.send!(request) Http.send!(request, {})
Http.send_json!(request, value) Http.send_json!(request, value, {})
Http.get!(url), Http.get_utf8!(url) Http.get!(url, {}), Http.get_utf8!(url, {})
No redirect following Opt in with { max_redirects: n }; the default is zero

Tcp

0.25 0.26
Tcp.Stream Tcp.Connection
connect!(host, port, ms) connect!(host, port, { connect_timeout, io_timeout })
listen!(host, port, ms) listen!(host, port, Duration)
accept!(ms), ListenerClosed accept!({ accept_timeout, io_timeout }), Closed
read_up_to!(n, ms), Ok([]) at the end read_up_to!(n), Err(EndOfInput)
read_exactly!(n, ms), TcpUnexpectedEOF read_exactly!(n), UnexpectedEndOfInput
read_until!(byte, max, ms), TcpReadLimitExceeded(n) read_until!(byte), ReadLimitExceeded(n)
read_line!(max, ms) keeping \n, TcpReadBadUtf8(e) read_line!() without it, BadUtf8(e)
write!(bytes, ms), write_utf8!(str, ms) write_bytes!(bytes), write_utf8!(str)
ConnectErr, TcpReadErr(…), TcpWriteErr(…), TcpListenErr(…) TcpErr(IOErr)

Cmd

0.25 0.26
Cmd.exec!("cargo", ["build"]) Cmd.call!("cargo", ["build"])
cmd.exec_cmd!() cmd.call!()
cmd.exec_output!()?.stdout_utf8 cmd.capture!()?
cmd.exec_output_bytes!() cmd.run!()?.require_success()?.stdout_bytes
cmd.exec_exit_code!() cmd.inherit_stdio().run!()?.status
args_str, arg_str and the other _str builders args, arg: an OsStr literal interpolates, and a List(Str) is names.map(OsStr.from_str)
timeout_ms(ms), where 0 meant no deadline timeout(Duration); leave it out for no deadline, and zero expires immediately
output_limit(n) output_limit(MaxBytes(n)) or output_limit(NoLimit)
merge_stderr(True) stderr(MergeWithStdout)
child.pid!(), try_wait!(), write!(bytes, ms) child.pid(), poll!(), write_bytes!(bytes, Duration)
child.read!(max, ms), Ok(End) child.read!(Duration), Err(EndOfInput)
RunOutput, RunErr, and the per-function error tags Cmd.Output, CmdErr(Cmd.Err), BadExitStatus(Output)

Sqlite

0.25 0.26
Sqlite.query!({ path, query, bindings, row }) db = Sqlite.open!(path, {})? then db.query_one!(query, decoder)
Sqlite.query_many! db.query_all!, or db.rows to stream
Sqlite.execute!({ path, … }) db.execute!(query); it now rejects a statement that returns rows before running it
bindings: [{ name: ":id", value: Integer(id) }] ${Integer(id)} in the query, or Query.new(sql, values) with ? markers
Sqlite.prepare!, Stmt removed; each Db caches prepared statements by SQL text
Sqlite.i64("id")(cols)(stmt)? Decoder.i64("id") in a record builder
Sqlite.u8("n") and the other narrow widths `Decoder.i64("n").map_try(
[NotNull(x), Null] Try(x, [Null]) from decoder.nullable()
SqliteErr(code, message) SqliteErr(Sqlite.Err); decode failures are BadRow(err)
NoRowsReturned, TooManyRowsReturned NoRows, TooManyRows
one shared :memory: database a private one per open!

Everything else

  • IOErr has five new variants (TimedOut, ConnectionRefused, ConnectionReset, AddrInUse, AddrNotAvailable). A match that lists every IOErr variant needs new arms, even in code that never uses TCP.
  • Path and OsStr values that differed only in representation are now equal, and collapse to one Dict or Set key.
  • Tty.enable_raw_mode! and disable_raw_mode! return a Try; add ?.
  • Write True and False, not Bool.True and Bool.False, to match the platform's own docs and examples.

Fixes

  • Http.with_json_body and Http.decode_json_response are declared as the pure functions they are.
  • Locale.get! errors now combine with other errors when called through a function value.
  • File readers, writers, sockets, child processes, SQLite connections and HTTP responses are released as soon as your program drops its last reference to them. The test suite now asserts on Linux, macOS and Windows that none is left open at exit.

Documentation and tooling

  • CONTRIBUTING.md has a new API conventions section, the rule set every module in this release follows.
  • CI now formats, lints and unit-tests the Rust host on Linux, macOS and Windows (#514).
  • New examples: file-write-stream.roc and http-download.roc. The TCP, command, SQLite, stdin and file examples are rewritten for the new APIs.