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.
Mental model
Section titled “Mental model”A Signal is a doubly linked list of connections:
connectputs a new node at the head. O(1), no arrays, no sorting.disconnectclears the node’sConnectedflag 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 itsNextpointer, so a fire that is standing on it continues to the rest of the list.firewalks from the head and runs each connected handler withtask.spawnon 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
fireare 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.
API reference
Section titled “API reference”| 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. |
Performance
Section titled “Performance”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.
Signal, Bus or Hooks
Section titled “Signal, Bus or Hooks”| 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.