v1.6.0 — Signals that never strand a thread
Twill’s own Signal runs every listener and never leaves a waiting thread behind.
Before upgrading
Section titled “Before upgrading”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:
| Before | After |
|---|---|
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 waiting thread is never stranded
Section titled “A waiting thread is never stranded”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.
Every listener runs
Section titled “Every listener runs”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.
Typed arguments
Section titled “Typed arguments”local died = Signal.new<<(who: Player, cause: string) -> ()>>()
died:Connect(function(who, cause) end) -- both typed and nameddied:Fire(player, "fell") -- checkeddied:Fire(player) -- a type error at the callPerformance
Section titled “Performance”Measured in a place, best of five:
| Case | Twill.Signal | Previous library |
|---|---|---|
| Fire 1 listener, 20000 times | 7.6 ms | 14.0 ms |
| Fire 100 listeners, 200 times | 2.0 ms | 0.9 ms |
The common case is nearly twice as fast. The hundred-listener case pays for the
per-listener xpcall.
Other changes
Section titled “Other changes”-
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,IsEmptyandIsDestroyedlet 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.Signalis removed. No third-party package is reachable from the root table any more.
Full changelog
Section titled “Full changelog”Every change in this release is listed in the changelog.