Skip to content

API Signal

iKrypto edited this page May 1, 2026 · 8 revisions

API: Signal

Signal is the framework's lightweight event primitive: connect handlers, fire payloads, wait for the next occurrence, and destroy all listeners without using BindableEvent.

Important

Docs audit note (2026-05): This page was re-audited against FSM/Orchestrator/Core/Signal.luau.


🎯 Overview

The implementation is intentionally tiny:

  • handlers live in self._handlers
  • Connect() stores connection tables with _handler, _signal, and Disconnect()
  • Fire(...) snapshots the current connection array with table.move(...)
  • each handler runs in its own task.spawn(...)
  • Wait() is implemented with Once(...) + coroutine.yield()

That gives you Roblox-signal-like ergonomics with explicit lifecycle control.


πŸ”Œ Connection shape

Signal does not export a separate module for connections, but Connect() / Once() return objects with this effective shape:

type Connection = {
    _handler: (...any) -> (),
    _signal: any,
    Disconnect: (self: Connection) -> (),
}

Disconnect():

  • looks up itself in signal._handlers
  • removes that entry if found
  • sets conn._handler = nil
  • sets conn._signal = nil

Calling Disconnect() twice is safe.


πŸ”§ API reference

Signal.new() -> Signal

Creates a new signal with an empty _handlers array.

local sig = Signal.new()

Signal:Connect(handler: (...any) -> ()) -> Connection

Registers a persistent handler.

Side effects

  • appends a new connection table to _handlers

Edge cases

  • the same function can be connected multiple times; each call returns an independent connection
  • later connections are stored later in the array, but Fire() iterates the snapshot backward
local conn = sig:Connect(function(value)
    print("received", value)
end)

Signal:Once(handler: (...any) -> ()) -> Connection

Registers a handler that disconnects itself before invoking the wrapped callback.

Behavior

  • internally calls Connect(function(...) connection:Disconnect(); handler(...) end)
  • fires at most once unless you register a new Once() handler later
sig:Once(function(state)
    print("first transition only", state)
end)

Signal:Fire(...: any) -> ()

Invokes the current listeners asynchronously.

Implementation details

  • snapshots self._handlers with table.move
  • iterates that snapshot from back to front
  • only spawns handlers whose conn._handler is still non-nil

Why the snapshot matters

  • disconnecting a listener during a fire does not corrupt iteration
  • if an earlier handler disconnects a later connection, that later snapshot entry now has _handler = nil, so it is skipped
  • Destroy() clears self._handlers, but handlers already captured in the snapshot may still run because Destroy() does not nil _handler
sig:Fire(42, "hello")

Signal:Wait() -> ...any

Yields until the next fire, then returns that payload.

Behavior

  • captures coroutine.running()
  • registers a Once() handler that resumes the waiting thread via task.spawn(thread, ...)
  • returns coroutine.yield()

Edge cases

  • there is no built-in timeout
  • only future fires count; earlier fires are not queued
local a, b = sig:Wait()

Signal:Destroy() -> ()

Clears the signal's live listener list.

Side effects

  • sets conn._signal = nil for every currently registered connection
  • table.clear(self._handlers)

Important

  • existing connection objects become inert because _signal is gone
  • the signal table itself still exists and can be reused with future Connect() calls
sig:Destroy()

πŸ“‹ Method inventory

Method Signature Returns Notes
new () -> Signal Signal creates empty handler list
Connect ((...any) -> ()) -> Connection Connection persistent listener
Once ((...any) -> ()) -> Connection Connection auto-disconnects before callback
Fire (...any) -> () () snapshots listener array and spawns each handler
Wait () -> ...any ...any waits for next fire only
Destroy () -> () () clears current live listeners

πŸš€ Real examples

Example 1: Managed subscription on an entity

local conn = self.StateUpdated:Connect(function(changes)
    if changes.Health then
        updateHealthBar(changes.Health)
    end
end)
self:Manage(conn)

Example 2: One-shot initialization gate

self:Manage(self.Destroyed:Once(function()
    print(self.Name, "destroyed exactly once")
end))

Example 3: Wait with an explicit timeout wrapper

local function waitWithTimeout(signal, timeoutSeconds)
    local done = false
    local payload = nil
    signal:Once(function(...)
        done = true
        payload = table.pack(...)
    end)

    local deadline = os.clock() + timeoutSeconds
    while not done and os.clock() < deadline do
        task.wait()
    end

    return done, payload
end

⚠️ Edge cases and pitfalls

  1. Wait() can leak suspended threads. Always wrap it when the signal may never fire.

  2. Handlers do not run inline. task.spawn(...) means you cannot rely on synchronous ordering between listeners.

  3. Recursive self-fire is still possible. Snapshotting protects iteration, not logic.

  4. Destroy() clears only currently registered listeners. A fire already in progress may still finish spawning callbacks captured in its snapshot.


πŸ”— See also

🏠 Home


πŸš€ Getting Started


πŸ—οΈ Architecture


πŸ”§ API Reference


πŸ“š Guides


βš™οΈ Advanced


πŸ› οΈ Development


πŸ“– Reference

Clone this wiki locally