Skip to content

Signal

Per-object events with O(1) disconnect and a reused handler thread, so firing allocates no coroutines in the steady state.

Realm
Shared
Require
ReplicatedStorage.ChloeKernel.IPC.Signal
Since
v0.1.0
Source
IPC/Signal.luau

A Signal is an event owned by one object: Scheduler.OnError, Process.OnExit, Session.OnEnd, a replica’s change event. Where a Signal belongs to one owner, the Bus is a topic space shared by every system, and the Bus is built on Signals. Both rest on thread reuse: firing N handlers allocates no coroutines in the steady state, so events are cheap enough to be the default way systems talk. A Signal cannot cancel anything. It announces that something happened; asking permission belongs to Hooks.

A Signal is a doubly linked list of connections:

  • connect puts a new node at the head. O(1), no arrays, no sorting.
  • disconnect clears the node’s Connected flag and unlinks it by joining its neighbours. O(1): before 0.9.0 it walked the list to find the previous node. The node keeps its Next pointer, so a fire that is standing on it continues to the rest of the list.
  • fire walks from the head and runs each connected handler with task.spawn on a shared runner thread. The runner parks itself in a module-level slot between handlers. While handlers do not yield, the same thread is reused forever and firing allocates nothing.
  • A handler that yields keeps the runner; the next handler or fire creates a fresh one. Yielding handlers work, at the cost of one coroutine each time.
  • A handler that errors is reported by the engine on the handler’s thread. The fire loop, the other handlers and the caller of fire are unaffected; the lost runner is replaced on the next fire.

Two mid-fire rules follow from the list walk:

  • A handler connected during a fire does not run in that fire. It goes in at the head, which the walk has passed.
  • A handler disconnected during a fire is skipped if the walk has not reached it yet.

If handler A disconnects itself and handler B during a fire, the walk still reaches C, and the next fire runs only C. The core regression runner checks this case and times 20,000 disconnects.

local Signal = require(game:GetService("ReplicatedStorage").ChloeKernel.IPC.Signal)
local Damaged = Signal.new()
local Conn = Damaged:connect(function(amount: number, source: string)
print("took", amount, "from", source)
end)
Damaged:once(function(amount: number)
print("first hit:", amount)
end)
task.spawn(function()
local Amount = Damaged:wait() -- parks this thread until the next fire
print("first damage after spawn:", Amount)
end)
Damaged:fire(25, "Falling")
Conn:disconnect()
Damaged:destroy()

once disconnects before it calls the handler, so a handler that fires the same signal again cannot run itself twice.

wait() parks the calling thread and resumes it with the fired values. If the waiting thread was cancelled in the meantime (task.cancel, or a process killed during the wait), the fire does not resume it. Before 0.9.0 it resumed the cancelled thread. destroy() resumes every thread still parked in wait(), with no values, instead of leaking it suspended.

Member Description
Signal.new(): Signal An empty signal.
signal:connect(fn): Connection Adds a handler at the head. Handlers receive exactly the arguments passed to fire.
signal:once(fn): Connection A connection that disconnects itself before its handler runs.
signal:wait(): ...any Yields the calling thread until the next fire and returns the fired values. A cancelled waiter is not resumed.
signal:fire(...) Runs every connected handler on the reused thread.
signal:destroy() Disconnects everything, resumes parked waiters and empties the list.
connection.Connected false after disconnect.
connection:disconnect() O(1) unlink. Idempotent.

From the kernel benchmarks (Bench.runAll on the hot paths):

Operation Throughput p50 per op
Signal:fire (1 handler) 4.5M ops/s 203 ns

The figure includes the thread-reuse path, with no coroutine created per handler. The Bus fires one Signal per matching topic or family, so publish has the same cost model plus one wildcard-cache lookup.

Question Primitive Why
“This object’s thing happened” Signal Owned by the object, easy to find on it, the cheapest of the three.

For “something happened, whoever cares” or “may this happen?”, see Bus and Hooks. The full comparison is on the Bus page.

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