Skip to Content
i documentation
The language standardChapters

9. Effects

i has no special effect definitions. Effects are made from normal things: functions with no parameters (§9.1), services (§9.2), errors as the service Fail (§9.3), and the control operations of i/control (§9.4). There is no error channel, no implicit capability and no layer concept.

9.1 Computations

A function with no parameters, () <- N -> A, is a computation that has not been done yet. It is the same idea as an Effect<A, E, R> value in Effect-TS, which is () <- (..R, Fail(E)) -> A in i. It may be stored, given to other functions, and put together with other computations. Calling it does it. Retrying, time limits, and running at the same time are normal functions:

retry = <A, E, N>(times : Int, body : () <- (..N, Fail(E)) -> A) -> A {
	fail.catch(body) |>
		(Ok(value)) { value }
		| (Error(error) ? times > 1) { retry(times - 1, body) }
		| (Error(error)) { fail.raise(error) }
}

user = () { retry(3, () { load_user("1") }) }

9.1.1 What a need tracks

The need after <- tracks the services a computation takes from its caller (§9.2), including errors through Fail (§9.3) and assertion failures through Assert (§9.6). It does not describe every way a computation can change state or stop.

A need does not track these conditions:

  • Whether a continuation has already been resumed or cancelled: a second use stops the program (§9.4).
  • Whether the boundary of a captured Fail is still active: using it after its catch has ended stops the program (§9.3, §9.4).
  • Whether a task or channel handle still belongs to a live run or scope, or whether a request is ready and has not been cancelled or taken: invalid use is checked at run time and stops the program (§9.9, §9.11).
  • Whether a value is portable (§11.9): an operation that requires portability checks it at run time and stops the program if it is not portable (§9.9).

request.take consumes the answer in shared request state: every alias observes the change from Ready to Taken (§9.11). Its type is <A>(Request(A)) -> A, with no need. Thus the absence of a need does not promise that a call leaves shared state unchanged. These run-time failures are stops (§11.7), not errors added to Fail.

9.2 Services

A service is an ordinary value of a labelled type (§4.3), normally one whose data holds functions. Two services of one form are made by defining two types. A service is a normal value: it may be stored and given to functions, and code that has it may call its functions directly.

The need N of a function type (P) <- N -> R is the set of services that a call takes from the place where it is called. Every item of a need, of a service pattern and of provided data is keyed by the type constructor of its type: order does not count, and there are no names or labels. <- (Db, Log) is the same type as <- (Log, Db). Needs are shown in the UTF-8 byte order of the qualified names of their type constructors, then the row variable. Only after <- is anything keyed by type; ordinary data keeps positions and labels.

Clock = (now = () -> Int)

elapsed = (start : Int) <- (clock : Clock) { clock.now() - start }
// elapsed : (Int) <- (Clock) -> Int
fixed = () { elapsed(40) <- (Clock(now = () { 42 })) }
// fixed : () -> Int
  • Taking. The service pattern of a function head (§6.8) names the services that the function uses itself: x : T binds x to the service keyed by T. A need that a head does not write still passes down: a function that calls elapsed needs Clock too, and inference writes it into its type.
  • Calls. The services go with every call: a called function gets the services of the place of the call. Nothing marks such a call. So a function made in one place and called in another gets its services from the place where it is called.
  • Providing. e <- s works out e with the services of the place plus the data s (§6.9), for any expression e; s is worked out first, with the services of the place. For a key that s has, s wins; the other keys come from outside. f(x) <- (s) provides to one call, and { … } <- (s) to every statement and the result of a block. Provided data is a data literal whose items are expressions or spreads, with no labels, or any postfix expression whose type is a data type with positions only or a row type (§9.4). An explicit item wins over an item from a spread with the same key; two explicit items, or two spreads, with one key are a compile error. A provided item is keyed by its known type (§4.8), which must be one named service type: not a union, a type variable, or unknown. A provided key that the need does not have gives a warning.
  • Merging. The merge of two needs is their intersection (§4.4).
  • When the intersection for one service of a need is Never while neither side is Never, the need cannot be satisfied: it is a compile error (E0515).
  • A provided value of type P satisfies the need entry S when P <: S (§4.5 rule 4). An entry that every value of its type constructor satisfies, as Fail(Never), is removed.
  • Row variables. A type parameter can stand for a whole need: map : <A, B, N>(List(A), (A) <- N -> B) <- N -> List(B). In (..N, C(…)), N has no entry for C. A rigid row variable that reaches a provide of C without a stated C entry is a compile error.
  • Branches. All branches of a chain share one need (§6.12).
  • Type checking makes certain that every service is there when a call takes it. Getting it never fails at run time.

Await, Root and Owner (§9.11), Scheduler and Runner (§9.9) are normal service values. Replacing a host service in i is a provide, as for any service. Replacing a random seed with task.top keeps the run route of the current Runner.

A service value can be captured. Providing the service later changes calls that take it from their caller, not the captured value:

Clock = (now = () -> Int)
Reading = (read = () -> Int)
make = () <- (clock : Clock) { Reading(read = () { clock.now() }) }
now = () <- (clock : Clock) { clock.now() }
example = () {
	saved = make() <- (Clock(now = () { 1 }))
	(saved.read(), now()) <- (Clock(now = () { 2 }))
}
// example() gives (1, 2).

Layers. There is no layer concept. A function that returns several services returns ordinary data, which may be provided whole or spread:

base = () <- (io : Io) -> (Config, Log) {
	(Config(url = "https://example.com"), Log(write = (s) { io.print(s) }))
}
live = () -> (Config, Log, Db) {
	s = base()
	d = db_layer() <- s
	(..s, ..d)
}
main = () { app() <- live() }
check = () { app() <- (..live(), fake_db) }

Here live has the type () <- (Io) -> (Config, Log, Db), and in check the explicit fake_db wins over the Db of the spread. The order in which services are made is written by hand.

9.3 Errors

An error is raised through the prelude service Fail(E):

Fail(E) = (raise = (E) -> Never)

An error may be a value of any type. Needs merge by direction (§9.2), so a function that calls functions needing Fail(A) and Fail(B) needs Fail(A | B). The module i/fail has these functions, written in i over Fail and control.escape (§9.4):

FunctionTypeWhat it does
fail.raise<E>(E) <- (Fail(E)) -> NeverEnds the computation with the error
fail.catch<A, E, N>(() <- (..N, Fail(E)) -> A) <- N -> Result(A, E)Changes every error of the body into Error(…)
fail.from_result<A, E>(Result(A, E)) <- (Fail(E)) -> ARaises x for Error(x)
fail.map_error<A, E, E2, N>(() <- (..N, Fail(E)) -> A, (E) <- N -> E2) <- (..N, Fail(E2)) -> AChanges the errors
raise = <E>(error : E) <- (handler : Fail(E)) -> Never { handler.raise(error) }

catch = <A, E, N>(body : () <- (..N, Fail(E)) -> A) -> Result(A, E) {
	control.boundary((b) {
		Ok(body() <- (Fail(raise = (e) { control.escape(b, Error(e)) })))
	})
}

Because N has no entry for Fail, catch takes every error of its body.

