Skip to main content

A tour of Hew 0.6: files, concurrent work and services

· 28 min read

A file tool starts simply: read an input, calculate something, print a result. Then there are several inputs. One read fails. A job takes too long. Eventually the tool becomes a service, and now it has a queue, connections to close and workers to restart.

That is a useful way to look at the additions and expanded forms since Hew 0.5.6. Actors and machines already existed; this tour also shows how existing features compose as a program grows. An ordinary function can become concurrent work, a value can become a message, and cleanup follows the work through both.

This tour covers three points in that progression:

VersionWhat the examples describe
0.6.0, releasedThe file tool and the main language tour below. The release notes and migration guide describe the published SDK.
0.6.1, releasedGeneric closures, compile-time inputs, macros, confinement and the other additions in the 0.6.1 section. See the 0.6.1 release notes and the examples below.
Development previewThe final section follows the development source. Those examples are part of the 0.6.2 preview and require that development toolchain.

Jump to data, failures, ownership, tasks, actors, streams, machines, 0.6.1 additions, or development previews.

Run the file and service examples with the native SDK. The sandbox capability matrix lists the separate browser and WASI target coverage.

Start with two files

Save this complete 0.6.0 program as sizes.hew:

import std.fs;
 
fn byte_count(path: string) -> i64 fails fs.IoError {
    let contents = fs.read_bytes(path)?;
    contents.len()
}
 
fn main() fails fs.IoError {
    let results = await fork [
        byte_count("first.txt"),
        byte_count("second.txt"),
    ];
    for result in results { println(result?); }
}

Create the inputs and run it:

printf 'Hew\n' > first.txt
printf 'hello\n' > second.txt
hew run sizes.hew

It prints 4 and 6. The calls run concurrently, and the results stay in input order. Each call keeps its own typed read error, which the loop can propagate with ?. This version reads each whole file into memory; a streaming version makes more sense for large inputs.

There is quite a bit of language in that small program. The rest of the tour pulls it apart, then adds the pieces a longer-lived service needs. Later snippets are excerpts: names such as load, fetch and save stand for application functions where their implementation is beside the point.

Make the data easy to work with

Modules, records and the names between them

An import follows the module path with dots. import std.fs makes fs available; import std.encoding.json makes json available. Selective imports name the items in braces, and generic application uses the same <T> spelling on types and calls:

import std.encoding.json;
 
type Summary {
    path: string;
    bytes: i64;
}
 
enum JobState {
    Waiting;
    Finished { summary: Summary; }
}
 
package fn empty_summary(path: string) -> Summary {
    Summary { path, bytes: 0 }
}

type declares a record. Fields and enum variants end with semicolons; a value’s field list uses commas. Summary { path, bytes: 0 } uses the local path binding directly. A known expected type lets a variant be written as .Waiting or .Finished { summary }; JobState.Waiting supplies the type explicitly.

The package modifier makes the helper available within its package. An unmarked declaration stays private to its module, while pub exposes it to importing packages. The package guide covers how those modules become a reusable dependency.

Change a value while keeping the earlier snapshot

A report often needs a few variations of the same input. Record updates and vector spreads keep that code close to the data:

let first = Summary { path: "first.txt", bytes: 4 };
let renamed = Summary { ..first, path: "archive/first.txt" };
 
let earlier = [first];
let reports = [..earlier, renamed];
var revised = reports;
revised.push(Summary { path: "second.txt", bytes: 6 });

The earlier values remain usable. var permits changes to that binding; it does not turn every copy into an alias of shared mutable storage. Ordinary values have snapshot semantics, and clone value makes duplication explicit where that is useful. Resource ownership has a different rule, which becomes important when the data contains an open file or connection.

Definite assignment also lets a binding receive its initial value along different control-flow paths. The checker requires every path reaching a read to have initialized it, so there is no need to invent a placeholder value just to satisfy a declaration.

