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.
Mental model
Section titled “Mental model”The state machine
Section titled “The state machine”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.
How the scheduler steps a process
Section titled “How the scheduler steps a process”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,.Resultholds the message with a full traceback (debug.tracebackagainst 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 ofschedule(), which would allocate an entry table and a handle table per process per frame. AStepQueuedflag guarantees the entry is never queued twice at once.
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 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 = 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 functionPause and kill from control code:
Mover:suspend() -- stops stepping; coroutine and state are keptMover:resume() -- picks up exactly where it yieldedMover: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")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 — 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).
Instance
Section titled “Instance”| 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() |
Running → Suspended; stops stepping, keeps everything. No-op otherwise. |
proc:resume() |
Suspended → Running; 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. |
Crash handling
Section titled “Crash handling”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 tracebackend)Proc:start()Supervision is a subscription, not a wrapper — attach OnExit before start() and restart, log, or escalate on Crashed as policy dictates.
Suspend, resume, kill — the sharp edges
Section titled “Suspend, resume, kill — the sharp edges”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 theStepQueuedflag 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
OnExitfires exactly once and the terminal state isKilled, 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.
Profiler naming
Section titled “Profiler naming”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"endName your processes. Process_412 in a hot-task readout is a question; Process Mover is an answer.