Skip to content

v1.6.0 — Signals that never strand a thread

Back to all updates
v1.6.0August 20, 2026

Twill’s own Signal runs every listener and never leaves a waiting thread behind.

new, wrap, Connect, Once, Wait, Fire, DisconnectAll and Destroy keep their names and meanings, and the Signal.Signal<(value: any) -> ()> type is unchanged. The immediate-mode variants are removed:

BeforeAfter
signal:ConnectNow(f)signal:Connect(f)
signal:OnceNow(f)signal:Once(f)
signal:WaitNow()signal:Wait()
signal:FireNow(...)signal:Fire(...)
signal:DisconnectAllNow()signal:DisconnectAll()
signal:DestroyNow()signal:Destroy()
connection:DisconnectNow()connection:Disconnect()
connection:ReconnectNow()connection:Reconnect()
connection:Destroy()connection:Disconnect()
signal:GetConnections()signal:Count()
signal:CancelAllMutations()Remove the call

A thread parked in Wait is resumed by the next fire. If the signal was destroyed first, nothing ever resumed it: the thread stayed suspended for the rest of the session, invisible to any profiler and holding everything its stack referenced.

local ready = Signal.new()
task.spawn(function()
ready:Wait()
print("reached")
end)
ready:Destroy()

Twill.Signal wakes it. Destroy and DisconnectAll resume every parked thread and pass it nothing, so a Wait always ends. Wait(timeout) also gives up after a number of seconds.

Each listener runs inside its own xpcall. One that throws is reported through Log with its own traceback, and the rest still run:

signal:Connect(function() print("one") end)
signal:Connect(function() error("something went wrong") end)
signal:Connect(function() print("three") end)
signal:Fire()

This prints one, reports the failure, and prints three.

Mid-fire changes follow the engine’s rules

Section titled “Mid-fire changes follow the engine’s rules”
  • A listener connected while the signal is firing waits for the next firing.
  • A listener disconnected while the signal is firing is not called again.
  • Firing from inside a listener runs immediately, nested.

These match BindableEvent, so there is one set of rules to remember.

local died = Signal.new<<(who: Player, cause: string) -> ()>>()
died:Connect(function(who, cause) end) -- both typed and named
died:Fire(player, "fell") -- checked
died:Fire(player) -- a type error at the call

Measured in a place, best of five:

CaseTwill.SignalPrevious library
Fire 1 listener, 20000 times7.6 ms14.0 ms
Fire 100 listeners, 200 times2.0 ms0.9 ms

The common case is nearly twice as fast. The hundred-listener case pays for the per-listener xpcall.

  • A pooled dispatch thread cancelled from outside is checked before reuse.

  • A destroyed signal keeps its methods and raises an error that names what happened, instead of attempt to index nil.

  • Count, IsEmpty and IsDestroyed let a test assert that no listener outlived its owner:

    Scope.Close(player)
    assert(events.Died:Count() == 0, "a listener outlived the player")
  • ReplicatedStorage.Twill.Packages.Signal is removed. No third-party package is reachable from the root table any more.

Every change in this release is listed in the changelog.