task
The task namespace — 21 functions.
task/active
task.active() -> number
Get number of active (running + waiting + deferred) tasks.
Returns number — Count of active tasks
task/await
task.await(promise_id) -> any
Yield until a promise is resolved. Used with mcp_call and other promise-returning APIs.
Parameters
promise_idstring— Promise identifier returned by a promise-returning API.
Returns any — The resolved value.
task/background
task.background() -> boolean
Declare that the calling task belongs in the background. A caller driving it inline — use_tool, bash, execute — hands back the task's watchable handle on its next poll instead of waiting out an inline budget the work will outlast. Call it as soon as the operation knows its own scale, before the long stretch begins; the decision can be per-call, so a small scope stays inline and a large one is handed back. Returns false outside any scheduler task, where there is no handle to hand back. Completion still arrives the usual way: poll task.status(handle), read the watch file, or wait for the operation's own notice.
Returns boolean — True when the calling task was marked; false outside any task
See also: task/current · task/status
task/batched
task.batched(count, fn, batch_size?, on_complete?) -> number
Run fn(i) for i = 1..count, yielding to the next frame every batch_size iterations (default 100). Spawns a coroutine and returns its task handle immediately. Use for bulk spawn loops or mass mutations that would otherwise block the VM long enough to trip the watchdog.
Parameters
countnumber— Total number of iterations.fnfunction— Function called as fn(i) for each iteration.batch_sizenumber(optional) — Iterations per frame before yielding (default 100).on_completefunction(optional) — Optional callback invoked after the final iteration.
Returns number — Task handle (the spawned coroutine).
task/callerInfo
task.callerInfo(level?) -> string?
Return the formatted "<source>:<line>" of a Luau stack frame, or nil if the frame doesn't exist or has no source. level defaults to 1 (the immediate Lua caller); 2 skips one frame above, and so on. Used by task.loop to tag iteration-error warnings with the spawn site, and useful for any custom error-reporting wrapper since the stock debug.info is gated out of user scope. See gh#3416 / gh#3248.
Parameters
levelnumber?— Stack level — 1 (default) = immediate Lua caller, 2 = caller's caller, etc.
Returns string? — "source:line" string, or nil when unavailable
task/cancel
task.cancel(handle)
Cancel a running or waiting task. The coroutine will not resume. No-op on finished tasks.
Parameters
handlenumber— Task handle from spawn/defer/delay
task/collect
task.collect(handle?) -> boolean
Free a finished task handle (or all finished handles if no argument). Returns true if any were collected. Finished handles are auto-collected after ~5 seconds.
Parameters
handlenumber?— Optional task handle to collect; omit to collect all
Returns boolean — Whether any handles were collected
task/current
task.current() -> number?
The handle of the task the calling code runs in, or nil outside any scheduler task. Pair it with task.status to tell whether the owner of a long-running operation is still being advanced: a cancelled task stops mid-flight without unwinding, so state it claimed (a lock, an in-flight flag, a progress record) outlives it unless someone can ask about the owner. Record task.current() when you take such a claim, and let the next caller reap it when task.status(owner) reports a terminal state. Scoped to the calling VM.
Returns number? — Handle of the calling task, or nil when not running inside one
task/defer
task.defer(fn, ...args) -> number
Schedule fn to run at end of current frame. Returns a task handle that can be used with task.status/task.cancel.
Parameters
fnfunction— Function to run at end of frame
Returns number — Task handle
task/delay
task.delay(seconds, fn, ...args) -> number
Schedule fn to run after seconds of real time. Returns a task handle.
Parameters
secondsnumber— Seconds before fn runs.fnfunction— Function to run after the delay.
Returns number — Task handle for cancel/status.
task/inflight
task.inflight(label) -> number
Number of scheduler tasks with the given label that have been dispatched and not yet reached a terminal state (finished or cancelled). Component lifecycle hooks dispatch under their hook name — task.inflight("awake") == 0 means every awake() coroutine has settled. The join primitive for "loaded means loaded": scene-load completion polls it so 'done' includes the detached lifecycle work the spawn dispatched, not merely the entity rows.
Parameters
labelstring— Task label to count (e.g. "awake")
Returns number — Count of in-flight tasks carrying that label
task/loop
task.loop(fn, dt?) -> number
Spawn a self-healing per-frame loop. Each iteration runs fn(delta) where delta is the seconds elapsed since the previous iteration, so task.loop(function(delta) pos += speed * delta end) moves by real time; a no-argument function() end still works. An uncaught error inside fn is caught, surfaced to task.onError (if registered), logged via log.warn, and the loop continues on the next iteration. Use this for the common scene pattern of one long-lived coroutine that drives input/sim/UI per frame; a bad nil-deref on one entity no longer kills the entire loop. See gh#3248.
Parameters
fnfunction— Body to run every iteration, called asfn(delta)with the seconds elapsed since the previous iteration. Errors are caught — they do not kill the loop.dtnumber(optional) — Seconds to wait between iterations (default 0 = next frame). This is the loop interval, distinct from the per-iterationdeltapassed tofn.
Returns number — Task handle (cancel via task.cancel to stop the loop).
task/onError
task.onError(handler: ((err: string, source: string?, handle: number) -> ())?)
Register a per-VM error handler invoked when a coroutine spawned via task.spawn / task.defer / task.delay errors. The handler receives (err_message, source_name, handle) and is itself called under pcall — its own errors are caught and logged, never re-raised. Passing nil clears the handler. Use this to surface 'silent freeze' coroutine deaths to scene-side code (e.g. show a SCRIPT ERROR overlay, restart the loop with backoff, escalate to telemetry). See gh#3248.
Parameters
handlerfunction | nil— Function called as (err, source, handle) on coroutine error, or nil to clear
task/result
task.result(handle) -> any?
Get a FINISHED task's return value, decoded from its lossless JSON capture. Raises when the task is not finished — still running/waiting/deferred, failed, cancelled, or an unknown handle — naming the state, so a nil return means exactly one thing: the task finished and returned nil. Poll task.status(handle) until it reports 'finished' (or wait on the watch file a promoted call gives you), then read the result.
Parameters
handlenumber— Task handle from spawn/defer/delay
Returns any? — The finished task's return value; nil only when it returned nil. Raises otherwise.
task/spawn
task.spawn(fn, ...args) -> number
Spawn a new coroutine that runs fn with the given args. Returns a numeric task handle (not a thread) that can be passed to task.cancel/task.status.
Parameters
fnfunction— Function to run in a new coroutine
Returns number — Task handle (numeric ID) for cancel/status
task/spawnSystem
task.spawnSystem(fn, ...args) -> number
Like task.spawn, but the coroutine is forced system-owned: it is never gameplay-owned, so it keeps running while the editor pauses gameplay — even when started from a component's awake()/onEnable(). Use for system-level loops (input ticking, watchers, editor tooling) that must survive pause regardless of where they were launched. Nested spawns inherit non-ownership normally.
Parameters
fnfunction— Function to run in a new system-owned coroutine
Returns number — Task handle (numeric ID) for cancel/status
task/status
task.status(handle) -> string
Get task status: 'running' (executing), 'waiting' (task.wait), 'suspended' (await), 'deferred' (queued), 'finished' (completed), 'cancelled', or 'dead' (unknown handle / aged out of terminal history). A finished task that was auto-collected still reports its terminal status from the terminal-history cache (mirrors task.result), so missing the live finished flip doesn't lose the outcome.
Parameters
handlenumber— Task handle from spawn/defer/delay
Returns string — Task status: 'running' | 'waiting' | 'suspended' | 'deferred' | 'finished' | 'cancelled' | 'dead'
task/wait
task.wait(seconds?) -> number
Yield the current coroutine. With a duration, resumes after seconds of real time. With none — task.wait() — resumes on the NEXT frame: getFrame() reads exactly one higher on the other side of it, wherever in the frame the wait was armed. Either way it returns the elapsed seconds.
Parameters
secondsnumber(optional) — Seconds to wait. Omit (or 0) to wait exactly one frame.
Returns number — Actual elapsed seconds.
task/waitDrawnFrames
task.waitDrawnFrames(frames?) -> number
Yield the current coroutine until the renderer has DRAWN frames more frames (default: 1), and ask it to keep drawing for as long as the wait lasts. This is the wait to use before reading anything the renderer produced — a capture, an observation, a readback, a cost — because the engine runs a frame whether or not anything wants a picture of it, and a headless renderer declines the ones nothing is consuming. task.waitFrames(n) counts frames the ENGINE ran, which on an idle headless engine is many times the number it drew. renderer.drawnFrames() is the count this is deadlined on.
Parameters
framesnumber(optional) — Drawn frames to wait for. Default 1.
Returns number — Frames the renderer drew across the wait.
task/waitFrames
task.waitFrames(frames?) -> number
Yield the current coroutine until frames more frames have run (default: 1). Exactly that many: the deadline is the engine's own frame counter, the one getFrame() reads, so local a = getFrame() task.waitFrames(n) leaves getFrame() - a == n however long those frames took and wherever in the frame the wait was armed. Work that lands a fixed number of frames later is read by counting frames rather than seconds, and one yield covers the whole span, where the same wait spelled as repeated task.wait() calls costs a scheduler round-trip per frame. It counts frames the ENGINE ran; frames the renderer submitted is a separate count (renderer.framePacing().submittedFrames), lower whenever the renderer declines a frame nothing is consuming.
Parameters
framesnumber(optional) — Frames to wait. Default 1 (the next frame).
Returns number — Frames waited.
task/wake
task.wake(co: thread, ...: any) -> (boolean, string?)
Resume a suspended coroutine and keep the scheduler's ownership of what it yields next. Code that captures coroutine.running() and resumes it later — a signal, a component event — uses this so the resumed coroutine's task.wait still reaches the scheduler and its task keeps reporting status and result. A coroutine the scheduler is not holding parked is resumed directly, and one whose task was cancelled is refused. Returns true when the resume ran, or false and the reason it could not.
Parameters
cothread— The suspended coroutine to resume...any(optional) — Values the coroutine's yield returns
Returns boolean — Whether the coroutine was resumed