If you do x:WaitForChild(y), and y is never added under the object x, and subsequently Destroy is called on object x in another thread, then the thread which is yielded for the WaitForChild is not cleaned up. This leaks Lua threads because these threads can subsequently never resume from the WaitForChild yield.
How to reproduce:
Open a baseplate.
Add a Script to workspace, with following source:
local watch = {}
setmetatable(watch, {__mode='kv'})
spawn(function()
local canary = {}
watch[canary] = canary
script.Folder:WaitForChild("Foo")
print("hello")
end)
wait(0.5)
script.Folder:Destroy()
wait(1.0)
print(next(watch)) -- check whether thread cleaned up
Create a Folder under the Script with name “Folder” (default).
Which is not nil, meaning the canary table was not cleared from the weak table, meaning the thread where WaitForChild is called is not cleaned up. The thread never resumes though, so the thread is leaked.
Expected behavior:
The thread should be cleaned up because the object that WaitForChild was called on is destroyed. In turn, “nil” should be printed, because the canary table in the weak table has no other references to it, so it should be garbage collected.
In case anyone is having a problem with this in the future:
Here is a workaround that won’t leak a thread if the instance is destroyed:
local function watertightWaitForChild(object, name)
local child = object:FindFirstChild(name)
while not child do
wait()
if not object.Parent then
coroutine.yield()
end
child = object:FindFirstChild(name)
end
return child
end
Polling like that is bad practice. Here’s a better solution using events:
-- returns the first child with a given name, or waits for it to be added.
-- if the parent is destroyed while waiting, returns nil
local function safeWaitForChild(parent, childName)
-- if the child's already there, we can return immediately
local child = parent:FindFirstChild(childName)
if child then
return child
end
-- create a 'resolve' event that will fire with the result
local resolveEvent = Instance.new "BindableEvent"
-- if a child is added with the correct name, resolve with the child
parent.ChildAdded:Connect(function(child)
if child.Name == childName then
resolveEvent:Fire(child)
end
end)
-- if the parent is removed from the data model, resolve with nil
parent.AncestryChanged:Connect(function()
if not parent:IsDescendantOf(game) then
resolveEvent:Fire(nil)
end
end)
-- once the resolve event fires, clean it up
resolveEvent.Event:Connect(function()
resolveEvent:Destroy()
end)
-- wait for a result
return resolveEvent.Event:Wait()
end
local function safeWaitForChild(parent, childName)
local child = parent:FindFirstChild(childName)
if child then
return child
end
while parent.Parent do
child = parent.ChildAdded:Wait()
if child.Name == childName then
return child
end
end
end
EDIT 2: alright I’ll stop editing my posts @Anaminus… wait, this is an edit, isn’t it?
Hence the reason I always use the second parameter of WaitForChild when I’m not sure if the child will be added. I typically set it to 300 seconds.
Heads up: It appears Roblox doesn’t properly destroy some instances (like characters). Thus, even if the behaviour of WaitForChild was adjusted, waiting for a child inside a player character won’t yield different results if the player respawns / leaves.
Can you explain this? From what I can tell, it works fine.
Adding a child fires ChildAdded immediately, setting the calling thread’s status to normal. The looping thread is then resumed, processing the child. Then it loops back around and yields, ready to process the next child. Skipping an added child might happen if the loop yielded some other way first, but that isn’t the case here.
I’m not sure what you mean about ancestors. Is WaitForChild affected by ancestry changes somehow?
Wait, I wasn’t reading the wrong snippet. Stop editing your posts @Elttob >:(
I actually think I might be mistaken here. I had issues in my require system before using this sort of loop, but I think maybe the require() were blocking, so this would cause issues.
Basically, I prefer to connect and disconnect the events instead of using a loop here, due to having this sort of code fail in the past.
I suspect this was mostly my fault, and not an actual issue with the core logic. This does feel slightly harder to do safely, but I think you’d be fine.
No, this was also a miscommunication, but a loop like this is unsafe. We want a slightly different condition than parent.Parent!
Instead, we want to do child:IsDescendantOf(game) or something like that.