load = (id) { parse_user(http_get(id)) }
// load : (String) <- (Fail(HttpError | ParseError), Http) -> User
safe = (id) {
	fail.catch(() { load(id) }) |>
		(Ok(user)) { user }
		| (Error(_ : ParseError)) { default_user }
		| (Error(other)) {
			fail.raise(
				other, // other: HttpError
			)
		}
}
// safe : (String) <- (Fail(HttpError), Http) -> User
  • To handle only some errors, take all of them with fail.catch, then match with type tests. A later catch-all gets the rest of the union (§4.8 rule 12), which may be raised again, as in safe above.
  • An error goes out through the calls to the catch that provided its Fail. The computation that is jumped over is unwound first (§9.5).
  • A Fail value that is used after the catch that made it has ended, because it was returned, stored, or captured by a function that runs later, stops the program with the message boundary not active (§9.4). The compiler warns when a Fail bound in a service pattern is captured by a function value or put into data.
  • Assert is a separate service (§9.6), so catch does not take assertion failures.
  • At an entry, the platform provides a top-level Fail (§10.2): an error that reaches it from the program entry or a test stops the program or fails the test, and one that reaches it from a host export is given to the host.

9.4 Control

The module i/control pauses a computation and goes on with it later:

FunctionType
control.boundary<R, N>((Boundary(R, N)) <- N -> R) <- N -> R
control.suspend<A, R, N>(Boundary(R, N), (Continuation(A, R, N)) <- N -> R) -> A
control.resume<A, R, N>(Continuation(A, R, N), A) <- N -> R
control.cancel<A, R, N>(Continuation(A, R, N)) <- N -> Nil
control.escape<R, N>(Boundary(R, N), R) -> Never

Boundary and Continuation are built-in types. Their values are made only by control.boundary and control.suspend, and the host cannot take them (§10.3).

  1. boundary(body) makes a boundary b and does body(b). The value of body is the value of boundary. b is active while the computation between it and the running code is on the stack: from the start of body until body ends, but not while that part is paused in a continuation. After resume, it is active again.
  2. suspend(b, f) pauses the computation from this point back to the boundary b. That paused part is a continuation k. Then f(k) is done in the place of the boundary, with the services of that place, and its value is the value of boundary. If b is not active, the program stops.
  3. resume(k, v) goes on with the paused computation, with v as the value of suspend. It goes on on top of the place where resume is called, and the value that the computation has when it gets to the boundary is the value of resume. The paused part keeps the services provided inside it; for the rest, it gets the services of the place where resume is called. So an error raised after resume goes to the Fail of the place of resume, unless the paused part provided its own. The services that a function made by keep keeps (below) count, for the code inside that function, like services provided inside the paused part.
  4. cancel(k) unwinds the paused computation (§9.5) and gives Nil. While it unwinds, the boundary stays not active, and the final functions get the services as in rule 3, from the place where cancel is called; inside a function made by keep, they get the kept services.
  5. escape(b, v) ends the computation back to the boundary b at once, unwinding it (§9.5), and v is the value of boundary. It is suspend(b, (k) { cancel(k) v }) with no continuation made. If b is not active, the program stops with the message boundary not active.
  6. One use only: resume and cancel together may be used one time for a continuation. A second use stops the program.
  7. If a continuation is never resumed or cancelled, its final functions are not run.
  8. A boundary and a continuation stay on the worker that made them (§11.9). They are not portable.

A structured task cancel resumes its scheduling continuation with a cancel answer; i code then cancels an inner continuation (§9.9). It does not change rule 4’s rule that the boundary of the cancelled continuation is not active during unwinding.

Keeping services. In a service pattern, ..env : N binds the row value defined by §6.8. A value of a row type may be bound, captured and provided after <-, alone or as a spread in provided data; it may not be spread into ordinary data, compared, printed or matched. keep captures exactly the services in f’s inferred residual need, after removing Fail. It does not capture other services merely because they are available. Wherever the kept function is called, it calls f with those captured services and the caller’s Fail. This is also the capture rule for task.keep and task.go, with the scheduler substitutions of §9.9:

keep = <S, A, E, N>(f : (S) <- (..N, Fail(E)) -> A) <- (..env : N) -> (S) <- (
	Fail(E),
) -> A { (x) { f(x) <- env } }

An error raised inside the kept function goes to the catch of its caller.

Generators, async code, and schedulers are libraries made from services and these operations. For example, a generator:

Yield(A) = (emit = (A) -> Nil)

collect = <A, N>(body : () <- (..N, Yield(A)) -> Nil) -> List(A) {
	control.boundary((b) {
		body() <- (
			Yield(
				emit = (x) {
					control.suspend(b, (k) { [x, ..control.resume(k, Nil)] })
				},
			),
		)
		[]
	})
}

9.5 Unwinding and final functions

A computation that is dropped is first unwound: its final functions are run, from the innermost to the outermost. This takes place when an error or an escape goes through it (§9.3, §9.4), and when its continuation is cancelled (§9.4).

A final registration is removed before its callback starts, on both normal exit and unwinding. It is invoked at most once. If that invocation suspends and raw continuation cancellation discards its remainder, only other installed finals are run; the removed registration is not invoked again. Structured task cancellation shields an already-running final as §9.9 R4 specifies. That shielding is a task rule, not a change to raw control.cancel.

The module i/resource puts final functions in place. Scope is a service of i/resource; the platform gives every entry one (§10.2).

FunctionTypeWhat it does
resource.ensure<A, N>(() <- N -> A, () <- N -> Nil) <- N -> ARuns the second function after the first, whether the first ends normally, ends with an error, or is unwound
resource.acquire<A, N>(() <- N -> A, (A) -> Nil) <- (..N, Scope) -> AOpens a resource and attaches its release to the current Scope
resource.scoped<A, N>(() <- (..N, Scope) -> A) <- N -> ARuns the body with a new Scope, and runs the attached releases in reverse order when the body ends, also by an error, an escape or a cancel

A Scope starts open. acquire checks that state before calling open; acquisition into a closing or closed scope stops with panic("resource: scope is not open") without calling open. A successful open registers its release before returning the resource. When the scoped body ends, the scope becomes closing before any release runs. It waits for acquisitions already admitted while open to register their releases, then drains releases in reverse registration order. A release cannot acquire into that closing scope; it can open a fresh scope. After draining, the scope is closed and stays closed even when its service value was captured or returned. The entry’s root resource scope closes after its task run and all structured children and task finals end, before the entry returns to its host (§10.2).

A release that needs services is made with keep (§9.4). A final function may not raise errors. When the program stops (§11.7), final functions are not run.

A structured task group waits for cancelled children and their final functions before it returns (§9.9). Those final functions may suspend and resume and use services, but may not raise errors. An unstarted child has no installed final functions. A host failure or a destroyed instance cannot run i final functions; the host instead releases its own work and resources (Appendix B).

9.6 Assert

Assert and AssertionFailure are prelude types:

AssertionFailure = (message = String)
Assert = (fail = (AssertionFailure) -> Never)

An Assert service is where failures are reported. Its fail never gives back a value. The built-in function assert is a normal function, which may be written in i:

assert = (c : Bool) <- (a : Assert) -> Nil {
	c |>
		(True) { Nil }
		| (False) { a.fail(AssertionFailure(message = "assert")) }
}
// assert : (Bool) <- (Assert) -> Nil

A binding whose pattern may fail also reports to Assert (§6.10).

At an entry point (§10.2), the platform gives an Assert whose fail stops the program; in a test, the test fails. Any code may provide its own Assert. The module i/assertion has one function that does this:

catch = <A, N>(body : () <- (..N, Assert) -> A) -> Result(A, AssertionFailure) {
	control.boundary((b) {
		Ok(
			body() <- (
				Assert(fail = (failure) { control.escape(b, Error(failure)) }),
			),
		)
	})
}

Assert is for mistakes in the program. A failure that may be expected uses an error (§9.3). fail.catch and fail.map_error do not take Assert failures.

9.7 Host services