fn choose(primary: bool) -> string {
    let path: string;
    if primary { path = "first.txt"; }
    else { path = "second.txt"; }
    path
}

The type guide introduces the declarations, and the 0.6.0 language guide has the complete record, spread and snapshot examples.

Keep the successful path in view

A missing setting, a failed read and a programming fault need different handling. Hew gives ordinary absence an Option, returned application errors a Result, and a declared failure path the fails clause used by byte_count.

fn positive_limit(value: i64) -> i64 fails string {
    if value <= 0 {
        return error "the limit must be positive";
    }
    value
}
 
let limit = positive_limit(configured_limit) handle message {
    println(message);
    10
};

The function body returns its success value normally. return error takes the declared failure path, while handle supplies local recovery. A caller that also declares fails can use ? to propagate the error instead. When accessing a successful result immediately, grouping makes the operation clear: (load()?).len().

Conditions can combine a successful pattern with tests on what it bound:

if let .Some(limit) = requested_limit && limit > 0 {
    println(limit);
}
 
let .Some(path) = requested_path else {
    return;
};

The first form avoids nested if blocks. The second keeps the binding available after the check because its else path leaves the current flow. For a chain of optional data, user?.address?.city carries absence through the member accesses, and ?? provides a default at the end.

Cleanup belongs beside the operation that needs it. A regular defer runs on scope exit; defer err runs when the scope exits through failure, a fault or cancellation. In an application that creates a temporary output, the shape is:

defer err { remove_temporary_output(); }
write_output()?;
publish_output()?;

Returning .Err(...) as ordinary data from a -> Result<...> function is still an ordinary return. Use fails and its failure operations when that is the control flow the function means to express.

Put ownership in the interface

An open connection has a lifetime that a string does not. A #[resource] type names its cleanup operation, and consume says when an operation takes the owner:

#[resource]
type Connection {
    descriptor: i64;
}
 
impl Connection {
    fn close(consume self) {
        release_descriptor(self.descriptor);
    }
}

Here release_descriptor is the application’s descriptor-release function. A live resource closes when its scope exits, including cleanup after failure or cancellation. An explicit close consumes it earlier.

For a transaction that must be committed or rolled back, #[linear] requires an explicit consuming operation on every exit path. It has no automatic close. That distinction makes an unfinished transaction a compile error rather than an implicit choice of outcome.

Function values can state ownership and invocation requirements too. fn(...) -> T is repeatedly callable through a read-only value, fn[var](...) -> T needs a mutable callable place, and fn[once](...) -> T consumes the callable when invoked. Adding clone promises independent duplication, while suspends permits the call to wait. These distinctions matter when a callback keeps a resource or mutable state.

fn apply_twice(f: fn[clone](i64) -> i64, value: i64) -> i64 {
    f(f(value))
}
 
fn read_with(load: fn[suspends](string) -> string, path: string) -> string {
    load(path)
}

A private mutable capture is written where the closure is created:

let count = 0;
var next: fn[var, clone]() -> i64 = capture(var count) || {
    count += 1;
    count
};

The counter belongs to this closure; it does not mutate the original binding. With an owned connection, a one-use callback can transfer and eventually close it:

let finish: fn[once]() -> i64 = move || {
    connection.close();
    0
};

move transfers captured ownership. Consuming that capture makes this callback call-once. The ownership examples in the language guide show resources, linear values and consuming methods together.

Choose where work runs concurrently

The file tool used await fork [...] because all its jobs had the same shape. The smaller building blocks are useful as soon as preparation and reporting take time too:

let first = fork byte_count("first.txt");
let prepared = fork {
    let text = load_template();
    prepare_report(text)
};
 
let bytes = await first;
let report = await prepared;

fork call(...) and fork { ... } produce tasks. An ordinary call waits for its result; await joins a task that was started separately. Task results keep their declared types, including an ordinary Result when a job can fail.

There are several useful ways to collect work:

