Processes
Runs long-lived coroutines with a PID and a lifecycle, stepped by the Scheduler so they can be suspended, resumed and killed.
- Realm
- Shared
- Require
ReplicatedStorage.ChloeKernel .Process - Since
- v0.1.0
- Source
- Process.luau
A process is a coroutine with a PID, a lifecycle state machine and an exit signal, stepped by the
Scheduler instead of running free. The body gives the frame back with Process.yield() and
resumes on a later scheduler step, under the frame budget and at its priority, or parks on one scheduler deadline with
Process.sleep(seconds). A hundred processes share one frame budget. Use one for behavior that runs across frames and
that something may need to pause or kill: an escort NPC, a cutscene, a multi-stage craft, a drain-over-time effect.
Kernel boot provides kernel:spawnProcess, and a scope can own a process
through its kill method.
Mental model
Section titled “Mental model”The state machine
Section titled “The state machine”Created --start()--> Running <--resume()-- Suspended | \ ^ | `--suspend()------' | +-------------+-------------+ v v v Completed Crashed Killed (returned) (threw) (kill())Six states, exposed as Process.State.Created through Process.State.Killed. The bottom three are terminal: once a
process completes, crashes or is killed it leaves the registry, and suspend(), resume() and kill() do nothing.
start() on anything but Created errors process <Name> already started.
How a step works
Section titled “How a step works”start(...) creates the coroutine, which runs the body inside xpcall with debug.traceback, and queues one step at
the process’s priority. Each step resumes the coroutine once. What happens next depends on how the body handed the
thread back:
| The body | What follows |
|---|---|
| returns | Completed. The first return value lands in Result and OnExit fires. |
| throws | Crashed. Result holds the error with the traceback taken inside the body. |
calls Process.yield() |
The step queues the next step. The body resumes on a later scheduler step. |
calls Process.sleep(seconds) |
The process arms one Scheduler:after(seconds) at its priority and gets no steps until it fires. Then it queues a step. |
waits on an engine primitive (task.wait, WaitForChild, an *Async call, Signal:wait) |
The step marks the process engine-owned and queues nothing. The engine resumes the body when the call completes. |
A yielding process runs at most one slice per scheduler step, interleaved with every other task under the budget. A sleeping process costs nothing per frame: one heap entry and no queue traffic (0.9.0; SpellKit channels use it).
Engine waits
Section titled “Engine waits”Each step clears a Cooperative flag before it resumes the body, and Process.yield() and Process.sleep() set it. A
body that comes back without the flag yielded somewhere else, so the step leaves the thread to whatever will resume it.
When the engine resumes the body, it runs outside the scheduler until its next Process.yield() or Process.sleep(),
which hands it back.
The code between an engine wait and the next Process.yield() therefore runs on the engine’s resume, outside the frame
budget. A body that completes or throws in that stretch still finishes normally: the wrapper inside the coroutine
records the result and fires OnExit.
Before 0.9.0 the scheduler resumed an engine-waiting body early and the engine call returned nil.
Allocation
Section titled “Allocation”Two details keep a fleet of processes cheap:
- One step closure per process, created at
start(). A fresh closure per resume would miss the profiler’s identity cache and allocate per process per frame. - One reusable queue entry. Steps queue it by reference through
scheduleEntryinstead ofschedule(), which would allocate an entry and a handle per process per frame. All processes share one frozen handle, and aStepQueuedflag guarantees the entry is never queued twice.
The registry
Section titled “The registry”Every process registers by PID in a weak-valued table. Process.get(pid) finds live processes, but the registry never
keeps one alive: a Created process nobody started or kept is collectable, and terminal processes deregister. PIDs come
from a counter and are never reused within a VM.
kernel:spawnProcess also records the process in kernel.Processes (weak keys) and removes it when it exits.
kernel:shutdown() kills every process still in that set with the reason "kernel shutdown", and spawnProcess after
shutdown asserts kernel is shut down.
Spawn through the kernel so the process lands on the kernel scheduler and is killed at shutdown:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Kernel = require(ReplicatedStorage.ChloeKernel).current()local Process = require(ReplicatedStorage.ChloeKernel.Process)
local Crate = Instance.new("Part")Crate.Anchored = trueCrate.Position = Vector3.new(0, 50, 0)Crate.Parent = workspace
local Mover = Kernel:spawnProcess(function(part: BasePart, target: Vector3) while (part.Position - target).Magnitude > 0.5 do part.Position = part.Position:Lerp(target, 0.05) Process.yield() -- give the frame back; resume on a later step end part.Position = target return "arrived"end, { Name = "Mover", Priority = Kernel.Priority.Low })
Mover.OnExit:connect(function(state, result) if state == Process.State.Crashed then warn("mover crashed:", result) -- result carries the traceback else print("mover finished:", state, result) endend)
Mover:start(Crate, Vector3.new(0, 5, 0)) -- arguments flow into the bodyA patrol that waits on the engine and then on a scheduler deadline:
local Patrol = Kernel:spawnProcess(function(npc: Model, points: { Vector3 }) local Humanoid = npc:FindFirstChildOfClass("Humanoid") if not Humanoid then return "no humanoid" end while true do for _, Point in points do Humanoid:MoveTo(Point) Humanoid.MoveToFinished:Wait() -- engine wait: the engine resumes the body Process.sleep(2) -- hands the body back and parks it on one deadline end endend, { Name = "Patrol" })
Patrol:start(workspace.Guard, { Vector3.new(0, 0, 0), Vector3.new(40, 0, 0) })Pause and kill from control code:
Patrol:suspend() -- no more steps; the coroutine and its state are keptPatrol:resume() -- continues from where it gave the thread backPatrol:kill("guard dismissed") -- cancels the sleep or engine wait, fires OnExit("Killed", "guard dismissed")Look a process up by PID:
local Found = Process.get(Mover.Pid)print(Found and Found.State, Process.count(), "processes registered")API reference
Section titled “API reference”Constructors and statics
Section titled “Constructors and statics”| Member | Description |
|---|---|
kernel:spawnProcess(fn, options?): Process |
options = { Name: string?, Priority: number? }. Binds the process to the kernel scheduler and tracks it for shutdown. Prefer this. |
Process.new(fn, options): Process |
options = { Scheduler, Name: string?, Priority: number? }. Scheduler is required. For tests and custom schedulers. |
Process.get(pid: number): Process? |
Registry lookup. nil once the process is terminal or collected. |
Process.count(): number |
Registry size, including created processes not yet collected. |
Process.yield() |
Inside a process: resume on a later scheduler step. Outside a process it is a plain coroutine.yield(). |
Process.sleep(seconds: number) |
Inside a process: park until a scheduler deadline seconds from now. Asserts Process.sleep must be called inside a process and Process.sleep seconds must be a finite number >= 0. Added in 0.9.0. |
Process.State |
Created, Running, Suspended, Completed, Crashed, Killed. |
Defaults: Name is Process_<pid> and Priority is Normal (3).
Instance
Section titled “Instance”| Member | Description |
|---|---|
proc:start(...): Process |
Creates the coroutine, passes ... to the body and queues the first step. Errors unless Created. Returns the process. |
proc:suspend() |
Running to Suspended. No more steps; everything is kept. Does nothing in any other state. |
proc:resume() |
Suspended to Running. Queues a step unless a step is already queued, a sleep deadline is pending or the engine owns the thread. Does nothing in any other state. |
proc:kill(reason: string?) |
Any live state to Killed. Cancels a pending sleep deadline, closes a thread parked at Process.yield() or Process.sleep(), and cancels a thread in an engine wait with task.cancel so the engine’s pending resume is dropped. Result becomes reason (default "killed"). |
proc.Pid |
Unique per VM. |
proc.Name |
Profiler and debugging name. |
proc.State |
Current state string. Compare against Process.State. |
proc.Priority |
Scheduler priority for every step and for the sleep deadline. |
proc.Result |
Return value (Completed), error with traceback (Crashed) or kill reason (Killed). |
proc.OnExit |
Signal fired once with (finalState, result) on any terminal transition. |
Crash handling
Section titled “Crash handling”A crash never reaches Scheduler.OnError or the frame; it becomes state. The body runs inside xpcall with
debug.traceback as the handler, so Result holds the message and a traceback taken inside the body, pointing at the
error site:
local Doomed = Kernel:spawnProcess(function() error("kaboom")end, { Name = "Doomed" })
Doomed.OnExit:connect(function(state, result) -- state == "Crashed"; result contains "kaboom" and the tracebackend)Doomed:start()Supervision is a subscription. Connect OnExit before start() and restart, log or escalate on Crashed as your
policy needs.
Suspend, resume, kill: the sharp edges
Section titled “Suspend, resume, kill: the sharp edges”- Suspend keeps a queued step.
suspend()only changes the state; a step already in the queue drains without resuming the body.resume()checksStepQueuedfirst, so suspend and resume before the old step drains never start two step chains. - Suspend during a sleep: the deadline still fires but queues nothing for a suspended process, and
resume()queues the step. Resume during a sleep queues nothing; the deadline does. - Suspend during an engine wait: the engine still resumes the body, which runs until its next
Process.yield()and parks there.resume()then queues a step. Resume during an engine wait queues nothing, so the body is never resumed twice (fixed in 0.9.0). - Kill during an engine wait cancels the thread with
task.cancel, which drops the engine’s pending resume. Code after the wait never runs (fixed in 0.9.0). - Kill from inside the body ends the process as
Killedat once. The coroutine cannot be closed while it runs, so the step that resumed it closes it when the body next yields.OnExitfires exactly once, and a later return never overwritesKilled. - A suspended process holds its coroutine, upvalues and registry entry indefinitely. A fleet of suspended NPC brains
costs no stepping but does cost memory.
kill()what you will never resume.
Profiler naming
Section titled “Profiler naming”start() registers the step closure with the scheduler as Process <Name> through Scheduler.setTaskOrigin, so the
hot-task profiler (scheduler:topTasks(n), the HotTasks diagnostics attributes and the
debug panels) shows Process Mover with its time, calls and longest step, instead of folding
every process into one script:line inside the process module:
for _, Hot in Kernel.Scheduler:topTasks(5) do print(Hot.Key, Hot.Time, Hot.Calls, Hot.Max) -- "Process Mover 0.0031 180 0.0002"endOnly the stepped slices are attributed. Code that runs on an engine resume, between an engine wait and the next
Process.yield(), is outside the scheduler and outside the profile. Name your processes: Process_412 in a readout
tells you nothing.