A type definition with the attribute @host("namespace") is a host service:

@host("app:storage/v1", "async")
Storage = (read = (String) <- (Fail(String)) -> String)
  • It is a labelled type whose data has only labels, each with a function type. The parameter data of these functions has positions only. Their parameters and values must be types that the host can take (§10.3). A function type there may have no need, or exactly <- (Fail(E)) with an E that the host can take: the host’s error is raised through the caller’s Fail. Two host services may not have the same namespace and a field with the same label.
  • With @host("namespace", "async"), the host does the functions later: the program waits at the call, and goes on from the call when the host gives a value or an error. In i code, such a call is a normal call.
  • A host service that the platform provides at an entry (§10.2) is given by the host. Every field is the Wasm import with the import module namespace and the field label as its name, encoded as in Appendix B. i code may also provide a host service, for example to put a fake in its place in a test; then no import is used for it.
  • The library module i/io declares and exports the standard service @host("i:io/v1") Io = (print = (String) -> Nil). print writes the text and a line feed to the standard output, and gives Nil, also after an output error. A program that prints imports it, for example ((Io) = i/io). A host service declared in another module with the namespace i:io/v1 is a normal host service, and the rule above about namespaces and labels is true for it as for any other: with a field print, it is a compile error.

Targets and platform services.

The module i/target exports these types; the modules i/js and i/wasi export the roots named in them (§10.1, §B.7.1):

Js = (js = js.Js)
NodeJS = (js = js.Js)
Wasi = (wasi = wasi.Wasi)
Native = ()
Host = ()
Target = (Js | NodeJS | Wasi | Native | Host)

Target is a service that the platform provides at every entry, holding the target the program was built for. Only §10.1.2’s certified entry binding is known to hold the build’s target. A program may provide another Target.

At an entry the platform of the target provides its services: Assert, Scope, Fail, Root, Owner, Await, Runner, Scheduler, Target, and the host services of this table. No library and no package chooses services for the program: every other service is provided in the program’s code, normally in main. A program built for targets that need different services has one entry for each such target.

TargetHost services of the platform
jsIo, Clock, Entropy
nodejsIo, Clock, Entropy, Arguments, Environment, WorkingDirectory, Fs, Net, Process, Stdio
wasiIo, Clock, Entropy, Arguments, Environment, Fs over preopened directories, Stdio
nativeIo, Clock, Entropy, Arguments, Environment, WorkingDirectory, Fs, Net, Tls, Process, Stdio
hostThe services that the embedder registers

The JS loader checks the required feature profile before starting: WasmGC, tail calls, exception handling, console and clocks/timers, and secure entropy. There is no insecure random fallback. A target never installs a service that only reports unsupported. Resource services use the same granted factories in generated Node builds and the SDK; an arbitrary Js value cannot manufacture authority over their private resource numbers.

The process services are split by capability, keeping their operation arguments, results, ProcessError values and cancellation rules. Each public wrapper needs only the service in its row, plus Await and Owner when it waits. Clock, Fs, Net, Stdio and Io are separate:

ServiceNamespaceRaw fields
Entropyi:entropy/v1entropy
Argumentsi:arguments/v1args
Environmenti:environment/v1env
WorkingDirectoryi:working-directory/v1cwd
Processi:process/v1spawn, wait, terminate, close

The first four have public constructors for ordinary fakes. Process keeps private raw fields and a private labelled Child type over Int. The same private Int representation is used for other IO resources. Neither these types nor their errors add a boundary type (§10.3).

i/net includes DNS: connect accepts a host name or numeric address, and resolve(name) returns addresses. The native host uses the system resolver and tries addresses in order, without IPv4/IPv6 racing. TLS belongs to i/tls. UDP datagrams are part of i/net: bind(address, port) returns a private labelled Datagram over Int, send_to(socket, address, port, bytes) sends one datagram, and receive_from(socket, max_bytes) returns (data = BitArray, address = String, port = Int) with the numeric sender address and port. local_address(socket) and local_port(socket) return the bound numeric address and port; close_datagram(socket) closes it. Bind accepts IPv4, IPv6 and host names; port 0 asks the OS to choose a port. Send accepts a numeric address or host name and port 1..65535; addresses are resolved and tried in system order as for TCP. Receive requires a positive bound, capped at 65536 bytes; it consumes exactly one datagram and discards any excess bytes. An empty datagram is data, not EOF. Sends require whole bytes and are atomic; expected host failures raise NetError values through Fail. UDP provides no delivery or ordering guarantee.

Datagrams follow TCP’s resource identity, owner, creation guard, teardown and lease rules. One send and one receive may overlap; a second operation in the same direction gives busy. Close is idempotent and raises no error, and wakes a blocked receive with closed unless its answer already committed. Group cancellation cancels an owned blocked receive and releases its lease, leaving the socket usable until close or entry teardown. The browser and WASI platforms have no Net, so a program that needs i/net.Net there is a compile error naming it (§10.2).

9.8 Keys

Key(A) and Keyed are built-in types. They are not labelled types: their values are made only by key.new and key.wrap. A key is the same only as itself (§11.4). Two Keyed values are equal when their keys are the same and their values are equal. Key(A) does not change (§4.5 rule 7). A Keyed holds its value (§11.9).

FunctionTypeWhat it does
new<A>() -> Key(A)A new key, the same only as itself
wrap<A>(Key(A), A) -> KeyedThe value, marked with the key
unwrap<A>(Key(A), Keyed) -> Lookup(A)Found(v) when x was made with k from v, and Missing when not

Found(A) = (A), Missing = () and Lookup(A) = Found(A) | Missing are types of i/key, not built-in names.

9.9 Tasks

i/task is a library implemented using i/control, i/key and i/request. A run owns its tasks and scheduler state. Its implicit root permits local values. Scopes constrain the lifetime of spawned work: none outlives its scope. Values pass between tasks only through go captures/kept services, channels, child results/errors and shares. Portable paths check core.portable even with one worker: sends at the call, send events at construction, children at completion, scope body errors at failure, shares at creation. Failure stops with panic(“task: value is not portable”). go never rejects for non-portability; on multiple workers it silently classifies the complete task record at go and publishes portable records. Other records stay local. Local scopes/channels hold the maker’s boundary only as a non-portability witness. A Task/join event from a local scope holds that scope too. Every stateful handle checks run identity; events have fresh keys and sync checks only their stateful leaves, including losing alternatives. Channels belong to the first scope in their maker’s chain, or the run; channels and shares are freed at scope exit. Every run has the result of some serial order of task steps. Timing may choose a sender, a race/select winner, which failures precede cancellation, or whether a handle is used before its owner ends. These order-dependent results may change between runs or worker counts. Tests must either avoid that dependence or list the allowed outcomes. No task API observes workers.

Export types Scheduler, Runner, Group(E), Task(A,E), Channel(A), Event(A,E,N), Shared(A), Ref(A), and ScheduleError; export only ScheduleError’s constructors. The other constructors and scheduler protocol are private ordinary labelled types, not opaque types. Group, Task, Channel, Shared, and Ref contain invariant keys; their parameters are therefore invariant. Event’s answer/error/need parameters are covariant. In Event(A, E, N), E is the type of the errors that syncing raises through Fail and N is a row variable for the other services it needs; Never in that place means no service: its private representation keeps typed decoding functions, rather than Key(A) in an output field. A public Event can hold user mapping functions and the local handles of its leaves. Such contents affect portability by the normal walk. The library creates no event that captures its Scheduler or mutable loop state. A join event keeps its task’s scope handle.