Work to collectForm
Calls with one result typeawait fork [first(), second()]
Calls with different result typesawait fork (read_header(), count_rows())
A vector of task handles already built by the programawait tasks

Vector results stay in vector order. A task belongs to its enclosing lifetime even if the program discards its handle, so discarding a handle is not a way to detach work.

Race a result, or select something ready

A first-response operation can join immediately or keep the race task for later:

let response = await race [primary.fetch(key), replica.fetch(key)];
 
let pending = race [primary.fetch(other_key), replica.fetch(other_key)];
prepare_display();
let other_response = await pending;

The inputs can also be task handles that the program has already created:

let primary_task = fork primary.fetch(key);
let replica_task = fork replica.fetch(key);
let response = await race [primary_task, replica_task];

race chooses the first normal completion, including an ordinary error value. It cancels and drains the losers before making the winning result available. That cleanup does not undo an external effect or retract a handler that another actor has already started.

A scope groups child work. Add within to place a deadline over the group and attach handle to recover after its work has drained:

let report = scope within 2s {
    let work = fork build_report();
    await work
} handle failure {
    cached_report()
};

select is useful when a task, an incoming stream item or a timer could determine the next step. With an existing task job and stream input, a selection can look like this:

select {
    value from job => show_result(value),
    item from input.recv() => show_item(item),
    after 100ms => show_progress(),
};

The selection does the waiting, so arm sources do not contain await. An existing task that loses this selection remains part of its scope and can still be joined. That is a useful difference from handing work to a race. The selection comparison and fan-out/fan-in example put these choices side by side.

Give a service its own state

An actor is useful when requests need to share a history: counters, a cache, a connection or the current stage of a job. Its handlers see that state one turn at a time.

actor Totals {
    var bytes: i64;
 
    receive fn add(amount: i64) {
        bytes += amount;
    }
 
    receive fn read() -> i64 {
        bytes
    }
}
 
let totals = spawn Totals { bytes: 0 };
totals.add(4).expect("record bytes");
println(totals.read().expect("read total"));

The keyed spawn spawn Totals { bytes: 0 } supplies the named state field; construction keys can also supply init parameters. A call on the resulting handle waits for that handler’s completion and returns its actor result. fork totals.read() starts that completion call concurrently. The actor type is also the type of its handle, so it can appear directly in a field or parameter.

Separate submission from completion

Some callers only need to submit work. mailbox(target) gives them a one-way view; policy(target) keeps completion semantics while choosing what to do when admission is full:

let inbox = mailbox(totals, on_full: .DropNewest);
let delivery = inbox.add(4);
 
let completion = policy(totals, on_full: .Reject).add(6);

Submission reports delivery, while a completion call reports what happened to the handler. A .Wait policy can suspend while waiting for capacity. A .Reject policy returns the unaccepted request, and a .DropNewest submission can discard the incoming message. The actor’s own mailbox declaration also controls admission and any replacement of queued submissions.

A rejected completion request keeps its owned payload. Its failure.message.retry() consumes that request and tries the original destination; failure.message.to(other) consumes it and redirects to a compatible handler. A request whose handler already ran has no equivalent safe retry guarantee.

The default matters: an ordinary handle waits for completion and capacity. A mailbox view inherits .Reject from an actor declared with overflow fail; otherwise it defaults to .Wait. The backpressure guide explains the delivery outcomes and queue policies.

Start, stop and restart deliberately

An actor’s init body initializes state before normal delivery. #[on(start)] and #[on(stop)] place lifecycle work beside its handlers, #[on(crash)] handles the fault lifecycle, and #[every(1s)] schedules a parameterless receive handler periodically.

Stopping has a request and a join, just as starting concurrent work does:

stop(totals);
stopped(totals);

stop requests graceful shutdown; terminate requests cancellation. Both return after the request, and stopped waits for terminal cleanup. A handler call finishing and the actor finishing are separate events.

For a small actor with one operation, an anonymous actor can be enough:

