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