Skip to content

Commit 4e26b91

Browse files
committed
Typed and new signal module
1 parent cc5e2ff commit 4e26b91

File tree

2 files changed

+405
-0
lines changed

2 files changed

+405
-0
lines changed

src/QubitNoise/Signal.lua

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
--------------------------------------------------------------------------------
2+
-- Batched Yield-Safe Signal Implementation --
3+
-- This is a Signal class which has effectively identical behavior to a --
4+
-- normal RBXScriptSignal, with the only difference being a couple extra --
5+
-- stack frames at the bottom of the stack trace when an error is thrown. --
6+
-- This implementation caches runner coroutines, so the ability to yield in --
7+
-- the signal handlers comes at minimal extra cost over a naive signal --
8+
-- implementation that either always or never spawns a thread. --
9+
-- --
10+
-- API: --
11+
-- local Signal = require(THIS MODULE) --
12+
-- local sig = Signal.new() --
13+
-- local connection = sig:Connect(function(arg1, arg2, ...) ... end) --
14+
-- sig:Fire(arg1, arg2, ...) --
15+
-- connection:Disconnect() --
16+
-- sig:DisconnectAll() --
17+
-- local arg1, arg2, ... = sig:Wait() --
18+
-- --
19+
-- Licence: --
20+
-- Licenced under the MIT licence. --
21+
-- --
22+
-- Authors: --
23+
-- stravant - July 31st, 2021 - Created the file. --
24+
--------------------------------------------------------------------------------
25+
26+
-- The currently idle thread to run the next handler on
27+
local freeRunnerThread = nil
28+
29+
-- Function which acquires the currently idle handler runner thread, runs the
30+
-- function fn on it, and then releases the thread, returning it to being the
31+
-- currently idle one.
32+
-- If there was a currently idle runner thread already, that's okay, that old
33+
-- one will just get thrown and eventually GCed.
34+
local function acquireRunnerThreadAndCallEventHandler(fn, ...)
35+
local acquiredRunnerThread = freeRunnerThread
36+
freeRunnerThread = nil
37+
fn(...)
38+
-- The handler finished running, this runner thread is free again.
39+
freeRunnerThread = acquiredRunnerThread
40+
end
41+
42+
-- Coroutine runner that we create coroutines of. The coroutine can be
43+
-- repeatedly resumed with functions to run followed by the argument to run
44+
-- them with.
45+
local function runEventHandlerInFreeThread(...)
46+
acquireRunnerThreadAndCallEventHandler(...)
47+
while true do
48+
acquireRunnerThreadAndCallEventHandler(coroutine.yield())
49+
end
50+
end
51+
52+
-- Connection class
53+
local Connection = {}
54+
Connection.__index = Connection
55+
56+
function Connection.new(signal, fn)
57+
return setmetatable({
58+
_connected = true,
59+
_signal = signal,
60+
_fn = fn,
61+
_next = false,
62+
}, Connection)
63+
end
64+
65+
function Connection:Disconnect()
66+
assert(self._connected, "Can't disconnect a connection twice.", 2)
67+
self._connected = false
68+
69+
-- Unhook the node, but DON'T clear it. That way any fire calls that are
70+
-- currently sitting on this node will be able to iterate forwards off of
71+
-- it, but any subsequent fire calls will not hit it, and it will be GCed
72+
-- when no more fire calls are sitting on it.
73+
if self._signal._handlerListHead == self then
74+
self._signal._handlerListHead = self._next
75+
else
76+
local prev = self._signal._handlerListHead
77+
while prev and prev._next ~= self do
78+
prev = prev._next
79+
end
80+
if prev then
81+
prev._next = self._next
82+
end
83+
end
84+
end
85+
86+
-- Make Connection strict
87+
setmetatable(Connection, {
88+
__index = function(tb, key)
89+
error(("Attempt to get Connection::%s (not a valid member)"):format(tostring(key)), 2)
90+
end,
91+
__newindex = function(tb, key, value)
92+
error(("Attempt to set Connection::%s (not a valid member)"):format(tostring(key)), 2)
93+
end
94+
})
95+
96+
-- Signal class
97+
local Signal = {}
98+
Signal.__index = Signal
99+
100+
function Signal.new()
101+
return setmetatable({
102+
_handlerListHead = false,
103+
}, Signal)
104+
end
105+
106+
function Signal:Connect(fn)
107+
local connection = Connection.new(self, fn)
108+
if self._handlerListHead then
109+
connection._next = self._handlerListHead
110+
self._handlerListHead = connection
111+
else
112+
self._handlerListHead = connection
113+
end
114+
return connection
115+
end
116+
117+
-- Disconnect all handlers. Since we use a linked list it suffices to clear the
118+
-- reference to the head handler.
119+
function Signal:DisconnectAll()
120+
self._handlerListHead = false
121+
end
122+
123+
-- Signal:Fire(...) implemented by running the handler functions on the
124+
-- coRunnerThread, and any time the resulting thread yielded without returning
125+
-- to us, that means that it yielded to the Roblox scheduler and has been taken
126+
-- over by Roblox scheduling, meaning we have to make a new coroutine runner.
127+
function Signal:Fire(...)
128+
local item = self._handlerListHead
129+
while item do
130+
if item._connected then
131+
if not freeRunnerThread then
132+
freeRunnerThread = coroutine.create(runEventHandlerInFreeThread)
133+
end
134+
task.spawn(freeRunnerThread, item._fn, ...)
135+
end
136+
item = item._next
137+
end
138+
end
139+
140+
-- Implement Signal:Wait() in terms of a temporary connection using
141+
-- a Signal:Connect() which disconnects itself.
142+
function Signal:Wait()
143+
local waitingCoroutine = coroutine.running()
144+
local cn;
145+
cn = self:Connect(function(...)
146+
cn:Disconnect()
147+
task.spawn(waitingCoroutine, ...)
148+
end)
149+
return coroutine.yield()
150+
end
151+
152+
-- Make signal strict
153+
setmetatable(Signal, {
154+
__index = function(tb, key)
155+
error(("Attempt to get Signal::%s (not a valid member)"):format(tostring(key)), 2)
156+
end,
157+
__newindex = function(tb, key, value)
158+
error(("Attempt to set Signal::%s (not a valid member)"):format(tostring(key)), 2)
159+
end
160+
})
161+
162+
return Signal

0 commit comments

Comments
 (0)