let factor = 3;
let scale = actor |n: i64| -> i64 { n * factor };
println(scale(7).expect("scale"));
stop(scale);
stopped(scale);

Its handle has an actor(i64) -> i64 shape and can be stored or passed like other values. The actor guide covers named and anonymous actors, initialization and lifecycle operations.

Keep worker roles stable across restarts

Once a service owns several workers, a supervisor can declare their construction and restart policy. This fragment assumes the application has a Worker actor:

supervisor Farm {
    strategy: simple_one_for_one;
    intensity: 5 within 60s;
 
    pool workers: Worker { id: 0 } count: 4;
}

A named child gives a stable supervised role; a pool gives indexed roles for a group. Code can keep the role while the supervisor replaces its actor incarnation, and restarted(role) waits for a replacement where the application needs that handoff. Pool members can restart independently. Stopping the supervisor requests shutdown of its children, and stopped joins their cleanup too.

For a Worker with an id_of handler, the same helper accepts a named child or a pool member. Call it when waiting for a known restart:

fn read_after_restart(worker: ChildRef<Worker>) -> i64 {
    restarted(worker);
    worker.id_of().expect("replacement worker")
}
 
let farm = spawn Farm;
let role: ChildRef<Worker> = farm.workers[0];

The supervision comparison shows the corresponding actor declarations and calls.

Process a flow without collecting everything first

The whole-file example is convenient, but a pipeline needs a way to read and write incrementally. Hew’s Sink<T> and Stream<T> provide the two ends of a pipe:

import std.stream;
 
let (output, input) = stream.pipe<string>(4).expect("create pipe");
output.send("first row").expect("send row");
output.finish();
 
for row in input {
    println(row);
}

send waits for capacity; try_send can report a full pipe immediately. recv returns an Option, where None means the stream has ended. An empty string or empty byte chunk is still an item. File and socket adapters use the same stream operations, so the processing loop need not know where its input began.

A generator is the local, lazy way to produce a sequence:

gen fn labels(prefix: string, count: i64) -> string {
    for index in 0 .. count {
        yield f"{prefix}:{index}";
    }
}
 
let short = gen { yield "first"; yield "second"; };
for label in labels("row", 3) {
    println(label);
}

Calling a generator constructs its state; iteration drives its body. Both generator functions and gen { ... } blocks can hold local state across yields. A generator declared with fails E yields Result items for the consumer to handle, and stopping early releases the generator’s owned state. The pipeline example and language guide cover streaming and generator composition.

Write the protocol as a machine

A service often has states that permit different inputs. Keeping that logic in a machine makes the protocol visible in one place, with typed outputs for the application to act on:

machine Meter {
    events {
        Reading { value: i64; }
    }
    emits {
        Alarm { value: i64; }
    }
    state Watching { peak: i64; }
 
    on Reading: Watching => Watching when event.value > state.peak {
        emit Alarm { value: event.value };
        Watching { peak: event.value }
    }
    on Reading: Watching => Watching { state }
}
 
var meter: Meter = .Watching { peak: 0 };
let report = meter.step(.Reading { value: 7 });

step updates the machine and returns a report containing the transition disposition and emitted outputs. The surrounding actor or function can inspect report.outputs and perform the actual I/O; the transition describes the state change.

Machines can take type parameters for their payloads and const parameters for fixed values such as a capacity. Composite states group related states, and entry/exit hooks keep shared transition behaviour with the state it belongs to. The machine guide introduces those forms. More deeply nested transitions and explicit re-entry appear in the 0.6.1 section below.

Share behaviour and exchange typed data

Traits can describe what a producer yields

An associated type lets a trait name a result that varies with the implementation. For example, a producer can define type Output and use Self.Output in its methods. Generic code keeps the concrete type, while a dyn Trait value carries a runtime choice of implementation.

trait Producer {
    type Output;
    fn output(self) -> Self.Output;
}
 
type Number {}
impl Producer for Number {
    type Output = i64;
    fn output(self) -> i64 { 1 }
}

