Repository navigation
0.26.0-rc3
Pre-releasebasic-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 cooperativeInterruptedresults so applications can clean up before exiting. - Use compact
Cmd.call!, a reusableCmd.Commandbuilder, and UnixCmd.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, andDuration.zeroalways expires immediately. - The end of input is
Err(EndOfInput), on stdin, files and sockets alike. An emptyOkis real data. - Anything you can read, you can loop over:
lines()andchunks()return aStreamon stdin, file readers, TCP connections and HTTP responses, androwsdoes 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)Durationis a non-negative length of time with nanosecond resolution:Duration.millis(250),Duration.seconds(5),Duration.minutes(2). It supports+,-,==, the ordering operators andList.sort, and prints as"250ms"or"1.5s". Nothing overflows or goes negative: results are clamped, sobudget - start.elapsed!()is simply zero once the budget is spent.Utcis 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 into_iso_8601.Monotonicis an opaque clock reading.start.elapsed!()andlater.since(earlier)return aDuration, so a reading can no longer be mistaken for a length of time.- File times (
time_accessed!,time_modified!,time_created!) returnUtcand succeed for files older than 1970. - Sleep is now fallible: use
Sleep.millis!(250)?orduration.sleep!()?. A caught stop request returnsErr(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()andreader.chunks()are new. Lines never include the\n, and one preceding\ris 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
breakout ofStdin.lines()after a header and hand the rest toStdin.read_to_end!()without losing a byte, and mixreader.lines()withseek!andread_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,AppendorRequireNew(fails withAlreadyExistsif 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. WithFile.open_writer!, callflush!yourself after the last write.Path.append_bytes!andPath.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)
})?OwnerOnlygrants the file's owner read/write access and grants others no access.PublicReadadditionally 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
AppendorTruncate. UseRequireNewwithOwnerOnlyfor secrets: an existing file or symbolic link fails withAlreadyExists, before its contents can change. path.set_permissions!(OwnerOnly)andpath.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 returnsPermissionDenied. Linux can restore access to a mode-000 file through a held metadata handle and requires/proc/self/fdfor 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, andresponse.content_length()isKnown(n)orUnknown.Http.send!,send_json!,get!andget_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 samemax_redirectsoption. 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)). DuplicateLocationheaders and invalid destinations returnAmbiguousRedirectLocationandInvalidRedirectUrl(Url.ParseErr)underHttpErr; a missingLocationreturns 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.Streamis nowTcp.Connection. It holds its I/O timeout and line limit;conn.with_timeout(d)andconn.with_line_limit(n)return the same connection with another setting.- Every failure is
TcpErr(IOErr), soresult == Err(TcpErr(TimedOut))andErr(TcpErr(ConnectionRefused))can be matched directly. Windows takes about two seconds to report a refused connection, so a shorterconnect_timeoutgivesTimedOutthere.IOErrgainsTimedOut,ConnectionRefused,ConnectionReset,AddrInUseandAddrNotAvailable. - 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 bareInterrupted; 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
Interruptedreachesmain!, 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
TimeoutStopSecescalation 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 backgroundrun!returns aCmd.Outputwithstatus,stdout_bytesandstderr_bytes, plusrequire_success(),stdout_utf8(),stdout_utf8_lossy()andstderr_utf8_lossy(). It prints its output as text, not as a list of byte numbers.- Command failures use
CmdErr(Cmd.Err); cooperative cancellation is bareInterrupted. Causes include:StartFailed(IOErr, program),TimedOut(partial_output),OutputLimitExceeded(partial_output)orIOErr(IOErr). A missing program isErr(CmdErr(StartFailed(NotFound, _))). .timeout(Duration.minutes(5))bounds the whole run, including a spawned child, which is killed at the deadline.- Errors and
to_inspectno longer contain environment values, so a secret passed with.envcannot leak into a log. - Compact
Cmd.call!(program, args),capture!,run!, andspawn!complement the reusableCmd.Commandbuilder. Builder execution uses.call!()too. cmd.inherit_stdio().run!()?.statuswaits 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 aBoolor 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 aDbthat you pass around and canclose!. Open read-only with{ mode: ReadOnly }. A locked database is now waited on (five seconds unless you setbusy_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 withbreak.query_one!andquery_all!cover the common cases.db.transaction!(|| { … })commits if the body returnsOkand rolls back if it returnsErr.- 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 isWrongParamCountinstead of a silent NULL, and running two statements where one is expected isMultipleStatementsinstead 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
IOErrhas five new variants (TimedOut,ConnectionRefused,ConnectionReset,AddrInUse,AddrNotAvailable). Amatchthat lists everyIOErrvariant needs new arms, even in code that never uses TCP.PathandOsStrvalues that differed only in representation are now equal, and collapse to oneDictorSetkey.Tty.enable_raw_mode!anddisable_raw_mode!return aTry; add?.- Write
TrueandFalse, notBool.TrueandBool.False, to match the platform's own docs and examples.
Fixes
Http.with_json_bodyandHttp.decode_json_responseare 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.rocandhttp-download.roc. The TCP, command, SQLite, stdin and file examples are rewritten for the new APIs.