scopes
Scopes: work you start together and that ends together, tied to what started it (an entity, a component, a scene, another scope), and the owning contexts every live resource is registered under. -- Load two things at once; neither outlives the load. task.spawn(function() scopes.run(function(s) s:spawn("terrain", function() task.wait(2) end) s:spawn("props", function() task.wait(1) end) end) print("both are done") end) -- What is live, and what will never be reached by a seam? for _, r in scopes.list() do if not r.endsAtASeam then print(r.kind, r.handle, r.scope, r.detail) end end
Start several pieces of work that must not outlive the thing that started
them, and wait for all of them:
scopes.run(function(s)
s:spawn("load-terrain", function() ... end)
s:spawn("load-props", function() ... end)
end)
-- returns once both are finished or cancelled; the first error either
-- raised is raised again here, after the other one was cancelled
Start work tied to an entity, without waiting for it:
local s = scopes.open({ entity = npc })
s:spawn("patrol", function() while true do ... task.wait(1) end end)
s:spawn("bark", function() ... end)
-- despawning npc cancels both; so does s:cancel()
Everything a task in a scope starts belongs to the scope too: a
task.spawn inside it, a job it registers, a scope it opens. A scope is
lived inside what the code opening it belongs to, so a scope opened in a
component's hook ends with the component (destroyed or hot-reloaded), one
opened by a scene's entrypoint ends with that load of the scene, and one
opened in another scope's work ends with that scope.
When a scope ends, every task still running in it is cancelled, and
unwinds: the cancellation is raised at the wait it is suspended in, so a
pcall or cleanup around that wait runs (task.isCancellation(err)
recognises it). A scope ends when:
- its
scopes.runis over; - work in it raises: the rest of its work is cancelled, and
join(and soscopes.run) raises that first error again where it is called.s.onError = function(name, err) endobserves every error as it is raised. The VM-widetask.onErroris not told while ascopes.runor ajoinwaits for the scope oronErrorobserves it; an error in a scope nothing waits for or observes is reported as any task's error is; s:cancel(),scopes.release(s.tag), or cancelling the task waiting inscopes.run;- what it is lived inside ends: the entity it is tied to despawns, the
component is destroyed or hot-reloaded, the scene load ends, the
enclosing scope ends.
s:why()says which ended it first, and is nil while the scope is open. Ascopes.runwhose scope something else ended returns once the work it started is cancelled, ands:why()names what ended it. The registry: a resource reached through a handle (a task, a substrate job, a camera feed, an input-map activation, an input subscription) outlives the call that made it. Each one records the context it was registered from, and the engine ends what a context left behind once that context's run is over. The contexts, by scope tag: scope:<n>: a task scope, as above.entity:<id>: an entity. Ends when it is despawned.scene:<guid>#<n>: one load of a scene layer. Ends when the load ends: the layer is reloaded or unloaded, the scene is swapped, the mode flips.chunk:<identity>: a module. Ends when the module runs again (a VFS write to its source) or is dropped from the require cache (vfs.reload). The next run registers its own.component:<instanceId>: one component instance. Ends when that instance is destroyed: its entity despawned, its scene cleared, the component removed. A hot reload of the component ends its tasks and the resources Luau code holds for it (listed with kindluau), and the new life'sawaketakes its own.exec:<chunk>: a chunk submitted for a single run, such as anexecutecall. It is never re-entered, so nothing ends it on its own. Aluauresource (an input-map activation, an input subscription) taken by work in a task scope, such as afuture.spawnbody or a future's handler, follows what that scope runs inside rather than the scope: the holder keeps it after the work is over.list()shows every live resource by the context it follows, andrelease(scope)ends everything under one. That last is what reaches a resource no variable names any more: a task anexecutecall started and lost the handle of is listed underexec:<chunk>, and releasing that tag cancels it.
require("@builtin::modules.scopes") -- scopes (also available as global 'scopes')
The scopes namespace: 15 functions.
globals/scopes/current
scopes.current() -> string?
The scope the calling code registers a resource under right now: the module whose body is running, the task scope its work runs in, the component instance whose lifecycle hook is on the stack, or the chunk of this call. Nil when the caller registers under no context.
Returns string? — The scope tag, or nil.
print("resources I register follow", scopes.current())
globals/scopes/list
scopes.list() -> { LiveResource }
Every live resource that follows an owning context, across every subsystem holding them. A resource registered with no context above it, the engine's own, is not listed, because no scope reaches it.
Returns { LiveResource } — Array of live resources.
for _, r in scopes.list() do print(r.scope, r.kind, r.detail) end
globals/scopes/open
scopes.open(options: ScopeOptions?) -> Scope
Open a task scope without waiting for it: start work in it with
s:spawn, end it with s:cancel(), or let what it is lived inside end it
({ entity = id } ties it to an entity). Wait for its work with
s:join() from a task.
Parameters
optionsScopeOptions(optional) —{ entity = id }ties the scope to an entity;{ name = "..." }names it.
Returns Scope — The scope.
local s = scopes.open({ entity = npc }); s:spawn("patrol", patrol)
globals/scopes/release
scopes.release(scope: string) -> { Released }
End every resource registered under scope, across every subsystem:
tasks are cancelled, jobs dropped. Reaches contexts no seam does, such as
the chunk of an execute call that started something and ended without
ending it. An execute call runs as a task listed under its own exec:
scope, so releasing that scope from the same call cancels the call too:
release it from a later call.
Parameters
scopestring— A scope tag, as thescopefield of alist()row carries it.
Returns { Released } — One row per subsystem that ended something, with how many it ended.
local ended = scopes.release("exec:__exec_12")
globals/scopes/run
scopes.run(fn: (Scope) -> ...any, options: ScopeOptions?) -> ...any
Run fn(s) in a new task scope and wait until every piece of work
started in it is finished or cancelled. Returns what fn returned. The
first error raised by fn or by any work in the scope cancels the rest,
and is raised again here, as it was raised, once their cleanup has run.
Waits, so it must be called from a task: in code that cannot wait (a
component hook called directly, a callback) it raises, naming that.
Parameters
fn(Scope) -> ...any— The function to run; it receives the scope, and starts work withs:spawn.optionsScopeOptions(optional) —{ entity = id }ties the scope to an entity;{ name = "..." }names it.
Returns ...any — What fn returned.
scopes.run(function(s) s:spawn("a", fa); s:spawn("b", fb) end)
modules/scopes/cancel
cancel(self: Scope, name: string?): number
Cancel the work named name, or end the whole scope when name is
omitted. Cancelled work unwinds: its cleanup runs at the wait it is
suspended in. Returns how many tasks this cancelled.
Parameters
selfScopenamestring?(optional) — A child's name; omit to end the scope.
s:cancel("patrol")
modules/scopes/children
children(self: Scope): { ScopeChild }
What this scope started with spawn, in the order started, each with
its current task.status: every child still running, and the 32 that
ended most recently.
Parameters
selfScope
for _, c in s:children() do print(c.name, c.status) end
modules/scopes/current
current(): string?
The scope the calling code registers a resource under right now: the module whose body is running, the task scope its work runs in, the component instance whose lifecycle hook is on the stack, or the chunk of this call. Nil when the caller registers under no context.
print("resources I register follow", scopes.current())
modules/scopes/join
join(self: Scope, name: string?)
Wait until the work named name (all of the scope's work when omitted)
is finished or cancelled, its cleanup included. Raises the first error
that work raised, as it was raised. Called from work in the scope (the
function scopes.run runs, say), it waits for the rest of the work and
not for the calling task. Waits, so call it from a task.
Parameters
selfScopenamestring?(optional) — A child's name; omit to wait for everything in the scope.
s:join()
modules/scopes/list
list(): { LiveResource }
Every live resource that follows an owning context, across every subsystem holding them. A resource registered with no context above it, the engine's own, is not listed, because no scope reaches it.
for _, r in scopes.list() do print(r.scope, r.kind, r.detail) end
modules/scopes/open
open(options: ScopeOptions?): Scope
Open a task scope without waiting for it: start work in it with
s:spawn, end it with s:cancel(), or let what it is lived inside end it
({ entity = id } ties it to an entity). Wait for its work with
s:join() from a task.
Parameters
optionsScopeOptions?(optional) —{ entity = id }ties the scope to an entity;{ name = "..." }names it.
local s = scopes.open({ entity = npc }); s:spawn("patrol", patrol)
modules/scopes/release
release(scope: string): { Released }
End every resource registered under scope, across every subsystem:
tasks are cancelled, jobs dropped. Reaches contexts no seam does, such as
the chunk of an execute call that started something and ended without
ending it. An execute call runs as a task listed under its own exec:
scope, so releasing that scope from the same call cancels the call too:
release it from a later call.
Parameters
scopestring— A scope tag, as thescopefield of alist()row carries it.
local ended = scopes.release("exec:__exec_12")
modules/scopes/run
run(fn: (Scope) -> ...any, options: ScopeOptions?): ...any
Run fn(s) in a new task scope and wait until every piece of work
started in it is finished or cancelled. Returns what fn returned. The
first error raised by fn or by any work in the scope cancels the rest,
and is raised again here, as it was raised, once their cleanup has run.
Waits, so it must be called from a task: in code that cannot wait (a
component hook called directly, a callback) it raises, naming that.
Parameters
fn(Scope) -> ...any— The function to run; it receives the scope, and starts work withs:spawn.optionsScopeOptions?(optional) —{ entity = id }ties the scope to an entity;{ name = "..." }names it.
scopes.run(function(s) s:spawn("a", fa); s:spawn("b", fb) end)
modules/scopes/spawn
spawn(self: Scope, name: string, fn: (...any) -> ...any, ...: any): number
Start fn(...) as a task in this scope, named name, and return its
task handle. The task, and everything it starts, belongs to the scope: it
is cancelled (and unwinds) when the scope ends. Raises when the scope has
already ended, naming why.
Parameters
selfScopenamestring— What the work is called, inchildren(),join(name), errors and listings.fn(...any) -> ...any— The function to run.argsany(optional)
s:spawn("props", function() task.wait(1) end)
modules/scopes/why
why(self: Scope): string?
Why the scope ended: its run finished, it was cancelled, a named
child raised, or what it was lived inside ended (entity:<id> ended,
component:<id> ended, ...). Nil while the scope is open.
Parameters
selfScope
print(s:why())