Skip to content

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.

A Signal is a singly linked list of connections:

  • connect prepends a node at the head — O(1), no arrays, no sorting.
  • disconnect flips the node’s Connected flag and relinks the list — safe at any time, including mid-fire.
  • fire walks the list and runs each connected handler via task.spawn on 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 fire are 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 Connected flag 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

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.

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.