This is quite awesome, I recommend checking out the Signal Certifications Grader to aid in the development of your Signal & help you figure out the capabilities of it as an event-dispatcher.
So far, your signal will dethrone LemonSignal* as the best Class 2 Signal on Roblox as soon as it fixes one critical bug in the Scheduler Certification:
I noted this during development actually! I wasn’t sure whether I should fix it or leave it in as intended behavior, but now I will fix this as soon as possible.
I’m considering updating to meet this certification as well, but that will require a bit more work, and I’m unsure if I should, because:
Should connections that were disconnected during invocation really still be fired?
Deferred re-entrancy isn’t a standard feature of most signal libraries, would a reasonable developer expect a Fire() to be deferred if it’s called during invocation?
And a suggestion:
Maybe consider typechecking/developer ergonomics (which I believe would be a bigger selling point of signals than something like microsecond difference in performance or niche behavior), though I understand this would require manual effort as it can’t be scripted.
^ I’ll now add NamedSignal to the Guide as an official Class 2
Is there any special description you would like me to put for your signal?
^ It follows the arbitrary logic where “all existing connections upon :Fire() will run”, it’s much easier to artificially create immediate-behavior from deferred-only mutations, than it is to create deferred-behavior from immediate-only mutations.
-- Immediate-only mutations trying to do deferred mutations
local cn1, cn2
local fireId = 0 -- This is necessary to account for reentrant Fires
local queuedDisconnections = {}
cn1 = signal:Once(function()
-- Defer-disconnect cn2, mark the fireId to prevent cn2 from running in future reentrant Fires.
queuedDisconnections[cn2] = fireId
-- "Future" Reentrant Fire, it's safe to add/subtract from fireId ONLY if it's running on a serial CPU thread AND signal-Fires are synchronous (start running all connections before returning)
fireId += 1
signal:Fire()
fireId -= 1
end)
cn2 = signal:Connect(function()
-- Guard-clause to prevent it from running if already disconnected in a prior fire, but still allow it to run in the same Fire it was disconnected in (deferred disconnection)
local queuedId = queuedDisconnections[cn2]
if queuedId ~= nil and queuedId < fireId then return end
-- cn2 Code
print(fireId) -- 1
end)
fireId += 1
signal:Fire()
fireId -= 1
-- Process deferred disconnections
for cn, fireId in queuedDisconnections do
cn:Disconnect()
end
table.clear(queuedDisconnections)
--[[
OUTPUT:
1
]]
-- Deferred-only mutations trying to do immediate mutations
local cn1, cn2
local immediateDisconnections = {}
cn1 = signal:Once(function()
-- Immediate-disconnect cn2
cn2:Disconnect() -- Deferred disconnection
immediateDisconnections[cn2] = true -- Guard-clause value
-- Reentrant Fire, ASSUMING this also follows deferred behavior, this should start after all connections are run and mutations processed, but also before the "parent" Fire returns to maintain synchronicity.
signal:Fire()
end)
cn2 = signal:Connect(function()
-- Guard-clause to prevent it from running if immediately disconnected
if immediateDisconnections[cn2] then return end
-- cn2 Code
print("Failed to immediately disconnect!") -- N/A.
end)
signal:Fire()
table.clear(immediateDisconnections)
--[[
OUTPUT:
]]
^ BUT, this doesn’t mean developers will HAVE to do it this way, you could always offer both built-in.
Which is exactly what SomeSignal does:
local cn1, cn2
cn1 = signal:Once(function()
-- Deferred mutation
cn2:Disconnect()
print(cn2.Connected) -- true (deferred false to after all connections are run and before the next reentrant Fire begins, and all before the original "parent" Fire returns to keep synchronicity)
-- OR immediate mutation
cn2:Disconnect("immediate") -- SomeSignal uses an autofill string for better readability for developers unfamiliar with SomeSignal
print(cn2.Connected) -- false
-- Reentrant Fire (always deferred in SomeSignal for reentrant safety)
signal:Fire()
end)
cn2 = signal:Connect(function()
print("deferred!")
end)
signal:Fire()
print(cn2.Connected) -- false
--[[
OUTPUT if deferred:
true
"deferred!"
false
OUTPUT if immediate:
false
false
]]
^ I would like to clarify that I mean custom deferral (and not task.defer) so it still can be synchronous within the original Fire. Reentrancy deferral is a necessity for deferred mutations to safely be respected. But just like with connections, you can still offer immediate reentrancy as an option (I advise against making immediate reentrancy the default if you offer mutation deferral). I understand why it wasn’t a standard on Roblox until now because it’s admittedly an initially tricky concept to understand & difficult hardcode as a feature without compromise.
Here’s examples of synchronicity and deferral in action using SomeSignal for clarification:
signal:Once(function()
-- Connection starts off as synchronous
...
-- Becomes asynchronous (exits the Fire's "stack" because of the task-scheduler yield, forcing the code after it to run on a later CPU cycle)
task.wait()
print("Asynchronicity Check!")
end)
signal:Once(function()
-- Connection starts off as synchronous
...
local nestedConnection = signal:Connect(function(fireId: number) -- A "nested" connection is a connection created mid-way through a Fire.
print("FireId: " .. fireId)
end)
signal:Fire(2) -- "FireId 2", reentrant Fires (also synchronous) are deferred/will wait for all existing connections (upon Fire) to run & mutations to be processed before starting, these will also finish before the "parent" :Fire() returns.
signal:Fire(3) -- "FireId 3", consecutive reentrant Fires are also supported
print("Synchronicity check!")
end)
signal:Fire(1)
print("--------") -- Proof that nested/reentrant Fires are also synchronous, EVERYTHING that's synchronous will run in the initial fire before returning.
signal:Fire(4) -- "FireId 4"
--[[
OUTPUT:
"FireId 2"
"FireId 3"
"Synchronicity check!"
"--------"
"FireId 4"
"Asynchronicity Check!"
]]
^ I like that idea as a “TypeChecker Certification”, because I believe it to be a both good & general-enough feature that all signals could adopt. However, I won’t make it a requirement for any Grade due to it ultimately not being necessary for event-dispatch functionality.
I also agree with the notion that good features & versatility are far more important than rationally-imperceptible speed differences that MOST developers will never use signals heavily enough for it to matter. EVEN IF a developer needs the fastest event-dispatch speed possible, no signal will ever beat BindableEvents in that regard for as long as task.spawn is necessary. Using a signal in the first place means you care more about features than raw speed lol.
Upon closer look, I may take a while to update to meet the snapshot certification, as I have to make a critical consideration:
When a developer wants to cancel deferred mutations/operations immediately, the yielded threads must be cancelled and never resumed. This leads to code following such operations never running, possibly causing silent bugs and memory leaks.
I may opt for only allowing developers to immediately disconnect existing connections, and force deferral for operations like DisconnectAll and Destroy.
^ No worries about needing to cancel Wait threads at all, all non-instance values that have no or weak reference(s) and have no further use will immediately be queued for deletion by the Garbage Collector.
print(true) -- Primitive value without a reference, is instantly Garbage-Collected after its use.
-- Primitive value with an initially strong reference, will only get GC'd when 'value' is no longer used (weak reference).
local value = true
-- Final usage of 'value' known to the TypeSolver, it's now a weak reference and will be GC'd after it's printed.
print(value)
local value = true
-- 'Value' will remain a strong reference for 300 seconds until it's printed (or if the running-thread is GC'd early)
task.wait(300)
print(value)
local value = true
-- Functions will keep external references strong until the function itself is weak.
local function PrintValue()
print(value) -- true | false
-- References inside the function will still GC because they're stack-local to the function's call frame & has no further use after printing (weak reference)
local value2 = 1
value2 += 1
print(value2) -- 2
end
-- Values inside the returned-closure of a module won't GC until all current & future references strictly to the module's return value become weak & no longer used.
return PrintValue
Finally, the thread example:
-- A thread that doesn't have any references will GC after its call-frame is complete
task.spawn(function()
print("Hello world!")
end)
-- Same thing if it has a weak reference
local thread = task.spawn(function()
print("Hello world!")
end)
print(coroutine.status(thread)) -- 'thread' is GC'd after printing because it becomes a weak reference and its call-frame is already complete (no further use)
-- This also applies for threads that have an indefinite yield, if a call-frame is indefinitely yield without a strong reference, it'll get GC'd.
task.spawn(function() -- Gets GC'd as soon as it's yielded
coroutine.yield()
end)
-- 'thread' starts off as a strong reference because the typechecker recognizes the eventual print()
local thread = task.spawn(function()
coroutine.yield()
end)
task.wait(300)
print(coroutine.status(thread)) -- suspended -- 'thread' is GC'd after this point because it becomes a weak reference and is indefinitely suspended (without any way to resume it)
^ Thanks to how the GC behaves, you don’t have to worry about memory leaks at all for as long as you remove the reference to the threads from the signal after :Fire(), :DisconnectAll(), or :Disconnect().
Here is a luau heap benchmark of the GC’s behavior in practice:
local THREAD_COUNT = 1e5 -- A noticeable number in the luau heap
local threads = {}
local function fn() coroutine.yield() end
for i = 1, THREAD_COUNT do
table.insert(threads, task.spawn(fn))
end
print("Threads created:", #threads) -- Open up the dev-console, and get a snapshot of the Luau Heap
-- Force 'threads' to remain a strong reference, subsequently applying to its index-values (created threads).
task.wait(12) -- Time until 'threads' becomes a weak reference, get a luau heap snapshot in this time.
print(threads)
print("Threads Garbage-Collected.")
Oh no, garbage collection of the threads themselves aren’t my concern, I know they will get GC’d once dereferenced, the concern however is I cannot resume threads that called an operation that gets deferred during an invocation, which otherwise means threads can be killed unexpectedly and following code is never executed.
Hence why I opted to only allow skipping re-entrancy for Connection:Disconnect() in v1.2.0, as no threads are involved in that.
^ I’m a little confused by this sorry, is this what you originally meant?
If it’s cleanup code put inside of those connections by the developers themselves is what you mean, then the issue is out of your hands and it’s up to the individual at that point to correctly handle their own connections.
signal:Connect(function()
signal:Wait()
-- Code that will never run because of the future DisconnectAll(), which is dangerous if this is meant to be "cleanup code" for something. Terribly designed example ik lol, but should get the point across.
...
print("Resumed!") -- N/A
end)
signal:Fire()
signal:DisconnectAll()
signal:Fire()
Otherwise if you’re talking about mutations yielding the connection, it shouldn’t need to yield at all, and instead queue such mutations if they’re deferred. Deferred Reentrant Fires and :Wait() are the only things that should yield.
local cn1
cn1 = signal:Connect(function()
cn1:Disconnect() -- This SHOULDN'T yield the thread at all regardless of it's immediate or deferred.
signal:DisconnectAll() -- No yield for this either.
signal:Wait() -- This should ALWAYS yield.
signal:Fire() -- This SHOULD yield if it's deferred reentrancy, it otherwise shouldn't if it's immediate reentrancy (but do still keep synchronicity).
end)
signal:Fire()
Not sure what you mean by this either sorry
If I’m wrong about anything, could you send code examples of what you mean?
I’ll also move NamedSignal to Class 3 rn, but do keep in mind that I’ll soon be updating the Snapshot & Synchronous Certifications to account for multi-signal usage (multiple signals from the same Signal module) and consecutive reentrant Fires. I’ll let you know when it has to recertify
If a developer calls something like Signal:Connect() during an invocation, the thread gets deferred and resumed at the end of invocation.
An immediate-mode DisconnectAll becomes unsafe because how should it handle the yielded threads? SomeSignal seems to just never resume them (though I find it a little hard to tell from the source code), which can result in threads unexpectedly being yielded indefinitely.
Fun fact: That’s how SomeSignal V1 used to work, but then I realized that it might cause problems later, so I changed how it’s handled when signal is firing.
So I create a connection which isn’t connected to the signal yet / create a deferred mutation which is then put in a queue, then I process the queue before fire reentrancy.
And that’s how I solved the whole issue overall on my side