Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

README.md

scopes

Work you start together and that ends together, and the owning contexts every live resource is registered under.

Task scopes

Start several pieces of work that must not outlive the thing that started them, and wait for all of them:

task.spawn(function()
    scopes.run(function(s)
        s:spawn("terrain", function() task.wait(2) end)
        s:spawn("props", function() task.wait(1) end)
    end)
    -- both are finished or cancelled here, and their cleanup has run
end)

scopes.run(fn, options?) runs fn(s) in a new scope and returns what fn returned once every piece of work started in the scope is over. It waits, so it is called from a task; code that cannot wait gets an error saying so.

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)
-- despawning npc cancels it; so does s:cancel()
On a scope sDoes
s:spawn(name, fn, ...)Start fn(...) as a task in the scope, and return its handle
s:join(name?)Wait until that child (or all the scope's work) is over, then raise the first error it raised
s:children(){ name, handle, status, source } for each child still running and the most recent 32 that ended, status being its task.status
s:cancel(name?)Cancel one child's work, or end the whole scope; returns how many tasks it cancelled
s:why()What ended the scope first, or nil while it is open
s.onError = fn(name, err)Observe each error raised in the scope, as it is raised
s.tagThe scope's tag, scope:<n>, as scopes.list() shows it

Everything a task in a scope starts belongs to the scope as well: a task.spawn, a job it registers, a scope it opens. A scope is lived inside what the code opening it belongs to, so the end of that ends the scope: a component destroyed or hot-reloaded, a scene load ending, an enclosing scope ending, the entity it is tied to despawning.

A scope ends when its run is over, when work in it raises, when it is cancelled or released by tag, when the task waiting out its scopes.run is cancelled, and when what it is lived inside ends. Every task still running in it is cancelled and unwinds: the cancellation is raised at its wait, so a pcall or cleanup around that wait runs. The first error cancels the rest of the scope's work and is raised again by join (and so by scopes.run) as it was raised. While a scopes.run or a join waits for the scope, or s.onError observes it, the VM-wide task.onError is not told; an error in a scope nothing waits for or observes is reported as any task's error is. A scopes.run whose scope something else ended (its entity despawned, say) returns once the work it started is cancelled, and s:why() names what ended it.

The contexts

A resource reached through a handle outlives the call that made it. Every one records the context it came from, and the engine ends what a context left behind once that context's run is over.

Scope tagWhat it isWhat ends it
scope:<n>A task scopeIts run is over, work in it raises, it is cancelled, or what it is lived inside ends
entity:<id>An entityIt is despawned
scene:<guid>#<n>One load of a scene layerThe load ends: the layer is reloaded or unloaded, the scene is swapped, the mode flips
chunk:<identity>A moduleThe module runs again (a VFS write to its source), or is dropped from the require cache (vfs.reload)
component:<instanceId>One component instanceThat instance is destroyed: entity despawned, scene cleared, component removed
exec:<chunk>A chunk submitted for a single run, such as an execute callNaming the scope in scopes.release

A registration made with no Luau frame above it is the engine's own and follows no context. It carries no scope, is not listed, and no release reaches it.

Which context a registration belongs to

Innermost first, with one exception at the top:

  1. A module body being loaded owns what its own frame registers, even inside a component's awake. The body runs once per load and is shared by every instance that requires it, so it follows the module.
  2. A task scope owns everything registered by the work running in it.
  3. A component instance owns everything else registered while one of its lifecycle hooks is on the stack, including registrations made by helper modules it calls. Launch a job in component context and it goes down with the component.
  4. Otherwise the calling chunk owns it.

scopes.current() answers this for the code calling it.

Reaching a resource nobody named

A chunk registers something and the handle never reaches a variable: it raised first, or it simply ended. The resource is live and no name in Luau refers to it. Tasks an execute call started are listed under that call's exec: scope.

for _, r in scopes.list() do
    if not r.endsAtASeam then
        print(r.kind, r.handle, r.scope, r.state, r.detail)
    end
end

scopes.release("exec:__exec_12")

endsAtASeam is false exactly for the resources a seam will never reach, which is the set worth reading first. state is where a resource stands in its subsystem's words: a task's task.status, a job's phase.

An execute call runs as a task of its own, listed under its exec: scope while it runs, so releasing that scope from the same call cancels the call too. Release a call's scope from a later call.

Explicit release

destroy / cancel / close end a resource the moment content calls them, and that is unchanged. Scoping is what happens to the resources nobody ended.