Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,15 @@ with different arguments, stdin, environment, fixtures, helper servers,
expected exit codes, or output assertions. `happy` is only a naming convention;
an app can have any number of successful and failing cases.

The runner normally uses the compiler cache. A temporary workaround disables it
for `x64musl` and `x64mac` builds with `nightly-2026-10-12-2664a27`: after
`roc check` and `roc test` warm the cache, the command stress helper can SIGSEGV
before its first write. The same build passes with `--no-cache`
([controlled reproduction](https://github.com/roc-lang/basic-cli/actions/runs/39512913704)).
TODO: remove this workaround once a compiler update passes that cached
check/test/build sequence on both targets. Other Unix targets and check/test
stages retain caching; the existing Windows workaround is unchanged.

CI separates source validation, cross-target compilation, and native execution.
Every example is compiled for each target declared in `platform/main.roc`:
`x64mac`, `arm64mac`, `x64win`, `x64musl`, and `arm64musl`. Target-specific
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ These host behaviors may affect application design:
- HTTP redirects are opt-in through trailing options, `{ max_redirects: n }`;
the default is zero. The request deadline covers every hop and the final
body. Cross-origin hops strip credentials; HTTPS downgrades are refused.
- HTTP defaults to `User-Agent: roc-basic-cli/0.26`, preserving an explicit
user agent. A non-empty body defaults to `Content-Type: text/plain` unless
one is set; an empty body adds no content type.
- A spawned `Cmd.Child` is killed and reaped when its last reference is
released; an unused `_child` binding does not keep it alive. `Pipe` output
is read through `child.read!(duration)` and is absent from `child.wait!()`;
choose `Capture` to retain it for `wait!`.
`.stdout(File(path, Append, {}))` or `.stderr(File(path, Truncate, {}))`
writes directly to disk with the writer modes and creation permissions.
`MergeWithStdout` shares stdout's open file; file-backed streams do not
consume capture or pending-output budgets.
- `Utc` supplies clock readings and signed epoch conversions. Use a package
such as [roc-time](https://github.com/lukewilliamboswell/roc-time) for timestamp
parsing, calendar calculations and richer formatting. Its documented profiles
use microseconds, while `Utc` stores nanoseconds. `Utc.to_iso_8601` is a
whole-second display; signed epoch nanoseconds preserve the stored instant.
- `Signal.catch!` catches Ctrl-C and, with a `System.Unix` witness, SIGTERM
(including default `systemctl stop`). Blocking effects return bare
`Interrupted`. Call `Signal.poll!()` before effectful application cleanup;
Expand Down
1 change: 1 addition & 0 deletions ci/rust_http_server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ async fn handle_redirect(req: Request<Incoming>) -> Result<Response<BoxBody>, Ge
"cookie",
"proxy-authorization",
"content-type",
"user-agent",
"content-length",
"x-hop",
"x-public",
Expand Down
30 changes: 27 additions & 3 deletions docs/releases/0.26.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ Until now each basic-cli module had grown its own habits. A timeout was a bare `

0.26.0 settles these questions once and applies the answers everywhere:

- **A timeout is a `Duration`**, and `Duration.zero` always expires immediately.
- **Basic-cli timeout options use `Duration`**, and `Duration.zero` always expires immediately. The shared HTTP package keeps its existing request-timeout representation, described below.
- **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 `==`.
- **A module's failures arrive under one tag** (`TcpErr(IOErr)`, `CmdErr(Cmd.Err)`, `SqliteErr(Sqlite.Err)`), while conditions such as `EndOfInput` and `Interrupted` remain bare tags. Every error can be compared with `==`.

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

Expand Down Expand Up @@ -58,6 +58,8 @@ stale = Utc.now!().since(path.time_modified!()?) > Duration.minutes(5)
- **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)`.

`Utc` supplies clock readings and signed epoch conversion. Timestamp parsing, calendar calculations and richer formatting belong in packages such as [roc-time](https://github.com/lukewilliamboswell/roc-time), rather than the native host. See its [supported profiles](https://lukewilliamboswell.github.io/roc-time/profiles.html#profiles): roc-time 0.3 uses microseconds, while `Utc` stores nanoseconds. Convert units explicitly between them. `Utc.to_iso_8601` remains a whole-second display; `to_nanos_since_epoch` and `from_nanos_since_epoch` preserve the full stored precision.

## Loop over input

Reading lines used to mean hand-written recursion. Now it is a loop, and the same loop works on every source:
Expand Down Expand Up @@ -129,8 +131,10 @@ File.with_writer!(destination, Truncate, {}, |writer| writer.write_stream!(respo

- 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.
- The shared HTTP `Request` keeps its existing timeout representation: `TimeoutMilliseconds(n)` sets the overall deadline, while `NoTimeout` (the default) and `TimeoutMilliseconds(0)` mean no deadline. Streaming `idle_timeout` uses `After(Duration)` or `NoTimeout`; `After(Duration.zero)` expires immediately.
- `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.
- Requests send `User-Agent: roc-basic-cli/0.26` by default, so APIs that require a user agent can be called directly. An explicit `User-Agent` overrides it regardless of header-name casing. `Content-Type: text/plain` is added only for a non-empty body without an explicit content type; bodyless GETs add none ([#531](https://github.com/roc-lang/basic-cli/issues/531)).
- 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.
Expand All @@ -151,7 +155,7 @@ 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`.
- TCP I/O failures use `TcpErr(IOErr)`, so `result == Err(TcpErr(TimedOut))` and `Err(TcpErr(ConnectionRefused))` can be matched directly. Conditions such as `EndOfInput`, `BadUtf8`, `ReadLimitExceeded` and `Interrupted` remain bare tags. 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.
Expand Down Expand Up @@ -210,7 +214,10 @@ child = Cmd.new("server").stdout(Pipe).spawn!()? # run in th
- `.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.
- Write a child's output directly to disk with `.stdout(File("server.log", Append, {}))` or `.stderr(File(path, Truncate, {}))` ([#532](https://github.com/roc-lang/basic-cli/issues/532)). The modes and options are the same as `File.Writer`, including `RequireNew` and `{ create_permissions: OwnerOnly }`. Relative paths use the command's working directory. With `.stderr(MergeWithStdout)`, both streams share the same open file. No output is captured or queued in memory for a file-backed stream, so capture and pending-output limits do not restrict it. Files are opened before the child starts; a failure returns `CmdErr(StartFailed(...))` and may leave newly created or truncated files.
- `cmd.inherit_stdio().run!()?.status` waits without recording output in memory. `.stdout(InheritStderr)` sends child stdout directly to parent stderr.
- Keep a spawned `Child` for later reads, waits or cleanup: releasing its last reference kills and reaps a running child. An unused binding such as `_child` can release it immediately.
- A spawned child's `Pipe` output is available only through `child.read!(duration)`; it is absent from `child.wait!()`'s output. Choose `Capture` to retain that stream for `wait!`, subject to the output limit.
- `.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.

Expand Down Expand Up @@ -255,6 +262,18 @@ The Windows x64 host keeps the MSVC ABI and now supplies the startup and runtime

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.

With `nightly-2026-10-12-2664a27`, Linux x64 and Intel macOS builds can crash when reusing a cache warmed by `roc check`/`roc test`. For that compiler bug, use `roc build --no-cache your-app.roc` ([reproduction](https://github.com/roc-lang/basic-cli/actions/runs/39512913704)). This is a temporary compiler workaround, not a requirement of the platform upgrade.

**Target discovery**

| 0.25 | 0.26 |
|---|---|
| `Env.platform!()` returns a record | `System.target!()` returns an opaque `System.Target` |
| `Env.platform!().arch` | `System.target!().arch()` returns `X64` or `Arm64`; `AARCH64` is now `Arm64` |
| `Env.platform!().os` returns `LINUX`, `MACOS` or `WINDOWS` | `System.target!().os()` returns `Linux(os)`, `MacOS(os)` or `Windows(os)` |

Keep the witness returned by an OS match when calling OS-specific operations: `os.unix()` on Linux or macOS supplies the evidence required by `Cmd.exec!` and `Terminate`.

**Time**

| 0.25 | 0.26 |
Expand Down Expand Up @@ -306,6 +325,7 @@ When upgrading an earlier 0.26 draft, add writer options: `File.open_writer!(pat

| 0.25 | 0.26 |
|---|---|
| `Cmd` as a command-builder type annotation | `Cmd.Command`; `Cmd` is the namespace |
| `Cmd.exec!("cargo", ["build"])` | `Cmd.call!("cargo", ["build"])` |
| `cmd.exec_cmd!()` | `cmd.call!()` |
| `cmd.exec_output!()?.stdout_utf8` | `cmd.capture!()?` |
Expand Down Expand Up @@ -337,7 +357,9 @@ When upgrading an earlier 0.26 draft, add writer options: `File.open_writer!(pat

**Everything else**

- **Caught signals add bare `Interrupted` results** to sleep, standard I/O, command waits and pipe I/O, TCP/Unix sockets, HTTP and SQLite operations. Update exhaustive error matches. Before effectful cleanup or retry, acknowledge pending events with `Signal.poll!()`; propagating `Interrupted` to `main!` exits without running application cleanup.
- **`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` representation tags are renamed**: `Unix(bytes)` becomes `UnixBytes(bytes)`, and `Windows(units)` becomes `WindowsU16s(units)`, matching `OsStr`. `Utf8` and the `Path.unix_bytes`/`Path.windows_u16s` constructors keep their names.
- **`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.
Expand All @@ -354,6 +376,8 @@ When upgrading an earlier 0.26 draft, add writer options: `File.open_writer!(pat
- CI now formats, lints and unit-tests the Rust host on Linux, macOS and Windows ([#514](https://github.com/roc-lang/basic-cli/pull/514)).
- New examples: [`file-write-stream.roc`](https://github.com/roc-lang/basic-cli/blob/0.26.0/examples/file-write-stream.roc) and [`http-download.roc`](https://github.com/roc-lang/basic-cli/blob/0.26.0/examples/http-download.roc). The TCP, command, SQLite, stdin and file examples are rewritten for the new APIs.

When copying an example from an RC tag or a source checkout, replace its pinned platform URL with the 0.26.0 bundle URL above before running it. The stable release workflow opens a follow-up PR to update checked-in example URLs after publication.

---

- [API documentation](https://roc-lang.github.io/basic-cli/0.26.0/)
Expand Down
Loading
Loading