Signal
A Signal is the kernel’s per-instance event primitive — a single event owned by one object: Scheduler.OnError, Process.OnExit, a replica’s OnChange. Where a Signal is scoped to one owner, the Bus is a shared topic space that lets whole systems react to each other without ever importing one another. Both rest on the same defining decision: thread reuse — firing N handlers allocates zero coroutines in the steady state, so events are cheap enough to be the default way systems talk. A Signal can’t cancel anything, either; announcing that something happened is its whole job, and asking permission belongs to Hooks.
Mental model
Section titled “Mental model”A Signal is a singly linked list of connections:
connectprepends a node at the head — O(1), no arrays, no sorting.disconnectflips the node’sConnectedflag and relinks the list — safe at any time, including mid-fire.firewalks the list and runs each connected handler viatask.spawnon a shared reusable thread. The runner thread parks itself in a module-level slot between fires; as long as handlers don’t yield, the same thread is reused forever and firing allocates nothing.- If a handler yields, it keeps the thread; the next handler (or next fire) creates a fresh runner. Yielding handlers work — they just cost one coroutine allocation each time.
- If a handler errors, the engine reports it on the handler’s thread; the fire loop, the other handlers, and the caller of
fireare unaffected. The reusable thread is lost and replaced on the next fire.
Two mid-fire semantics fall out of the list walk and are safe to rely on:
- A handler connected during a fire does not run in that fire (it prepends ahead of the walk’s start).
- A handler disconnected during a fire is skipped if it hasn’t run yet (the
Connectedflag is checked per node).
local Signal = require(game:GetService("ReplicatedStorage").ChloeKernel.IPC.Signal)
local Damaged = Signal.new()
local Conn = Damaged:connect(function(amount, source) updateHealthBar(amount, source)end)
Damaged:once(function(amount) playFirstHitTutorial()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 invoking its handler, so a handler that fires the same signal re-entrantly cannot run itself twice. destroy disconnects everything and — spec-verified — resumes any wait()-parked threads (with no values) instead of leaking them suspended.
| Member | Description |
|---|---|
Signal.new() → Signal |
|
signal:connect(fn) → Connection |
Prepends; handlers receive exactly the arguments passed to fire |
signal:once(fn) → Connection |
Self-disconnecting connection; disconnects before the handler runs |
signal:wait() → ...any |
Yields the calling thread until the next fire; resumes with the fired values |
signal:fire(...) |
Runs every connected handler on the reusable thread |
signal:destroy() |
Disconnects all, resumes parked waiters, clears the list |
connection.Connected |
false after disconnect |
connection:disconnect() |
O(1) flag flip plus list relink; idempotent |
Performance
Section titled “Performance”From the kernel benchmarks (Bench.runAll on the hot paths):
| Operation | Throughput | p50/op |
|---|---|---|
Signal:fire (1 handler) |
4.5M ops/s | 203ns |
Firing allocates nothing in the steady state — the number includes the thread-reuse path, not a coroutine spawned per handler. The Bus is built directly on Signal, so publish inherits the same cost model (~198ns) plus one wildcard-cache lookup.
Signal vs Bus vs Hooks
Section titled “Signal vs Bus vs Hooks”| Question | Primitive | Why |
|---|---|---|
| “This object’s thing happened” | Signal | Owned, discoverable on the object, cheapest, typed by convention at the source |
For “something happened, whoever cares” or “may this happen?”, see Bus and Hooks — the full three-way comparison lives on the Bus page.