Each type below writes its type parameters before its parameter part. N is the row of the services that a body needs besides those that the function provides to it.

BindingType
top(random.Generator) <- (Runner) -> Runner
run<A, N>(() <- (..N, Scheduler, Runner, request.Await, request.Owner, request.Root) -> A) <- (..N, Runner) -> A
test<A, E, N>(random.Generator, List(List(Int)), () <- (..N, Fail(E), Scheduler, Runner, request.Await, request.Owner, request.Root) -> A) <- (..N, Fail(E | ScheduleError), Runner) -> A
scope<A, E, N>((Group(E)) <- (..N, Fail(E)) -> A) <- (..N, Fail(E), Scheduler) -> A
local_scopeThe type of scope
go<A, E, N>(Group(E), () <- (..N, Fail(E), Scheduler, Runner, request.Await) -> A) <- (..N, Scheduler) -> Task(A, E)
join<A, E>(Task(A, E)) <- (Fail(E), Scheduler) -> A
yield() <- (Scheduler) -> Nil
path() <- (Scheduler) -> List(Int)
random() <- (Scheduler) -> random.Generator
keep<S, A, E, N>((S) <- (..N, Fail(E), Scheduler, Runner, request.Await) -> A) <- (..N, Scheduler) -> (S) <- (Fail(E), Scheduler) -> A
channel<A>() <- (Scheduler) -> Channel(A)
local_channel<A>() <- (Scheduler) -> Channel(A)
send<A>(Channel(A), A) <- (Scheduler) -> Nil
receive<A>(Channel(A)) <- (Scheduler) -> A
send_event<A>(Channel(A), A) -> Event(Nil, Never, Never)
receive_event<A>(Channel(A)) -> Event(A, Never, Never)
join_event<A, E>(Task(A, E)) -> Event(A, E, Never)
always<A>(A) -> Event(A, Never, Never)
never() -> Event(Never, Never, Never)
choose<A, E, N>(List(Event(A, E, N))) -> Event(A, E, N)
map<A, B, E, F, N, M>(Event(A, E, N), (A) <- (..M, Fail(F)) -> B) -> Event(B, E | F, N | M)
sync<A, E, N>(Event(A, E, N)) <- (..N, Fail(E), Scheduler) -> A
share<A, E>(Group(E), A) <- (Scheduler) -> Shared(A)
get<A>(Shared(A)) <- (Scheduler) -> A
ref<A>(A) <- (Scheduler) -> Ref(A)
update<A, B>(Ref(A), (A) -> (A, B)) <- (Scheduler) -> B
both<A, B, E, N>(() <- (..N, Fail(E), Scheduler, Runner, request.Await) -> A, () <- (..N, Fail(E), Scheduler, Runner, request.Await) -> B) <- (..N, Fail(E), Scheduler) -> (A, B)
race<A, E, N>(() <- (..N, Fail(E), Scheduler, Runner, request.Await) -> A, () <- (..N, Fail(E), Scheduler, Runner, request.Await) -> A) <- (..N, Fail(E), Scheduler) -> A
map_concurrently<A, B, E, N>(List(A), (A) <- (..N, Fail(E), Scheduler, Runner, request.Await) -> B) <- (..N, Fail(E), Scheduler) -> List(B)

In tables, \| is only Markdown escaping; the i operator is |. The schemes for combinators use one common E and N; subtyping admits either child’s error and needs. No union of invariant Task(Int,E) and Task(String,E) is required: both keeps two separate handles. No local variants of these three combinators are exported in v1. Local callers write a local_scope explicitly.

go, keep and the combinators provide Scheduler, Runner and Await to their bodies themselves, so their callers need only the rest of the bodies’ needs and Scheduler. They capture the residual need by §9.4. The captured record substitutes portable detached placeholders for these three services; the running task supplies its own values when it calls the body. Owner and Root are not provided there. run and test provide a fresh Root and a default Owner; test still needs the calling Runner for its nested path and wait route. Host fields add fixed service types to needs, never a constraint that depends on a type. Keys are invariant and obey the value restriction; Request and Event add no qualified types.

No public spawn detaches a task. go never checks its closure’s portability. It increments the calling task’s spawn/run counter, beginning at 1; the child path is the parent’s path with that index appended. A nested run takes an index from that same counter. path and random are data reads without a suspend. A task gets its random generator by the Runner rule below; reading it does not draw or split it.

A scope waits for all children even when its body returns without joining them. Returning a Task handle does not extend the right to join it. Handles check run identity before other checks, then chain membership or liveness. share checks membership as go does (same outside-scope stop), including local scopes; values shared through either scope kind must be portable. get checks run then share liveness. Handle equality means the same object of the same run, never equal numeric ids in two runs. Text uses constructors without hidden ids: a private identity-bearing key is printed as //key, and implementation ids/paths are kept under Keyed fields whose text is //keyed, not exposed as plain fields. path() alone exposes the semantic task path.

Scopes, errors, and cancellation

R1. A task’s chain is the scope bodies in its running continuation, inner first, followed by its starting scope’s chain. A scope records itself plus the opener’s chain when it opens; a child starts with that recorded chain.

The scope-body part of a chain travels with a continuation (§9.4). Suspension removes the captured scope bodies from the running chain. Resumption restores them ahead of the resuming task’s chain for as long as that continuation runs. This also holds when another task in the same run resumes it. Cancellation restores them while unwinding, then removes them as their bodies end. Run identities never change. An abandoned continuation runs no finals (§9.4 rule 7); its scopes remain unfinished, but are not in the running task’s chain. A run still waits for those scopes, and stops with task: deadlock if no task or request can make progress.

go and join require the relevant scope in the running chain. Run-key mismatch stops with task: handle of another run; failed membership stops with task: go outside its scope or task: join outside its scope. These messages are passed to panic, so the stop prefix is panic: .

R2. A scope body executes in the opener task under an inner scope boundary; it does not start another scheduler. A child executes from the run loop under its own task boundary. On body/child failure, mark the scope cancelling, cancel its body and every child, and propagate down their active scope trees. Wait for all unwind operations, including scopes created by finals. Choose the least path among actual failures; a prefix sorts before its extensions and the scope body has the opener’s path. A cancellation is not a failure. Types accumulate ordinary error unions; stops cannot be caught. A go accepted while cancelling creates an unstarted, immediately dropped child and its ordinary Task(A,E) handle. A shielded final receives that handle; its body never runs. If that scope’s cancellation can unwind the caller, the command delivers cancellation instead of returning the handle. This priority also applies at a valid join. A cancelling scope never starts new user work just to manufacture a join value.

R3. On a normal scope exit, join every live child before returning its body value. Before either successful or failing return, free the scope’s channels, shares, completed-task table entries, and registration records. The owning Group/Task handle can survive but does not retain its result/state after exit. Reads needing dead state stop by the defined checks. Channel ownership is the first scope in the maker’s chain, or the run. A channel wait still present when that owner ends stops with task: channel is gone, including a waiter in an outer scope; it is not silently dropped. share get after exit stops with task: shared value is gone. Scope ends are scheduling/commit steps.

R4. A paused or runnable started task receives Cancel at the next switch after cancellation reaches it. A running task sees it at its next scheduler suspend. An unstarted task is dropped without calling its body. The Scheduler command which receives Cancel suspends to the innermost cancellation delimiter being cancelled; that handler calls control.cancel only on that inner continuation. The outer task boundary stays active. Finals use the task’s saved services; all awaits and sends they perform go to that still-active task boundary. An unwind flag excludes that task from a second cancel, including cancellation of scopes it creates while unwinding. Such new scopes still obey their own internal failure/normal-exit rules. The outer cancel waits for them to end. A final that waits forever can keep cancellation waiting forever.