That dynamic value can be returned from a function or stored in a collection, which is useful for a set of renderers, commands or handlers selected from configuration. The caller uses the trait’s methods without building a manual enum for every implementation. The type guide covers traits; the language guide’s associated-type examples show the declarations and implementations.

For example, a common naming interface can hide the returned implementation:

trait Named { fn name(self) -> string; }
type Person { label: string; }
impl Named for Person {
    fn name(self) -> string { self.label }
}
fn make_named(label: string) -> dyn Named { Person { label } }
 
let entries: Vec<dyn Named> = [make_named("ada"), make_named("lin")];
for entry in entries.into_iter() { println(entry.name()); }

Give serialized fields a stable identity

A typed protocol starts with the fields that must survive being stored or sent:

#[wire]
type JobSummary {
    id: u64 @1;
    path: string @2;
    note: Option<string> @3 optional;
}

The numeric tags are stable field identities. optional permits the key to be absent. A required Option<string> instead requires the key while allowing its value to be null, which is a different contract when another program sends the data.

JSON, CBOR, YAML, TOML and MessagePack expose typed encode and decode<T> operations. For example, json.decode<JobSummary>(text) reports a typed decoding error when the document does not fit. Format limits still apply: TOML has no null value, so a nullable schema needs a representation that TOML can express. The wire-type comparison explains field identity, compatibility and presence.

Keep blocking foreign work off scheduler workers

Native integrations sometimes need a C function that blocks. Put #[offload] on its extern declaration to run that call on the blocking pool while the Hew caller suspends:

extern "C" {
    #[offload]
    fn render_report(template: string, rows: Vec<string>) -> string;
}

The caller still makes an ordinary unsafe call. Offloaded arguments and results must satisfy the supported copyable boundary; consuming resource handles and raw pointers are refused. If the caller’s deadline expires, the foreign call may continue on the pool and discard its eventual result. That matters for a function that writes files or performs another external action.

Test the program at the level it is used

#[test] marks an ordinary test. Give the test concrete data parameters and the runner generates cases, shrinks failures and reports a reproducible counterexample:

#[test]
fn a_snapshot_keeps_its_values(values: Vec<i64>) {
    let before = values;
    var after = values;
    after.push(7);
    assert(after.len() == before.len() + 1);
}

#[should_panic] checks an expected fault, #[ignore] leaves a test out of normal runs, and #[serial] coordinates tests that need exclusive access to shared state. A benchmark uses #[bench] and a std.bench.Bencher parameter, with b.iter(...) describing the operation to measure.

import std.bench;
 
#[bench]
fn concatenate(b: bench.Bencher) {
    b.iter(|| "hello" + " world");
}

Run tests with hew test and measurements with hew bench. The testing guide explains case counts, seeds, filtering and benchmark baselines.

What 0.6.1 adds

The following examples use the released 0.6.1 SDK.

Keep a function generic after putting it in a value

A generic closure can keep its type parameters until each call, including when the closure is returned by another function:

fn make_identity() -> fn<T>(T) -> T {
    |<T> value: T| -> T { value }
}
 
let identity = make_identity();
let number = identity(41);
let greeting = identity<string>("hello");

Hew 0.6.1 also lets generic code constrain an associated type directly. where I.Item: Display says that an iterator’s item can be displayed, while I: Iterator<Item = T> ties that associated type to another type parameter. These constraints let an iterator helper state what it needs without forcing callers to change their container. The 0.6.1 specification includes quantified function values and projection bounds.

fn first_label<I: Iterator>(var items: I) -> string where I.Item: Display {
    match items.next() {
        .Some(item) => f"{item}",
        .None => "empty",
    }
}
 
fn equal_label<I: Iterator<Item = T>, T>(var items: I) -> string
    where <I as Iterator>.Item: Display {
    first_label(items)
}

Bring assets and configuration into the build

