Skip to content

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.

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.

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).

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.

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 scheduleEntry instead of schedule(), which would allocate an entry and a handle per process per frame. All processes share one frozen handle, and a StepQueued flag guarantees the entry is never queued twice.

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 = true
Crate.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)
end
end)
Mover:start(Crate, Vector3.new(0, 5, 0)) -- arguments flow into the body

A 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
end
end, { 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 kept
Patrol:resume() -- continues from where it gave the thread back
Patrol: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")
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).

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.

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 traceback
end)
Doomed:start()

Supervision is a subscription. Connect OnExit before start() and restart, log or escalate on Crashed as your policy needs.

  • Suspend keeps a queued step. suspend() only changes the state; a step already in the queue drains without resuming the body. resume() checks StepQueued first, 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 Killed at once. The coroutine cannot be closed while it runs, so the step that resumed it closes it when the body next yields. OnExit fires exactly once, and a later return never overwrites Killed.
  • 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.

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"
end

Only 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.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.