R4a. Keep outcome and cleanup completion separate. A task outcome is pending, success, failure or cancelled; drop sets cancelled immediately, and a started task sets cancelled when its home scheduler accepts cancellation, before running its unwind. A mere cancellation request not yet accepted is not a cancelled outcome: that task can still finish first. Normal success/failure is recorded after its final functions end. Scope exit still waits for cleanup completion, independently of the outcome. Keep dropped/cancelled records until scope exit. A cancelled outcome retains its causing scope and cancellation epoch, so a join can apply that cause even before its caller receives a cancel message. Do not use only a worker-local “cancel delivered” flag to decide whether to stop. join’s type remains <A, E>(Task(A, E)) <- (Fail(E), Scheduler) -> A. After the run/chain checks, process any applicable scope cancellation before observing a target’s cancellation outcome. A joiner still cancellable by that cancellation is unwound; it never races between this path and a join panic. This includes a final executing a fresh scope body that the fresh scope can cancel: unwind that inner body, not the already-shielded outer final. A cleanup scope and its children are exempt from the older cancellation as R4 requires.

A joiner shielded from the target scope’s cancellation cannot obtain A or E from a dropped/cancelled child. It stops immediately with panic("task: join of a cancelled task") when that outcome is known; it does not wait for the cancelled target’s final functions. If it joined while the outcome was still pending, accepting cancellation wakes that join immediately. This also avoids cycles between shielded finals joining one another. The stop ends the run and, as with any stop, may abandon remaining i finals. In a valid structured exit without that stop, scopes still wait for all cleanup to finish. An already recorded success or failure gives ordinary A or E. No Cancelled error value, aggregate error, or changed join signature is introduced. A join can wait for genuinely live work, but never for an already cancelled outcome.

A join_event has the same rule when selected/committed. A known cancelled outcome makes that alternative ready, even if cleanup is not complete; it is not a prevalidation fault of a losing alternative. A cancellable syncing task processes cancellation first. A shielded syncing task stops only if that join alternative wins; losing map functions still do not execute. This preserves CML selection and prevents an invalid stop from racing cancellation of an ordinary joiner.

R5. A race commits the first successful child completion observed by its scope. Commit atomically records the winner and initiates the ordinary scope cancel of the loser and body wait. Return only after their finals end. Failure before a success commits invokes R2; a failure already observed wins over a later success. Once success has committed, cancellation suppresses later unobserved errors. Thus race is first completion unless that completion was a failure; errors use the same scope rules as other combinators. It is not “first successful result despite failures”. Both/map_concurrently give argument/input order and R2’s errors. All three use portable scopes and check child results and errors before recording a completion, including race losers that finish before cancellation reaches them.

R6. The runtime entry wrapper and every explicit run own a local root task, which they run to completion with structured children. An entry error is reported only after the run has unwound; a host export receives its ordinary error. A stop immediately traps and runs no finals, as §9.5 requires. A host trap, failure or drop also invalidates the instance (§B.5); it cannot promise to resume i cleanup. “Cancellation runs finals” refers to i structured cancellation, not destruction of a trapped instance by its host.

i values and bindings are immutable; task.ref is the only state construct, owned by its scope, changed only by task.update with a pure function.

A Ref(A) is an immutable handle to scheduler-owned state. ref(value) creates one in the first scope of the maker’s chain, or in the run at its root. update(ref, change) atomically replaces its value with the first part of change(old) and returns the second part. The callback is pure, with no typed errors or services. Stored values must be portable. Check the run identity and ref liveness before calling the callback. Owner exit frees its state; later updates stop with task: shared value is gone. Refs use the same scheduler arbitration as channels, and do not introduce host state or mutable i values.

Events, FIFO behavior, and fairness

Channels are unbuffered rendezvous channels in v1. No close-channel operation is needed: owner-scope exit frees their state. An event is a reusable immutable description. choose([]) behaves as never(); sync(always(x)) gives x. New event constructions have fresh identity keys, independent of A/E/N, so equality means the same event handle rather than a comparison of event meanings. map/choose perform no Scheduler operation. send_event checks portable payloads immediately; receive_event and join_event do not test liveness until sync. A send_event can also be built without Scheduler; its handle records whether it is local.

One sync creates one registration identity with ordered alternatives. Validate run identities, join chains and channel liveness of all leaves before registering any. An invalid losing alternative is still an invalid sync. Register without running map callbacks. Among ready alternatives use source order; among partner registrations use FIFO arrival order. Distinct leaves from the same registration must never rendezvous with one another. A registration may commit once. Commit atomically marks both partner registrations, removes all their losing leaves, and transfers one value. Keep already-runnable tasks in their order. Append the older blocked partner to the runnable queue first, then append the committing task at the tail. Neither runs in the middle of the commit. Apply only the winning branch’s map functions, in composition order, in the syncing task after commit. A mapper can raise, await, spawn, or stop; it cannot roll back the message. No guard or nack operation is exported. Cancelling a sync unregisters all leaves before unwinding.

A turn lasts until the running task blocks, yields, or reaches an operation that ends its turn in the table below. A script choice is consumed only when the scheduler chooses a runnable task for a new turn, including the first turn. Cancellation bookkeeping and host polling do not consume choices.

OperationTurn behavior
path, randomData reads; no cancellation check, poll, or turn end
top, keep, event construction (always, never, choose, map, send_event, receive_event, join_event)No scheduler command or turn end
go, channel, local_channel, share, get, ref, update, scope entryCheck cancellation and perform the command in the current turn; poll only if a task needs host progress; no turn end
yieldEnd the turn and queue the caller at the tail
sync, join, send, receiveEnd the turn even when ready immediately; if blocked, remove the task until ready
Scope exitEnd the turn; wait for children and cleanup as required by R3
Async await through the task’s AwaitEnd the turn even when the request is already terminal
run, testRun the nested scheduler; the outer task ends its turn only when the nested Runner waits or polls
scope, local_scope, both, race, map_concurrentlyTheir constituent operations have the behavior above; there is no additional turn end at a wrapper call

go appends the child to the FIFO queue before returning in the same turn. A completion queues waiters FIFO. Cancellation bookkeeping runs before another normal task turn; a task already unwinding is queued normally. Each loop switch is a poll point, subject to the progress-interest rule below.

At every switch, first run cancellation bookkeeping and scan terminal ticket waiters. Build the progress-interest set from tasks actually suspended in Await.wait on Waiting tickets, including interests forwarded by nested runs. Root-level Runner.poll calls request.poll once only when this set is nonempty. Otherwise skip it, including before the first task turn. Pure go/yield/join/ scope operations and merely starting an unawaited raw request do not by themselves return status 2. A task/run which never blocks, and makes no explicit root wait/poll call, therefore never yields to the host. A pure Scheduler-only entry can return async status 0 immediately. A runnable pure task can of course share its run with a sibling needing host progress: that sibling’s wait causes the run’s hand-off. This is service of an existing wait, not an implicit yield added to the pure task.

While there is progress interest and a task is runnable, poll once before its next turn, using status 2. Scan settled tickets, then run a task even if further host answers might be ready. This keeps an infinite yielding task from starving an IO-waiting sibling, without making pure task switches pay an event-loop yield. Unawaited raw work alone does not force progress hand-offs during a runnable loop; its caller must eventually wait/poll explicitly or let the run become idle. Idle accounting still counts it as specified next.