A schema, template or small default configuration can be compiled into the executable. Paths are relative to the source file and must stay inside the package:

const SCHEMA: string = include_str!("sql/schema.sql");
const LOGO: bytes = include_bytes!("assets/logo.png");
 
type Config {
    host: string;
    port: u16;
}
 
const DEFAULTS: Config = embed!("config/defaults.toml");

Those files must exist when the program is built. embed! decodes JSON, YAML or TOML into the expected type during compilation, including #[serial] names and skipped fields. Aggregate constants can also contain records, enums, tuples, arrays and collections built from constant values, so a default need not be flattened into separate scalar constants.

For diagnostics, stringify!(expression) gives the expression’s source form without evaluating it, and location!() produces a SourceLocation with file, line and column. The compile-time input guide covers input tracking, package boundaries and typed embedding.

let expression = stringify!(price + tax);
let here = location!();
println(f"{here.file}:{here.line}: {expression}");

Generate ordinary source where repetition earns it

A declarative macro can expand to an expression or to items. Repetition makes patterns such as a small sum concise:

macro sum {
    ($($value:expr),*) => expr {
        { var total = 0; $(total += $value;)* total }
    }
}
 
let count = sum!(1, 2, 3);

An items { ... } rule can produce declarations, and attribute macros can wrap a function while forwarding its parameters, return type and body. Expansion preserves the normal type and ownership checks. A macro that duplicates an expression also duplicates its evaluation, so use a template local when an argument should run once.

macro logged {
    #[($label:literal)] $f:fn => items {
        $f.vis fn $f.name $f.generics ($f.params) $f.ret $f.where {
            println($label);
            $f.body
        }
    }
}
 
#[logged("answer")]
fn answer() -> i64 { 42 }

For a schema that generates whole modules, declare a generation step in the manifest:

[[generate]]
name = "schema"
run = "tools/schema.hew"
args = ["schema.json", "models"]
inputs = ["schema.json"]
outputs = "models"

hew generate updates the step’s source output. hew generate --check compares regenerated output without replacing the checked-in tree, which makes it useful in CI. Dependency packages ship their generated source; importing one does not execute its generator. The macro guide and source-generation guide cover the two scales of reuse.

Wrap deliveries and supply the authority a component needs

An actor interceptor puts common per-delivery behaviour around its handler. This store refuses an erase and inspects a successful read:

actor Store {
    #[intercept]
    fn guard(call: Call<Self>) -> Outcome<Self> {
        if call.handler_name() == "erase" {
            call.refuse("read-only store")
        } else {
            let outcome = call.proceed();
            match outcome.response {
                .Some(.read(value)) => println(value),
                _ => {}
            }
            outcome
        }
    }
    receive fn read() -> string { "stored value" }
    receive fn erase() {}
}

The interceptor consumes its Call<Self> and returns the delivery’s Outcome<Self>. Its response is an Option<Self.Response>: refusal produces None, while a completed unit handler still has a response variant without a payload. Declared handler failures remain distinguishable from a handler that successfully returns an ordinary error-valued Result.

#[confined] addresses a different service concern: which outside operations a component can acquire. Supply the file, directory or application service it should use when constructing it. Its ordinary calls can then use that supplied service, while ambient acquisition is checked. A directory service can grant a subtree instead of passing a global filesystem path as authority. The confinement guide explains the construction model and its supported service boundaries.

For an existing input/orders directory, the launcher can narrow a read root before supplying it:

import std.fs;
 
#[confined]
actor OrderReader {
    let input: fs.ReadDir;
    receive fn read() -> Result<bytes, fs.IoError> {
        input.read("orders.csv")
    }
}
 
let root = fs.open_read_dir("input").expect("read root");
let orders = root.subdir("orders").expect("narrow to orders");
root.close();
let reader = spawn OrderReader { input: orders };
let data = reader.read().expect("actor reply").expect("read orders");
stop(reader);
stopped(reader);

