Skip to content

Processes

A process is a coroutine with an operating-system posture: a PID, a lifecycle state machine, and an exit signal, stepped by the Scheduler instead of running free. The defining design decision is Process.yield(): a process gives the frame back voluntarily and resumes on a later scheduler step, under the frame budget, at its priority — it never blocks the frame the way a while true do task.wait() end loop floats outside any budget. Use a process for anything that runs across frames and someone might need to pause or kill: escort NPCs, cutscenes, multi-stage crafting, drain-over-time effects.

Created ──start()──▶ Running ◀──resume()── Suspended
│ ▲ ▲
│ └────suspend()───────┘
┌──────────────┼──────────────┐
▼ ▼ ▼
Completed Crashed Killed
(returned) (errored) (kill())

Six states, exposed as Process.State.Created through Process.State.Killed. The three bottom states are terminal: once a process completes, crashes, or is killed, it deregisters and kill()/suspend()/resume() become no-ops. start() on anything but Created errors process NAME already started.

start(...) wraps your function in a coroutine and enqueues one step task on the scheduler at the process’s priority. Each step resumes the coroutine once:

  • The function returns → state Completed, the return value lands in .Result.
  • The function errors → state Crashed, .Result holds the message with a full traceback (debug.traceback against the coroutine, captured before the stack unwinds).
  • The function called Process.yield() → the step re-enqueues itself for the next scheduler step, and the loop continues.

So a yielding process runs at most one slice per scheduler step, interleaved with every other task under the frame budget — a hundred processes at Normal priority share the frame instead of each claiming one.

Two allocation details matter at fleet scale, both pinned by spec:

  • One stable closure per process. The step function is created once at start(), not per resume. A fresh closure per frame would miss the profiler’s identity cache and allocate per process per frame.
  • One reusable queue entry. Steps re-enqueue by reference (scheduleEntry) instead of schedule(), which would allocate an entry table and a handle table per process per frame. A StepQueued flag guarantees the entry is never queued twice at once.

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 the caller dropped without starting is collectable; terminal processes deregister explicitly. PIDs are a monotonic counter — never reused within a VM.

Spawn through the kernel so the process lands on the kernel scheduler:

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Process = require(ReplicatedStorage.ChloeKernel.Process)
local Kernel = require(ReplicatedStorage.ChloeKernel).current()
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 function

Pause and kill from control code:

Mover:suspend() -- stops stepping; coroutine and state are kept
Mover:resume() -- picks up exactly where it yielded
Mover:kill("owner released") -- closes the coroutine, fires OnExit("Killed", "owner released")

Look a process up by PID:

local Found = Process.get(Mover.Pid)
print(Found and Found.State, Process.count(), "processes alive")
Member Description
kernel:spawnProcess(fn, options?): Process options = { Name: string?, Priority: number? }. Binds the process to the kernel scheduler — prefer this.
Process.new(fn, options): Process options = { Name: string?, Scheduler, Priority: number? }. Direct construction for tests or custom schedulers.
Process.get(pid: number): Process? Registry lookup; nil once the process reaches a terminal state (or is collected).
Process.count(): number Live registry size.
Process.yield() Call inside a process: suspends until the next scheduler step at the process’s priority.
Process.State The state-name table: Created, Running, Suspended, Completed, Crashed, Killed.

Defaults: Name falls back to Process_<pid>; Priority falls back to Priority.Normal (3).

Member Description
proc:start(...): Process Creates the coroutine, passes ... to the function, enqueues the first step. Errors unless Created. Returns itself for chaining.
proc:suspend() RunningSuspended; stops stepping, keeps everything. No-op otherwise.
proc:resume() SuspendedRunning; re-enqueues a step (guarded — never double-queues). No-op otherwise.
proc:kill(reason: string?) Any live state → Killed. Closes the coroutine, sets .Result to reason (default "killed"), fires OnExit. No-op on terminal states.
proc.Pid Unique per VM, monotonic.
proc.Name Profiler and debugging name.
proc.State Current state string — compare against Process.State.*.
proc.Priority Scheduler priority for every step.
proc.Result Return value (Completed), traceback string (Crashed), or kill reason (Killed).
proc.OnExit Signal fired once with (finalState, result) on any terminal transition.

A crash is contained: the error never reaches the scheduler’s error signal or the frame — it becomes state. .Result carries debug.traceback(thread, message) taken against the dead coroutine before anything else touches it, so the trace points at the error site inside your function, not at the scheduler’s resume:

local Proc = Kernel:spawnProcess(function()
error("kaboom")
end, { Name = "Doomed" })
Proc.OnExit:connect(function(state, result)
-- state == "Crashed", result contains "kaboom" plus the traceback
end)
Proc:start()

Supervision is a subscription, not a wrapper — attach OnExit before start() and restart, log, or escalate on Crashed as policy dictates.

The state machine governs stepping, and the source is careful about the races:

  • Suspend keeps the queued step. suspend() only flips state; a step already in the scheduler queue drains as a no-op (it checks state before resuming). resume() checks the StepQueued flag before enqueueing — resume-before-the-old-step-drains never produces two step chains. The spec pins this: suspend, resume, and step yield exactly one tick.
  • Kill closes the coroutine when it safely can. Killing a process that is suspended at a yield closes the coroutine immediately. Killing a process from inside itself (or mid-resume) cannot close a running coroutine — the pending step closes it on the next drain. Either way OnExit fires exactly once and the terminal state is Killed, never overwritten by a completion.
  • Suspended processes survive indefinitely. Suspension holds the coroutine, its upvalues, and its registry entry. A fleet of suspended NPC brains is cheap (no stepping cost) but not free (memory) — kill() what you will never resume.

Every step is attributed to the process by name. start() registers the step closure with the scheduler as Process <Name> (via Scheduler.setTaskOrigin), so the hot-task profiler — scheduler:topTasks(n), the HotTasks diagnostics attribute, and the debug panel — shows Process Mover with per-step time, call counts, and max, instead of melting every process into one script:line of the kernel’s resume closure:

for _, Hot in Kernel.Scheduler:topTasks(5) do
print(Hot.Key, Hot.Time, Hot.Calls, Hot.Max) -- e.g. "Process Mover 0.0031 180 0.0002"
end

Name your processes. Process_412 in a hot-task readout is a question; Process Mover is an answer.