-- ----------------------------------------------------------------------------- -- Batched Yield-Safe Signal Implementation -- -- This is a Signal class which has effectively identical behavior to a -- -- normal RBXScriptSignal, with the only difference being a couple extra -- -- stack frames at the bottom of the stack trace when an error is thrown. -- -- This implementation caches runner coroutines, so the ability to yield in -- -- the signal handlers comes at minimal extra cost over a naive signal -- -- implementation that either always or never spawns a thread. -- -- -- -- API: -- -- local Signal = require(THIS MODULE) -- -- local sig = Signal.new() -- -- local connection = sig:Connect(function(arg1, arg2, ...) ... end) -- -- sig:Fire(arg1, arg2, ...) -- -- connection:Disconnect() -- -- sig:DisconnectAll() -- -- local arg1, arg2, ... = sig:Wait() -- -- -- -- License: -- -- Licensed under the MIT license. -- -- -- -- Authors: -- -- stravant - July 31st, 2021 - Created the file. -- -- sleitnick - August 3rd, 2021 - Modified for Knit. -- -- ----------------------------------------------------------------------------- -- Signal types export type Connection = { Disconnect: (self: Connection) -> (), Destroy: (self: Connection) -> (), Connected: boolean, } export type Signal = { Fire: (self: Signal, T...) -> (), FireDeferred: (self: Signal, T...) -> (), Connect: (self: Signal, fn: (T...) -> ()) -> Connection, Once: (self: Signal, fn: (T...) -> ()) -> Connection, DisconnectAll: (self: Signal) -> (), GetConnections: (self: Signal) -> { Connection }, Destroy: (self: Signal) -> (), Wait: (self: Signal) -> T..., } -- The currently idle thread to run the next handler on local freeRunnerThread = nil -- Function which acquires the currently idle handler runner thread, runs the -- function fn on it, and then releases the thread, returning it to being the -- currently idle one. -- If there was a currently idle runner thread already, that's okay, that old -- one will just get thrown and eventually GCed. local function acquireRunnerThreadAndCallEventHandler(fn, ...) local acquiredRunnerThread = freeRunnerThread freeRunnerThread = nil fn(...) -- The handler finished running, this runner thread is free again. freeRunnerThread = acquiredRunnerThread end -- Coroutine runner that we create coroutines of. The coroutine can be -- repeatedly resumed with functions to run followed by the argument to run -- them with. local function runEventHandlerInFreeThread(...) acquireRunnerThreadAndCallEventHandler(...) while true do acquireRunnerThreadAndCallEventHandler(coroutine.yield()) end end --[=[ @within Signal @interface SignalConnection .Connected boolean .Disconnect (SignalConnection) -> () Represents a connection to a signal. ```lua local connection = signal:Connect(function() end) print(connection.Connected) --> true connection:Disconnect() print(connection.Connected) --> false ``` ]=] -- Connection class local Connection = {} Connection.__index = Connection function Connection.new(signal, fn) return setmetatable({ Connected = true, _signal = signal, _fn = fn, _next = false, }, Connection) end function Connection:Disconnect() if not self.Connected then return end self.Connected = false -- Unhook the node, but DON'T clear it. That way any fire calls that are -- currently sitting on this node will be able to iterate forwards off of -- it, but any subsequent fire calls will not hit it, and it will be GCed -- when no more fire calls are sitting on it. if self._signal._handlerListHead == self then self._signal._handlerListHead = self._next else local prev = self._signal._handlerListHead while prev and prev._next ~= self do prev = prev._next end if prev then prev._next = self._next end end end Connection.Destroy = Connection.Disconnect -- Make Connection strict setmetatable(Connection, { __index = function(_tb, key) error(("Attempt to get Connection::%s (not a valid member)"):format(tostring(key)), 2) end, __newindex = function(_tb, key, _value) error(("Attempt to set Connection::%s (not a valid member)"):format(tostring(key)), 2) end, }) --[=[ @within Signal @type ConnectionFn (...any) -> () A function connected to a signal. ]=] --[=[ @class Signal Signals allow events to be dispatched and handled. For example: ```lua local signal = Signal.new() signal:Connect(function(msg) print("Got message:", msg) end) signal:Fire("Hello world!") ``` ]=] local Signal = {} Signal.__index = Signal --[=[ Constructs a new Signal @return Signal ]=] function Signal.new(): Signal local self = setmetatable({ _handlerListHead = false, _proxyHandler = nil, }, Signal) return self end --[=[ Constructs a new Signal that wraps around an RBXScriptSignal. @param rbxScriptSignal RBXScriptSignal -- Existing RBXScriptSignal to wrap @return Signal For example: ```lua local signal = Signal.Wrap(workspace.ChildAdded) signal:Connect(function(part) print(part.Name .. " added") end) Instance.new("Part").Parent = workspace ``` ]=] function Signal.Wrap(rbxScriptSignal: RBXScriptSignal): Signal assert( typeof(rbxScriptSignal) == "RBXScriptSignal", "Argument #1 to Signal.Wrap must be a RBXScriptSignal; got " .. typeof(rbxScriptSignal) ) local signal = Signal.new() signal._proxyHandler = rbxScriptSignal:Connect(function(...) signal:Fire(...) end) return signal end --[=[ Checks if the given object is a Signal. @param obj any -- Object to check @return boolean -- `true` if the object is a Signal. ]=] function Signal.Is(obj: any): boolean return type(obj) == "table" and getmetatable(obj) == Signal end --[=[ @param fn ConnectionFn @return SignalConnection Connects a function to the signal, which will be called anytime the signal is fired. ```lua signal:Connect(function(msg, num) print(msg, num) end) signal:Fire("Hello", 25) ``` ]=] function Signal:Connect(fn) local connection = Connection.new(self, fn) if self._handlerListHead then connection._next = self._handlerListHead self._handlerListHead = connection else self._handlerListHead = connection end return connection end --[=[ @deprecated v1.3.0 -- Use `Signal:Once` instead. @param fn ConnectionFn @return SignalConnection ]=] function Signal:ConnectOnce(fn) return self:Once(fn) end --[=[ @param fn ConnectionFn @return SignalConnection Connects a function to the signal, which will be called the next time the signal fires. Once the connection is triggered, it will disconnect itself. ```lua signal:Once(function(msg, num) print(msg, num) end) signal:Fire("Hello", 25) signal:Fire("This message will not go through", 10) ``` ]=] function Signal:Once(fn) local connection local done = false connection = self:Connect(function(...) if done then return end done = true connection:Disconnect() fn(...) end) return connection end function Signal:GetConnections() local items = {} local item = self._handlerListHead while item do table.insert(items, item) item = item._next end return items end -- Disconnect all handlers. Since we use a linked list it suffices to clear the -- reference to the head handler. --[=[ Disconnects all connections from the signal. ```lua signal:DisconnectAll() ``` ]=] function Signal:DisconnectAll() local item = self._handlerListHead while item do item.Connected = false item = item._next end self._handlerListHead = false end -- Signal:Fire(...) implemented by running the handler functions on the -- coRunnerThread, and any time the resulting thread yielded without returning -- to us, that means that it yielded to the Roblox scheduler and has been taken -- over by Roblox scheduling, meaning we have to make a new coroutine runner. --[=[ @param ... any Fire the signal, which will call all of the connected functions with the given arguments. ```lua signal:Fire("Hello") -- Any number of arguments can be fired: signal:Fire("Hello", 32, {Test = "Test"}, true) ``` ]=] function Signal:Fire(...) local item = self._handlerListHead while item do if item.Connected then if not freeRunnerThread then freeRunnerThread = coroutine.create(runEventHandlerInFreeThread) end task.spawn(freeRunnerThread, item._fn, ...) end item = item._next end end --[=[ @param ... any Same as `Fire`, but uses `task.defer` internally & doesn't take advantage of thread reuse. ```lua signal:FireDeferred("Hello") ``` ]=] function Signal:FireDeferred(...) local item = self._handlerListHead while item do task.defer(item._fn, ...) item = item._next end end --[=[ @return ... any @yields Yields the current thread until the signal is fired, and returns the arguments fired from the signal. Yielding the current thread is not always desirable. If the desire is to only capture the next event fired, using `Once` might be a better solution. ```lua task.spawn(function() local msg, num = signal:Wait() print(msg, num) --> "Hello", 32 end) signal:Fire("Hello", 32) ``` ]=] function Signal:Wait() local waitingCoroutine = coroutine.running() local connection local done = false connection = self:Connect(function(...) if done then return end done = true connection:Disconnect() task.spawn(waitingCoroutine, ...) end) return coroutine.yield() end --[=[ Cleans up the signal. Technically, this is only necessary if the signal is created using `Signal.Wrap`. Connections should be properly GC'd once the signal is no longer referenced anywhere. However, it is still good practice to include ways to strictly clean up resources. Calling `Destroy` on a signal will also disconnect all connections immediately. ```lua signal:Destroy() ``` ]=] function Signal:Destroy() self:DisconnectAll() local proxyHandler = rawget(self, "_proxyHandler") if proxyHandler then proxyHandler:Disconnect() end end -- Make signal strict setmetatable(Signal, { __index = function(_tb, key) error(("Attempt to get Signal::%s (not a valid member)"):format(tostring(key)), 2) end, __newindex = function(_tb, key, _value) error(("Attempt to set Signal::%s (not a valid member)"):format(tostring(key)), 2) end, }) return { new = Signal.new, Wrap = Signal.Wrap, Is = Signal.Is, }