The narrowed resource keeps its own open directory handle. Its relative paths stay beneath that directory, and it cannot write or acquire an unrelated path. Supplying a ReadFile or WriteFile narrows authority further to one opened file and one direction.

Describe a deeper protocol without flattening its states

Hew 0.6.1 extends machine composition with deeper nested states, named targets and computed targets. A transition to a composite state follows its declared initial child; a computed target can choose the next state from its input. reenter requests exit and entry behaviour even for an explicit transition back into the same state.

For a connection with a handshake and several handshake phases, those rules let shared connection hooks stay on the parent while each phase handles its own inputs. The 0.6.1 machine specification describes target selection and hook order.

Here are the state and rule excerpts from such a machine; its input declarations include Open, Retry and Check { valid: bool; }:

state Idle;
state Connection {
    initial state Handshake {
        initial state Waiting;
        state Verified;
    }
    state Live;
}
 
on Open: Idle => Connection;
on Retry: Verified => Handshake reenter;
on Check: Waiting => _ {
    if event.valid { Verified } else { state }
}

A named sibling transition keeps the shared parent entered. A computed _ target exits the active branch before running its body, then enters the chosen branch, even when the body chooses the same leaf.

Development preview: compose the same operations further

This section requires the development toolchain at a340281056. It describes work after the 0.6.1 release, for the 0.6.2 preview.

Select ordinary calls and observe machine changes

Development select can prepare ordinary calls, including mutable callable values and dynamic dispatch, alongside the existing task, stream and timer forms:

select {
    notice from subscription.recv() => show_notice(notice),
    after 1s => show_progress(),
};

Here subscription.recv() is an ordinary checked call. This is useful with the machine observation feed: a machine owner applies inputs, while subscribers receive ordered revisions and transition reports. Readers can follow state changes without owning the machine’s mutation interface. The machine-feed example shows subscription, cancellation and selection together.

Using the Meter machine from earlier, an owner and subscriber can be created together:

import std.machine.watch;
 
let (owner, feed) = watch.observe(
    Meter.Watching { peak: 0 }, capacity: 4, subscribers: 1
).expect("observe meter");
var live = owner;
let updates = feed.subscribe().expect("subscribe");
let _ = live.step(.Reading { value: 7 });
let notice = updates.recv().expect("feed readable").expect("revision");
println(notice.revision);

The same selection syntax works for a private mutable callback and a dynamic trait method. This excerpt reuses Named and Person from the trait example:

let count = 0;
var next = capture(var count) || -> i64 {
    sleep(1ms);
    count += 1;
    count
};
let number = select {
    value from next() => value,
    after 1s => 0,
};
 
let named: dyn (Named + Send + Sync) = Person { label: "ready" };
let name = select {
    value from named.name() => value,
    after 1s => "waiting",
};

For distributed services, peer.export and peer.receive_service exchange typed service grants. Receiving an actor’s address and receiving authority to call an exported service are separate operations. The development distribution specification covers the peer and grant lifecycle.

With authenticated peer sessions already supplied, one side can offer the earlier Store actor and the other can acquire its interface:

fn offer(peer: net.PeerSession, store: Store, done: Stream<i64>) {
    let export = peer.export(store).expect("offer admission");
    let _ = done.recv();
    export.close();
}
 
fn read_remote(peer: net.PeerSession) {
    let service = peer.receive_service<Store>().expect("receive store");
    println(service.read().expect("remote reply"));
    service.close();
}

The module imports std.net; peer connection and authentication setup are omitted here. Closing the export revokes future claims, while a handler that already claimed its request may finish. A grant survives an authenticated reconnect between the same node sessions; interrupted calls are not retried, and an actor or node restart invalidates the grant.

Extend indexing and stream framing through ordinary types

User-defined Index implementations and projected-field updates let container code support expressions such as items[index].count += 1 while keeping the earlier value’s snapshot. Strings use Unicode-scalar positions for indexing, lengths and ranges; text[index] produces a character, and a range produces a string. Use get for an optional result when a position may be outside the string. These positions count scalars, not the user-perceived characters that can contain several scalars.