A nested loop’s private Runner.poll passes its current interest tickets to the outer Scheduler with its nested-run id, then yields only its calling outer task. The outer loop keeps that interest registration while the nested run lives and forwards it in turn if nested again. Thus an inner IO waiter alongside an inner CPU-yielding task can receive host answers even when the outer caller is runnable. Terminal tickets are filtered; each poll replaces that run’s prior interests, and an error-free final removes the registration on every nested-run exit. Cancellation removes a nested registration before completing the calling task’s unwind; borrowed tickets themselves are not cancelled by unregistering interest. No new ABI operation is needed: these are private ordinary Scheduler commands. The root Runner’s poll takes the aggregated tickets; it never scans all instance requests to invent a wait. Explicit low-level request.poll still intentionally hands off, and stops request: no active entry from a synchronous entry.

When no task is runnable, scan terminal tickets first. The logical waitable set is the union of outstanding requests in every group of the run’s stored program Root and Waiting tickets its tasks explicitly await, including borrowed tickets from another root on the same worker. Group-observer tickets contribute their underlying outstanding members, not the observer cell itself (§9.11). Deduplicate identities, including an owned request also explicitly awaited. This is a set of work, not a List(Ticket) that can be exported across workers: observe owned groups through their counts and revision notifications (§9.11); borrowed Ticket values stay local. If this union is nonempty, Runner.wait waits for a member to settle and then rescans. An unawaited raw request in any custom group prevents deadlock until it settles. Its detached Ready answer is retained only by its handle. If the set is empty and no task or completion delivery can run, stop with panic(“task: deadlock”), even if an unrelated root has work. A nested run therefore includes an explicitly borrowed outer request, but not unrelated outer work.

A dispatcher may own several ordinary Roots with separate Owners. It chooses which root’s work it drives; no Root or request has a magic module-based class. The scheduler driver keeps a separate control Root/Owner for its wake requests, scoped to the run’s driver lifetime and closed on every driver exit. It does not bind that Owner for user tasks or expose its wake tickets. The stored program Root plus explicit borrowed waits above alone justify program progress; the control root does not. This same separation serves a batch dispatcher, a timeout manager and a plugin notification loop. Both kinds still use the same Root/Owner registration, count, cancel and teardown rules. The distinction is which roots the ordinary driver selects, not a host import name or a compiler exception.

Quiescence snapshots count runnable/claimed-but-unstarted/running tasks and pending completion-to-task or group-notification processing as work. Requests stay counted until their answer/cancel transition publishes that work atomically; there is no gap where both counts are zero. With no such work, zero members of the program waitable set means deadlock, whatever the control count. MC takes that snapshot and records the stop under the shared scheduler lock; a wake-only control delivery does not count as program work. At the ABI level every actual outstanding request, including a wake, still satisfies status 1’s requirement. Never return status 1 with zero actual outstanding requests or busy-poll with no runnable task. A one-worker run needs no wake request. CPU code without suspension can monopolize M:1; a go-only loop may postpone children. No bounded wall-time guarantee is made for final functions.

Runner, nested runs, and testing

The runtime supplies an initial portable Runner: prefix [], seed random.from_seed(0), FIFO policy, the enclosing request-context Root, and constant functions implementing root wait/poll. For each task, task.random() is random.at_path(run_seed, full_task_path). Here run_seed is the complete generator in the Runner when the run begins, including any prior draws or splits, and full_task_path is the value of task.path(), including every enclosing run prefix. Children use this same equation, rather than deriving from their parent’s task generator. Repeated reads give equal generators. A nested run uses its supplied Runner’s seed and its allocated full prefix; top(seed) changes the seed of subsequent runs using that Runner, not the generator of the task already running. run uses that Root while opening its fresh run Root, so its public environment still needs Runner rather than an extra Root argument. top(seed) replaces only the seed in the current Runner and preserves its path allocator and wait route. This is why its scheme needs Runner: rebinding a seed inside a task must not reset its nested-run prefix or make its nested wait bypass the outer loop. Entry provisioning uses the private ordinary root-runner constructor, not top. The ledger supplies this initial service, so a fixed-seed test can write ! task.Runner = task.top(random.from_seed(7)) without a bootstrap problem. In each task its Runner routes wait/poll to the outer Scheduler and takes paths from that task’s shared spawn/run counter. That per-task Runner is non-portable; go’s detached substitution above prevents accidental retention in children. Explicit run inside the entry’s default root task is nested. Each run opens a fresh request.Root and its default Owner around its entire loop, in a retained frame. Tasks keep Root and their go-site Owner. Custom within groups remain registered to that root. The run closes the entire root after all tasks/finals end, not just its default group. Only the runtime-created entry run is eligible for future multicore execution; v1 has one worker throughout.

A nested loop owns fresh run identity and FIFO state on the caller task’s stack. Nested children do not execute an outer task’s channel commands: all handle uses check their run key. Its wait hands tickets to the outer task’s Scheduler; its poll yields that calling task without directly yielding to the host. Only that task is paused; other outer tasks continue. When nested work ends it cancels remaining requests/children before return. A nested deadlock is detected from that nested run’s own tasks and waitable requests. It does not return status 1 from a task stack. Chains do not cross run identities.

task.test(seed, choices, body) supplies a Runner for a fresh nested run. Before a turn, take the next script path, if any; it must be runnable, otherwise raise NotRunnable(path = List(Int)), the one member of ScheduleError = NotRunnable, after structured cleanup. When the script ends choose FIFO. Do not root-poll or use real clocks. Completed fake requests are allowed; reaching a required wait on an unsettled host request stops with panic("task: test needs fake IO"). A replay record includes the seed, body inputs, script, enclosing Runner prefix, and initial state of its shared spawn/run allocator. Equal replay records give the same values, full task paths, selected alternatives and trace. Two successive tests in one task use different allocator states, so the same seed, body and script alone do not promise the same replay. Tests for real host overlap use ABI completion gates instead. The testing policy changes only which legal runnable step is chosen; it cannot commit an unready event.

9.10 Random generators

The module i/random gives pure, repeatable random computations. It needs no service and does not import i/task. It exports the type Generator with its constructor hidden, the error type below with its constructors, and these functions. The signatures describe bindings with bodies. RandomError = InvalidBound | InvalidPath, with InvalidBound = (Int) and InvalidPath = (List(Int)).

NameType

| from_seed | (Int) -> Generator | | word | (Generator) -> (Int, Generator) | | int | (Generator, Int) <- (Fail(RandomError)) -> (Int, Generator) | | float | (Generator) -> (Float, Generator) | | split | (Generator) -> (Generator, Generator) | | at_path | (Generator, List(Int)) <- (Fail(RandomError)) -> Generator |

A Generator contains a normalized seed, SplitMix state, tagged derivation history, and a nonnegative draw index. Equality is ordinary structural equality. Reusing the same immutable input repeats the same result; callers pass the next generator to get more values. This is not a cryptographic generator. Different streams need not have disjoint outputs, and their finite states can collide.

The algorithm is SplitMix, by Steele, Lea and Flood (OOPSLA 2014), using the mkSMGen, nextWord64, and splitSMGen variant of splitmix 0.1.3.1. Its exact operations are as follows. Let W = 2^64. Shifts are unsigned, and additions and multiplications reduce modulo W. M(x) applies xor-shift 33, multiply by 0xff51afd7ed558ccd, xor-shift 33, multiply by 0xc4ceb9fe1a85ec53, then xor-shift 33. V(x) applies xor-shift 30, multiply by 0xbf58476d1ce4e5b9, xor-shift 27, multiply by 0x94d049bb133111eb, then xor-shift 31. G(x) sets the low bit of V(x) to give z; if popcount(z xor (z >> 1)) < 24, it gives z xor 0xaaaaaaaaaaaaaaaa, otherwise z.

