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
Failis still active: using it after itscatchhas 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 : Tbindsxto the service keyed byT. A need that a head does not write still passes down: a function that callselapsedneedsClocktoo, 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 <- sworks outewith the services of the place plus the datas(§6.9), for any expressione;sis worked out first, with the services of the place. For a key thatshas,swins; 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
Neverwhile neither side isNever, the need cannot be satisfied: it is a compile error (E0515). - A provided value of type
Psatisfies the need entrySwhenP <: S(§4.5 rule 4). An entry that every value of its type constructor satisfies, asFail(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(…)),Nhas no entry forC. A rigid row variable that reaches a provide ofCwithout a statedCentry 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):
| Function | Type | What it does |
|---|---|---|
fail.raise | <E>(E) <- (Fail(E)) -> Never | Ends 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)) -> A | Raises x for Error(x) |
fail.map_error | <A, E, E2, N>(() <- (..N, Fail(E)) -> A, (E) <- N -> E2) <- (..N, Fail(E2)) -> A | Changes 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 insafeabove. - An error goes out through the calls to the
catchthat provided itsFail. The computation that is jumped over is unwound first (§9.5). - A
Failvalue that is used after thecatchthat made it has ended, because it was returned, stored, or captured by a function that runs later, stops the program with the messageboundary not active(§9.4). The compiler warns when aFailbound in a service pattern is captured by a function value or put into data. Assertis a separate service (§9.6), socatchdoes 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:
| Function | Type |
|---|---|
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).
boundary(body)makes a boundaryband doesbody(b). The value ofbodyis the value ofboundary.bis active while the computation between it and the running code is on the stack: from the start ofbodyuntilbodyends, but not while that part is paused in a continuation. Afterresume, it is active again.suspend(b, f)pauses the computation from this point back to the boundaryb. That paused part is a continuationk. Thenf(k)is done in the place of the boundary, with the services of that place, and its value is the value ofboundary. Ifbis not active, the program stops.resume(k, v)goes on with the paused computation, withvas the value ofsuspend. It goes on on top of the place whereresumeis called, and the value that the computation has when it gets to the boundary is the value ofresume. The paused part keeps the services provided inside it; for the rest, it gets the services of the place whereresumeis called. So an error raised afterresumegoes to theFailof the place ofresume, unless the paused part provided its own. The services that a function made bykeepkeeps (below) count, for the code inside that function, like services provided inside the paused part.cancel(k)unwinds the paused computation (§9.5) and givesNil. While it unwinds, the boundary stays not active, and the final functions get the services as in rule 3, from the place wherecancelis called; inside a function made bykeep, they get the kept services.escape(b, v)ends the computation back to the boundarybat once, unwinding it (§9.5), andvis the value ofboundary. It issuspend(b, (k) { cancel(k) v })with no continuation made. Ifbis not active, the program stops with the messageboundary not active.- One use only:
resumeandcanceltogether may be used one time for a continuation. A second use stops the program. - If a continuation is never resumed or cancelled, its final functions are not run.
- 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).
| Function | Type | What it does |
|---|---|---|
resource.ensure | <A, N>(() <- N -> A, () <- N -> Nil) <- N -> A | Runs 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) -> A | Opens a resource and attaches its release to the current Scope |
resource.scoped | <A, N>(() <- (..N, Scope) -> A) <- N -> A | Runs 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 anEthat the host can take: the host’s error is raised through the caller’sFail. 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
namespaceand 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/iodeclares and exports the standard service@host("i:io/v1") Io = (print = (String) -> Nil).printwrites the text and a line feed to the standard output, and givesNil, 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 namespacei:io/v1is a normal host service, and the rule above about namespaces and labels is true for it as for any other: with a fieldprint, 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.
| Target | Host services of the platform |
|---|---|
| js | Io, Clock, Entropy |
| nodejs | Io, Clock, Entropy, Arguments, Environment, WorkingDirectory, Fs, Net, Process, Stdio |
| wasi | Io, Clock, Entropy, Arguments, Environment, Fs over preopened directories, Stdio |
| native | Io, Clock, Entropy, Arguments, Environment, WorkingDirectory, Fs, Net, Tls, Process, Stdio |
| host | The 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:
| Service | Namespace | Raw fields |
|---|---|---|
| Entropy | i:entropy/v1 | entropy |
| Arguments | i:arguments/v1 | args |
| Environment | i:environment/v1 | env |
| WorkingDirectory | i:working-directory/v1 | cwd |
| Process | i:process/v1 | spawn, 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).
| Function | Type | What it does |
|---|---|---|
new | <A>() -> Key(A) | A new key, the same only as itself |
wrap | <A>(Key(A), A) -> Keyed | The 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.
| Binding | Type |
|---|---|
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_scope | The 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.
| Operation | Turn behavior |
|---|---|
path, random | Data 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 entry | Check cancellation and perform the command in the current turn; poll only if a task needs host progress; no turn end |
yield | End the turn and queue the caller at the tail |
sync, join, send, receive | End the turn even when ready immediately; if blocked, remove the task until ready |
| Scope exit | End the turn; wait for children and cleanup as required by R3 |
Async await through the task’s Await | End the turn even when the request is already terminal |
run, test | Run the nested scheduler; the outer task ends its turn only when the nested Runner waits or polls |
scope, local_scope, both, race, map_concurrently | Their 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)).
| Name | Type |
|---|
| 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 | Function | Type | Rule |
|---|
| 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
| Function | What it does |
|---|---|
monotonic_ns | Read the monotonic clock in nanoseconds. |
unix_ns | Read Unix time in nanoseconds. |
sleep_ns | Wait for a duration in nanoseconds. |
i/stdio
| Function | What it does |
|---|---|
read | Read bytes from standard input. |
write | Write bytes to standard output. |
write_error | Write bytes to standard error. |
flush | Flush standard output. |
flush_error | Flush standard error. |
print | Write text and a newline to standard output. |
print_error | Write text and a newline to standard error. |
i/fs
| Function | What it does |
|---|---|
open | Open a file in the given mode. |
read | Read bytes from a file. |
write | Write bytes to a file. |
flush | Flush a file. |
close | Close a file. |
remove | Remove a path. |
rename | Rename a path. |
mkdir | Create a directory. |
list | List directory entries. |
metadata | Read file size and kind. |
temp_dir | Find the temporary directory. |
read_file | Read a whole file. |
write_file | Write a whole file. |
read_lines | Read a file as text lines. |
i/process
| Function | What it does |
|---|---|
args | Read process arguments. |
env | Read an environment variable. |
cwd | Read the working directory. |
entropy | Obtain secure random bytes. |
spawn | Start a child process. |
spawn_piped | Start a child with selected standard streams. |
spawn_config | Start a child with stream, environment and directory settings. |
read_stdout | Read the child’s standard output. |
read_stderr | Read the child’s standard error. |
write_stdin | Write to the child’s standard input. |
close_stdin | Close the child’s standard input. |
wait | Wait for a child to exit. |
terminate | Terminate a child. |
close | Close a child handle. |
i/net
| Function | What it does |
|---|---|
bind | Bind a UDP datagram socket to an address and port (0 selects a port). |
send_to | Send one datagram to an address and port. |
receive_from | Receive one datagram and its sender address and port. |
local_address | Read a datagram socket’s numeric local address. |
close_datagram | Close a datagram socket. |
listen | Listen for TCP connections. |
local_port | Read a listener or datagram socket’s local port. |
accept | Accept a TCP connection. |
connect | Connect to a host and port. |
resolve | Resolve a host name to addresses. |
read | Read bytes from a stream. |
write | Write bytes to a stream. |
shutdown_write | Shut down the stream’s write side. |
close_stream | Close a stream. |
close_listener | Close a listener. |
stream_number | Return the underlying stream number. |
byte_stream | Make a byte stream interface for a connection. |
i/tls
| Function | What it does |
|---|---|
options | Make default TLS options. |
identity | Make a certificate and key identity. |
connect | Establish client TLS on a connected stream. |
accept | Establish server TLS on a connected stream. |
dial | Connect to a host and establish client TLS. |
alpn | Read the negotiated ALPN protocol. |
read | Read bytes from a TLS stream. |
write | Write bytes to a TLS stream. |
shutdown_write | Shut down the TLS stream’s write side. |
close | Close a TLS stream. |
byte_stream | Make a byte stream interface for a TLS stream. |