nupp.tasks
An application task scope.
nupp.suspension's combinators own a family that is complete when the call is written: all cannot be handed a fourth body once it is running. create makes a coroutine that inherits a handler and says nothing about its result, failure or lifetime. Neither is what a server, a loading pipeline or a scene needs, which is a place to put children discovered over time and one answer to what happens when the body returns, a child fails, or the whole thing is cancelled.
nupp.workers has that shape already for CPU work, and its terminal cleanup cannot suspend, so leaving its scope blocks the thread until unawaited children finish. Correct for a worker scope, wrong inside a frame. A task scope owns one lazily and closes it through the suspension-aware path before the task scope returns.
The scheduling contract is with the host, not with each child. A host sees one aggregate and decides when it runs; this decides which of its children run then, in FIFO order, up to a bounded number of activations per host turn. Nesting divides that bound rather than multiplying it, because the token belongs to the turn rather than to a scope.
local greeting = ""
with scope = nupp.tasks.open() do
const hello = scope:spawn(function(): string
return "hello"
end)
const world = scope:spawn(function(): string
return "world"
end)
greeting = hello:await() .. " " .. world:await()
end
assert(greeting == "hello world")A scope is opened with open, held by a with, and settled when the block ends: every child has run, been cancelled, or unwound by then. open takes a limit, which parks spawn and fork while that many children are live, and a deadline. A running child cooperates by calling checkpoint, so it can stop promptly when the scope is cancelled.
local total = 0
with scope = nupp.tasks.open(deadline = 1000) do
const sum = scope:spawnNamed("sum values", function(): integer
local answer = 0
for value = 1, 1000 do
nupp.tasks.checkpoint()
answer = answer + value
end
return answer
end)
total = sum:await()
end
assert(total == 500500)Module contents
Types
| Type | Kind | Description |
|---|---|---|
Cancellation | record | What a cancelled task raises. |
Scope | record | The scope a run body is handed. |
Task | type | The handle one spawn answers. |
Functions
| Function | Kind | Description |
|---|---|---|
ForkMT.await | function | |
ForkMT.cancel | function | |
ForkMT.isDone | function | |
ForkMT.status | function | |
TaskMT.await | function | |
TaskMT.cancel | function | |
TaskMT.isDone | function | |
TaskMT.status | function | |
checkpoint | function | Raises where the current task has been cancelled or has run out of time. |
deadline | function | The effective deadline of the current task, or nil where there is none. |
gather | function | Runs every body concurrently and answers what each of them did, failures included. |
isCancelled | function | Whether a caught value is a task cancellation. |
open | function | Opens a scope, to be held by a with. |
settle | function | Settles an opened scope, which is what leaving its with does. |
Types#
Cancellationrecord#
record tasks.Cancellation
operation: string
reason: string?
endWhat a cancelled task raises.
Nominal rather than a string, so isCancelled recognizes it without matching text, and so a program that catches everything still sees something it can ask about. tostring renders it because an uncaught one reaches a human.
Fields
Scoperecord#
record tasks.Scope
spawn: function<F>(
borrows self: tasks.Scope,
takes body: F,
...: unpackof Parameters(F)
): tasks.Task<F> borrows (self)
spawnNamed: function<F>(borrows self: tasks.Scope, name: string, takes body: F): tasks.Task<F> borrows (self)
fork: function<F is Submittable>(
borrows self: tasks.Scope,
F,
...: unpackof nupp.runtime.services.workers.Submitted(F)
): tasks.Task<F> borrows (self)
cancel: function(borrows self: tasks.Scope, reason: string?): nil
endThe scope a run body is handed.
Not affine, and deliberately: run owns the extent, so there is no obligation for a body to discharge and no way for one to end the scope early. What the body can do is add children to it and reach the worker scope it owns.
Methods
spawn#
spawn: function<F>(
borrows self: tasks.Scope,
takes body: F,
...: unpackof Parameters(F)
): tasks.Task<F> borrows (self)Starts a child under this scope: scope:spawn(arguments..., f).
Arguments
| Name | Type | Description |
|---|---|---|
borrows self | tasks.Scope | |
takes body | F | |
... | unpackof Parameters(F) |
Returns
| Type | Description |
|---|---|
tasks.Task<F> borrows (self) |
spawnNamed#
spawnNamed: function<F>(borrows self: tasks.Scope, name: string, takes body: F): tasks.Task<F> borrows (self)Starts a named child under this scope.
Arguments
| Name | Type | Description |
|---|---|---|
borrows self | tasks.Scope | |
name | string | |
takes body | F |
Returns
| Type | Description |
|---|---|
tasks.Task<F> borrows (self) |
fork#
fork: function<F is Submittable>(
borrows self: tasks.Scope,
F,
...: unpackof nupp.runtime.services.workers.Submitted(F)
): tasks.Task<F> borrows (self)Starts a child on a worker lane: scope:fork(arguments..., f).
Arguments
| Name | Type | Description |
|---|---|---|
borrows self | tasks.Scope | |
? | F | |
... | unpackof nupp.runtime.services.workers.Submitted(F) |
Returns
| Type | Description |
|---|---|
tasks.Task<F> borrows (self) |
Tasktype#
type tasks.Task<F> = TaskType(F)The handle one spawn answers.
Its await result pack is the body's, which is why this is derived from the function type rather than declared once over any.
Type parameters
| Name | Description |
|---|---|
F |
Functions#
ForkMT.awaitfunction#
Arguments
| Name | Type | Description |
|---|---|---|
self | any |
ForkMT.cancelfunction#
Arguments
| Name | Type | Description |
|---|---|---|
self | any | |
reason | string? |
Returns
| Type | Description |
|---|---|
boolean |
ForkMT.isDonefunction#
Arguments
| Name | Type | Description |
|---|---|---|
self | any |
Returns
| Type | Description |
|---|---|
boolean |
ForkMT.statusfunction#
Arguments
| Name | Type | Description |
|---|---|---|
self | any |
Returns
| Type | Description |
|---|---|
string |
TaskMT.awaitfunction#
Arguments
| Name | Type | Description |
|---|---|---|
self | any |
TaskMT.cancelfunction#
Arguments
| Name | Type | Description |
|---|---|---|
self | any | |
reason | string? |
Returns
| Type | Description |
|---|---|
boolean |
TaskMT.isDonefunction#
Arguments
| Name | Type | Description |
|---|---|---|
self | any |
Returns
| Type | Description |
|---|---|
boolean |
TaskMT.statusfunction#
Arguments
| Name | Type | Description |
|---|---|---|
self | any |
Returns
| Type | Description |
|---|---|
string |
tasks.checkpointfunction#
function tasks.checkpoint(): nilRaises where the current task has been cancelled or has run out of time.
The one authored cancellation point, and the only thing that reaches a body computing without parking. It never suspends, so a nosuspend region and a worker lane can both call it, and outside any task it does nothing.
for index = 1, #items do
tasks.checkpoint()
consume(items[index])
endReturns
| Type | Description |
|---|---|
nil |
Raises
the cancellation, where one has been requested
tasks.deadlinefunction#
function tasks.deadline(): number?The effective deadline of the current task, or nil where there is none.
So a body can size its work rather than discover the bound by being cancelled part way through it.
Returns
| Type | Description |
|---|---|
number? | an absolute monotonic reading, as |
tasks.gatherfunction#
Runs every body concurrently and answers what each of them did, failures included.
The fail-soft family. Both arrays are indexed as bodies was, and exactly one of them holds an entry per branch, so a caller who has to see every outcome sees them beside each other. A scope is the fail-fast answer to the same question: use one where the first failure should end the rest.
const values, errors = nupp.tasks.gather({
function(): string return fetch(primary) end,
function(): string return fetch(mirror) end,
})Type parameters
| Name | Description |
|---|---|
T |
Arguments
| Name | Type | Description |
|---|---|---|
bodies | {function(): T} | what to run |
Returns
| Type | Description |
|---|---|
{T?} | each body's value, where it returned |
{any} | each body's error, where it raised |
Raises
the enclosing deadline, where one passed before the family settled
tasks.isCancelledfunction#
function tasks.isCancelled(value: any): booleanWhether a caught value is a task cancellation.
The one question a pcall around task work has to be able to ask, because cancellation is not a failure and should usually be re-raised rather than reported.
const ok, problem = pcall(work)
if not ok and not tasks.isCancelled(problem) then
report(problem)
endArguments
| Name | Type | Description |
|---|---|---|
value | any | whatever was caught |
Returns
| Type | Description |
|---|---|
boolean | whether this is a cancellation |
tasks.openfunction#
Opens a scope, to be held by a with.
The block is the scope's body. Children started in it with spawn and fork are its family, and leaving the block -- normally, by break or return, or by an error -- settles them: every one has run, been cancelled, or unwound before the block is left. A child's failure is the scope's from the moment it happens, cancels its siblings, and is raised where the block is left, whether or not anything awaited that child.
With a limit, spawn and fork park while that many children are live, so a loop that fans out over a source is bounded by the loop itself: nothing is pulled from the source until there is room to run it. With a deadline, in milliseconds of the monotonic clock, expiry requests ordinary cancellation. A scope opened inside another takes the earlier of the two deadlines: a child may bound itself more tightly than its parent did, and may not extend what its parent already promised.
Both are named arguments: open(), open(limit = 8), open(deadline = 500), or open(limit = 8, deadline = 500).
const sizes: {integer} = {}
with scope = nupp.tasks.open(limit = 8) do
for index, url in ipairs(urls) do
scope:spawn(sizes, index, url, storeSize)
end
endArguments
| Name | Type | Description |
|---|---|---|
limit | integer? | how many children may be live at once, or nil for no bound |
deadline | number? | how long the whole scope may take, in milliseconds, or nil |
Returns
| Type | Description |
|---|---|
affine(tasks.Scope, tasks.settle) | the scope, settled when its |
Raises
where the limit is not a positive integer or the deadline is not a finite non-negative number
tasks.settlefunction#
Settles an opened scope, which is what leaving its with does.
Every child has run, been cancelled, or unwound when this returns, and the worker scope, where one was opened, has been closed through the suspension-aware path. It is a settling terminal: it parks until that is so, and so is refused inside a nosuspend region. Idempotent, so a scope settled by hand before its block ends settles once.
A failure the block itself raises does not cancel the children: they run to completion before it propagates. Call cancel first where that is not wanted.
Arguments
| Name | Type | Description |
|---|---|---|
takes scope | tasks.Scope | the scope |
Returns
| Type | Description |
|---|---|
nil |
Raises
the first failure a child had, or the cancellation a deadline caused