from_seed reduces its arbitrary Int seed by Euclidean remainder modulo W to n in 0..W-1. Seeds differing by W give the same generator. Its initial state is (M(n), G(n + 0x9e3779b97f4a7c15)), with empty history and draw index zero. A state (s, gamma) draws M(s + gamma), then becomes (s + gamma, gamma). word gives this word in 0..W-1 and increments the draw index. Native splitting gives continuation (s + 2gamma, gamma) and child (M(s + gamma), G(s + 2gamma)).

The public derivations navigate this native split tree. Define fork(h) by native-splitting h into (rest, a), then native-splitting rest into (_, b), and returning the two children (a, b). Each bit 0 or 1 selects that fork child, using the entire current state. Give the split step S(1) code 00 and S(2) code 01. For a positive index i, give the path step P(i) code 1 followed by Elias gamma(i): put k-1 zeros before the k-bit binary representation of i. Thus P(1) = 11, P(2) = 1010, and P(3) = 1011. These codes are prefix-free.

split returns the generators reached by S(1) and S(2). at_path follows P(i) for each successive list element. Each derivation appends the tagged step and the previous draw index to the history and resets its draw index to zero. Derivation after a draw therefore differs from derivation of its input. Consecutive path components behave the same whether given in one at_path call or several. at_path(g, []) is g. Path and split steps are distinct, including for arbitrarily large Int indices; scheduling never selects bits.

int(g, below) requires below > 0, including arbitrary-size bounds. Otherwise it raises InvalidBound(below) without a draw. Use the least k with W^k >= below, at least one. Draw k words in least-significant-first order to form u. Let limit = W^k - (W^k % below). Reject u >= limit and draw another group. Give u % below and the generator after every accepted and rejected draw. This avoids modulo bias under the uniform word model. float draws one word u and gives floor(u / 2^11) / 2^53 as a Float, with that word’s next generator. Its result is in [0, 1); at most 53 bits enter the conversion.

at_path(g, path) requires every index to be strictly positive. Otherwise it raises InvalidPath(path) without a draw or derivation, carrying the whole supplied path.

9.11 Requests

Request(A) and Ticket are compiler-defined names available in every module, like Boundary and Continuation. They have no public constructors. i/request exports the following ordinary status/service types, their constructors, and functions; it does not redeclare or re-export the primitive types.

Await = (wait = (Ticket) -> Nil)
Root = (key = Key(Nil))
Owner = (key = Key(Nil), root = Root)
Waiting = ()
Ready(A) = (A)
Cancelled = ()
Taken = ()
RequestState(A) = Waiting | Ready(A) | Cancelled | Taken
FunctionTypeRule

| open_root | () <- (Root) -> Root | Open an independent request run within the current entry context | | close_root | (Root) -> Nil | Close every live group of this root and the root itself; idempotent | | open_owner | () <- (Root) -> Owner | Register a group at the current root; changing Owner does not change that root | | close_owner | (Owner) -> Nil | Cancel its outstanding work and remove the live group; idempotent | | count | (Owner) -> Int | Number of outstanding requests in this group | | count_root | (Root) -> Int | Number of outstanding requests in all groups of this root | | settled_owner | (Owner) -> Bool | True exactly when count(owner) is zero | | settled_root | (Root) -> Bool | True exactly when count_root(root) is zero | | cancel_all | (Owner) -> Nil | Cancel the group’s outstanding work at this step; leave the group open | | cancel_all_root | (Root) -> Nil | Cancel outstanding work in all its groups at this step; leave them open | | wait_none | (Owner) <- (Await) -> Nil | Await until this group’s count is zero | | wait_none_root | (Root) <- (Await) -> Nil | Await until this root’s count is zero | | within | <A, N>(() <- (..N, Owner) -> A) <- (..N, Root) -> A | Fresh Owner; ensure close_owner on exit | | ready | <A>(A) -> Request(A) | A fresh completed request; available in pure fakes | | ticket | <A>(Request(A)) -> Ticket | The same underlying request identity; no answer access | | state | <A>(Request(A)) -> RequestState(A) | Reads do not consume the answer | | take | <A>(Request(A)) -> A | Ready becomes Taken atomically; other states stop | | settled | (Ticket) -> Bool | True for Ready, Cancelled or Taken | | cancel | (Ticket) -> Nil | Idempotent; cancels only Waiting, discards a Ready answer | | await | <A>(Request(A)) <- (Await) -> A | Borrow the producer, wait, then take the Ready value | | await_owned | <A>(Request(A)) <- (Await) -> A | Own a non-resource-producing operation; ensure ticket cancellation on exit | | wait_any | (List(Ticket)) -> Nil | Root wait; return when any listed ticket settles | | poll | () -> Nil | Root hand-off with status 2, even with no requests |

cancel makes every alias observe Cancelled, even for a previously Ready cell. A Taken request stays Taken on cancel. It invokes ABI cancel only for a still-outstanding host id. Cancelling a Ready request never invokes that import. await or take on Cancelled stops with panic("request: cancelled"); on Taken it stops with panic("request: taken"); take on Waiting stops with panic("request: not ready"); if Await.wait returns with Waiting it stops with panic("request: wait returned early"). wait_any([]) stops with panic("request: empty wait"). A nonempty list may contain aliases; deduplicate by identity. Terminal tickets make it return immediately. It must keep waiting through answers of unlisted requests, which are stored for later use. All-listed cancelled tickets count as settled. No root continuation is stored on a cell.

Cancellation ownership and run affiliation are separate. Root identifies a request run and its enclosing entry context. The runtime creates the initial Root only for an entry whose inferred needs require request/task support. open_root needs an existing live Root to retain that entry context; it does not require the new root to share the old root’s request count. A dispatcher can thus open an independent root without inventing a second entry. The initial bootstrap is part of the narrow entry provision, not a public unscoped root allocator. Entry request contexts and each task.run open a fresh Root and bind it for their whole lifetime. open_owner uses that Root, not the currently bound Owner, to register its group. request.within opens/binds a fresh Owner and ensures close_owner on exit; it leaves Root unchanged. Thus custom groups of a run have the same root, and a nested task.run has a different root. Both Root and Owner are ordinary portable key-bearing service values, not host requests.

Every raw host start records its Owner group and that group’s immutable root. close_owner cancels only its own group. close_root closes all groups registered at that root; no subgroup or old Owner binding escapes it. All group operations include every worker, including groups made inside request.within and retained by go. Counts and readiness are atomic snapshots; a later start can change them. A closed group/root has count zero and is settled. cancel_all cancels the Waiting members at its linearization step, not subsequent starts or detached Ready values; close additionally refuses future starts. Opening a group under a closed Root stops with request: root is gone; a host start under a closed Owner stops with request: owner is gone, before making the ABI import. Only live registrations and outstanding cells are retained; absence supplies the gone check.

There is no public outstanding/outstanding_root enumeration, including no worker-local variant. A Root/Owner operation never returns a Request or Ticket. Group cancellation is accepted atomically and forbids later success publication for its selected members. Before returning it applies the host cancellation actions, including lease revocation. Every state, settled, take, cancel, wait and delivery operation observes that terminal state. A host id is retired once.