For a small table with a named column, Index<string> supplies both optional and direct access:

type Table { value: i64; }
 
impl Index<string> for Table {
    type Output = i64;
    fn get(self, key: string) -> Option<i64> {
        if key == "value" { .Some(self.value) } else { .None }
    }
    fn at(self, key: string) -> i64 {
        self.get(key).expect("unknown column")
    }
}
 
let table = Table { value: 42 };
println(table["value"]);
var reports = [Summary { path: "first.txt", bytes: 4 }];
reports[0].bytes += 1;

Scalar indexing gives the same positions to single-character access, ranges and iteration:

let text = "Aé🐱";
assert(text.len() == 3);
assert(text[1] == 'é');
assert(text[1..3] == "é🐱");
for character in text.iter() { println(character); }

Generic sorting uses sort.sort<T: Ord> or sort.sort_by for an explicit comparator. The returned vector is independent of the input. Floating-point values need the comparator form when the program wants a total ordering, using math.total_cmp to include NaNs and signed zero deliberately. The development migration guide lists the replaced string and sorting names.

import std.sort;
import std.math;
 
let ordered = sort.sort([3, 1, 2]);
let backwards = sort.reverse(ordered);
let floats = sort.sort_by([3.14, 1.41, 2.72], |a, b| math.total_cmp(a, b) < 0);

A byte stream can also use an application-defined stream.Codec<T> to turn chunks into frames or values into encoded output:

let messages = input.frames(codec);
let output = byte_sink.framed(encoder);
let another_writer = output.clone();

This excerpt assumes a byte input stream, a byte sink and codecs implementing the trait. The framing adapters keep partial input between chunks, and cloned framed sinks share the output while keeping complete sends together. The stream codec example and socket example include the codec definitions and cleanup behaviour.

Let an actor own a scheduled request

A one-shot actor timer schedules a handler on self and returns a cancellation handle. Inside a named actor, the development form is:

let timer = mailbox(self, after: 5s, overdue: 500ms).refresh();

after is the minimum delay before submission. overdue is optional: it faults the owner with CrashKind.TimerOverdue if the handler has not started within that interval after the timer fires. Discarding the submission before it starts counts immediately.

Dropping timer leaves the actor-owned request scheduled. timer.cancel() before firing prevents delivery; after firing it disarms the overdue check without withdrawing an admitted message. Stopping or restarting the actor cancels its pending timers. A timer targets self, its handle cannot be sent to another actor, and a timer view refuses .Wait admission. The timer example includes cancellation, actor construction and payload cleanup.

Crash reporting uses typed values alongside these operations. CrashInfo.kind is a CrashKind, including HandlerFailed and TimerOverdue, and DownTarget distinguishes a local actor from a remote target with its full location. Code can match the public cause and target instead of depending on runtime integers.

For an actor whose supervisor permits restart, a crash hook can match the cause directly:

#[on(crash)]
fn crashed(info: CrashInfo) -> CrashAction {
    match info.kind {
        .TimerOverdue => println("scheduled work did not start"),
        .HandlerFailed => println("submitted handler returned an error"),
        _ => println(info.message),
    }
    CrashAction.Restart
}

Try one change at a time

Use the getting-started guide to install the matching SDK, then run the complete sizes.hew program with 0.6.0. Change one path to a missing file and handle that result locally; next, keep the two task handles and join them separately. Those small edits make the failure and concurrency choices visible before adding a service around them.

For an existing 0.5.6 project, start with the 0.6.0 migration guide, resolve package dependencies, run hew fmt --migrate --root ., and review the changes before checking and rebuilding with the matching compiler, runtime and standard library.

Hew Notes

Language development, engineering articles, releases and community projects. About once a month, with an extra issue for a significant release. Written in English.

Subscribe to Hew Notes