wait_none/wait_none_root use Await, so a task waits without pausing runnable siblings. They return immediately on zero, including for a closed group. Otherwise a private local Request(Nil) observation cell subscribes to the group and settles on its next zero observation. It has no host id, producer operation or Owner membership: a wait is not work that can complete itself. Its Ticket never leaves the observer’s worker. Check-and-subscribe shares the registration lock so completion cannot be lost between checking and sleeping. A concurrent start before the zero observation is included; one after it does not undo the return. An ordinary ensure unregisters/cancels this local observer on waiter exit without cancelling the group’s producers. These private cells use the same typed state/await machinery as requests; no foreign Ticket is manufactured. For progress accounting, an observation cell depends on the actual outstanding members of its target group, not on itself; add those members as explicitly borrowed work and deduplicate identities (§9.9). In particular it cannot keep an otherwise deadlocked empty group alive. Remote members are driven on their home workers; group changes reach the local observer through its wake route. The private Runner idle operation subscribes to group revisions for any change, combined with explicitly borrowed local tickets (§9.9), without enumerating foreign tickets. Its observation/check/subscription must be atomic too.

Root is kept at go sites as a normal service. Changing Owner alone cannot move a group/request to another run. open_owner under a nested run’s Root makes a nested group even if user code temporarily rebinds an outer Owner. Explicitly using a previously made outer Owner for a raw start still charges that outer group/root; this is a capability use, not a transfer of the group. Borrowed request tickets may be awaited by another run on the same worker. Opening or rebinding an independent Root is the public low-level operation for writing a separate request dispatcher, like calling root wait directly; it does not silently reaffiliate an existing i/task run’s stored root or its old groups. The default task scheduler counts the root it owns, not whichever Owner is bound at an idle point. An independent request dispatcher must drive its root.

A Ready cell has left its group and belongs to its handle. Closing a group/root does not discard such an answer; raw callers must own it just as they own files returned by open. Completed cells retain decoded i values only while handles are reachable, not through the outstanding table. Low-level cancellation cannot roll back a host side effect or run i final functions. Closing a root after its structured tasks/finals end cancels any still-outstanding unowned raw work.

Root wait/poll require an active async entry context when they must hand off; otherwise they stop with panic("request: no active entry"). A later synchronous export must not hand off through a stale boundary from a completed async call. An immediate wait on completed fake requests works without that context. They are public primitives for writing dispatchers, batch IO, or request races. Direct use inside a task pauses that entire run until it returns; normal await uses Await instead. This is explicit control use, like suspending to an outer boundary, and does not create multiple scheduler histories.

Root and Owner are ordinary services, not primitive types or built-in names. They use Key(Nil) and are portable; outstanding request values remain non-portable. Root/group registration and observation use internal accounting. Local Ticket cancel uses v3 cancel on its home worker; MC group cancellation uses the registered host cancellation actions above and retires the home id once. MC routes notifications through ordinary request-mode wake services, without new runtime imports. Generic request groups also serve batch cancellation, request timeouts, and host plugin cleanup, independently of tasks. Request operations introduce no new error-row or environment polymorphism. Ticket cannot recover A; state only reads through the original Request(A). The value restriction prevents a fresh request from being generalized at multiple unrelated answer types. A cell’s state transitions are Waiting -> Ready or Cancelled, and Ready -> Taken or Cancelled; there is no write of another A.

In a task, Await.wait submits a WaitRequest(ticket) command to its Scheduler, with the task’s inner-unwind target registered privately. The loop records the waiting task and ticket and queues another task. On each switch it scans settled tickets; a ticket may have several waiters. Await consumes the answer once; multiple waiters deliberately competing for it may therefore meet request: taken. The plain async adapter owns its just-started request through resource.ensure, and cancels it on exit, including while unwinding. A raw starter gives ownership to the caller, who can use the same pattern. Cancelling one waiter of a manually shared raw request does not cancel the producer unless its owner’s final function does so; cancelling a ticket wakes all waiters, whose await then stops as above.

Because request.cancel also discards Ready answers, an implicit adapter must take its A before its final cancels the cell. For resource-creating imports, IO wrappers additionally transfer resource ownership as section 8 specifies; discarding an unclaimed Ready answer releases its resource. These are two separate ownership transfers, not a promise that all raw results are resources.

The ordinary helper for an owned operation that produces no resource is:

await_owned = <A>(r : Request(A)) -> A {
	resource.ensure(() { await(r) }, () { cancel(ticket(r)) })
}

This definition lives in i/request and uses its local function names. The final runs on success, error and task cancellation; after take, cancel is a no-op. request.await itself remains a borrowed wait and never takes producer ownership. File/Stream/Child wrappers and other non-creating IO wrappers use await_owned, including their close operations; resource creators use resource guards (§9.7). Cancelling a pending Stream read revokes its read lease before the cancelled wrapper finishes unwinding, so a following read cannot see busy from that abandoned request. Already consumed bytes or committed effects are not rolled back. A cancelled Child wait releases its wait lease without closing the Child; a cancelled blocking File operation instead closes that File as §9.7 requires.

9.12 IO library operations

These ordinary functions use the services described in §9.7; their definitions and exported types are in their library modules.

i/clock

FunctionWhat it does
monotonic_nsRead the monotonic clock in nanoseconds.
unix_nsRead Unix time in nanoseconds.
sleep_nsWait for a duration in nanoseconds.

i/stdio

FunctionWhat it does
readRead bytes from standard input.
writeWrite bytes to standard output.
write_errorWrite bytes to standard error.
flushFlush standard output.
flush_errorFlush standard error.
printWrite text and a newline to standard output.
print_errorWrite text and a newline to standard error.

i/fs

FunctionWhat it does
openOpen a file in the given mode.
readRead bytes from a file.
writeWrite bytes to a file.
flushFlush a file.
closeClose a file.
removeRemove a path.
renameRename a path.
mkdirCreate a directory.
listList directory entries.
metadataRead file size and kind.
temp_dirFind the temporary directory.
read_fileRead a whole file.
write_fileWrite a whole file.
read_linesRead a file as text lines.

i/process

FunctionWhat it does
argsRead process arguments.
envRead an environment variable.
cwdRead the working directory.
entropyObtain secure random bytes.
spawnStart a child process.
spawn_pipedStart a child with selected standard streams.
spawn_configStart a child with stream, environment and directory settings.
read_stdoutRead the child’s standard output.
read_stderrRead the child’s standard error.
write_stdinWrite to the child’s standard input.
close_stdinClose the child’s standard input.
waitWait for a child to exit.
terminateTerminate a child.
closeClose a child handle.

i/net

FunctionWhat it does
bindBind a UDP datagram socket to an address and port (0 selects a port).
send_toSend one datagram to an address and port.
receive_fromReceive one datagram and its sender address and port.
local_addressRead a datagram socket’s numeric local address.
close_datagramClose a datagram socket.
listenListen for TCP connections.
local_portRead a listener or datagram socket’s local port.
acceptAccept a TCP connection.
connectConnect to a host and port.
resolveResolve a host name to addresses.
readRead bytes from a stream.
writeWrite bytes to a stream.
shutdown_writeShut down the stream’s write side.
close_streamClose a stream.
close_listenerClose a listener.
stream_numberReturn the underlying stream number.
byte_streamMake a byte stream interface for a connection.

i/tls

FunctionWhat it does
optionsMake default TLS options.
identityMake a certificate and key identity.
connectEstablish client TLS on a connected stream.
acceptEstablish server TLS on a connected stream.
dialConnect to a host and establish client TLS.
alpnRead the negotiated ALPN protocol.
readRead bytes from a TLS stream.
writeWrite bytes to a TLS stream.
shutdown_writeShut down the TLS stream’s write side.
closeClose a TLS stream.
byte_streamMake a byte stream interface for a TLS stream.