Task.wait() does not respect script.Disabled and always resumes

Reproduction Steps
task.wait() does not respect script.Disabled like wait() does, so I cannot use it since it means my code just continues running when I have explicitly tried to halt execution.

Repro and demonstration:
wait_disable.rbxlx (37.7 KB)

Two scripts:

-----------------------------------------------------------------------------------
-- ScriptA
-----------------------------------------------------------------------------------

task.delay(3, function()
	warn("Disabling wait script!")
	script.Disabled = true
end)


for i=1, 15 do
	print(string.format(
		"% 9s % 3d | Disabled: %s",
		"wait", i, tostring(script.Disabled)
	))
	wait(0.5)
end

warn("wait will not reach this since it gets disabled before the loop finished")

-----------------------------------------------------------------------------------
-- ScriptB
-----------------------------------------------------------------------------------

task.delay(3, function()
	warn("Disabling task.wait script!")
	script.Disabled = true
end)


for i=1, 15 do
	print(string.format(
		"% 9s % 3d | Disabled: %s",
		"task.wait", i, tostring(script.Disabled)
		))
	task.wait(0.5)
end

warn("task.wait finished regardless of the script being disabled")

Output:

       wait   1 | Disabled: false - ScriptA:8
  task.wait   1 | Disabled: false - ScriptB:8
       wait   2 | Disabled: false - ScriptA:8
  task.wait   2 | Disabled: false - ScriptB:8
       wait   3 | Disabled: false - ScriptA:8
  task.wait   3 | Disabled: false - ScriptB:8
       wait   4 | Disabled: false - ScriptA:8
  task.wait   4 | Disabled: false - ScriptB:8
       wait   5 | Disabled: false - ScriptA:8
  task.wait   5 | Disabled: false - ScriptB:8
       wait   6 | Disabled: false - ScriptA:8
  task.wait   6 | Disabled: false - ScriptB:8
  Disabling wait script! - ScriptA:2
  Disabling task.wait script! - ScriptB:2
  task.wait   7 | Disabled: true - ScriptB:8
  task.wait   8 | Disabled: true - ScriptB:8
  task.wait   9 | Disabled: true - ScriptB:8
  task.wait  10 | Disabled: true - ScriptB:8
  task.wait  11 | Disabled: true - ScriptB:8
  task.wait  12 | Disabled: true - ScriptB:8
  task.wait  13 | Disabled: true - ScriptB:8
  task.wait  14 | Disabled: true - ScriptB:8
  task.wait  15 | Disabled: true - ScriptB:8
  task.wait finished regardless of the script being disabled - ScriptB:15

Expected Behavior
I expect task.wait() to stop executing my loop when the script is Disabled, like the behavior of the wait() it is supposed to be a replacement for.

Actual Behavior
task.wait() will always resume.

Workaround
Use the gross old wait().

Issue Area: Engine
Issue Type: Other
Impact: Moderate
Frequency: Often

45 Likes

This is very odd and from what I know should not happen perhaps it’s the task.delay() causing this?

The behaviour of wait() is not consistent.

script.Disabled = true
for i = 1, 15 do
    print(i)
    wait(0.5)
end
warn("This should not run")

This still warns.

spawn(function() script.Disabled = true end)
for i = 1, 15 do
    print(i)
    wait(0.5)
end
warn("This should not run")

But this does not.

task.wait() would warn in both cases.

6 Likes

Thanks for the report.

I’ve taken a look into this and it’s not guaranteed to behave as you are expecting. This is why there’s some inconsistency with it working in some cases and not others, and not at all with the new wait method.

Still, I can see why this would be useful so I’ll follow-up internally on this. In the meantime, getting a better understanding of what you’re trying to do would definitely help.

16 Likes

This also occurs if the script is destroyed. The code will continue to run even if the script is disabled or destroyed, which can leak memory or cause unintended behavior.

A case where I think I would run into problems with this is with client character ability code. Some of my abilities have infinite loops that raycast regularly or do other processing. If the character dies and I have one such script running in their character, the loop will never stop and rapidly start running duplicates as the player respawns (assuming the code doesn’t error out). This is extremely unexpected for me as a developer, and that I have to manually handle this is very unintuitive.

@WallsAreForClimbing (Forgot to reply)

20 Likes

I don’t think any developer expects a script to run any threads when script.Disabled is set to true, so this feels like a pretty important thing to take a look at. When I disable a script or a script is in nil I want to be 100% sure that it won’t be running anymore, I feel like this has a lot of potential to cause unwanted memory leaks without much explanation.

16 Likes

I just spent a couple of hours de bugging reasons for why my code suddenly started running while dead - and turns out it’s due to @tnavarts 's signal API it actually seems to be something different and confusing to debug.

warn(debug.traceback())
warn(script:GetFullName())

the top prints Workspace.[CHARACTER].[SCRIPT], and yet the other only prints [CHARACTER].[SCRIPT], without Workspace. to show that it’s ran within the DataModel. Unsure of what to make of this.

Adding onto this, I honestly would’ve probably gone insane if not for someone pointing me to this thread, because I could’ve never guessed that the issue was related to signals (at least, in my case). I even wrote a script cleanup script to destroy all scripts in the character upon respawn, and yet even that didn’t help, which caused great confusion.

local Players = game:GetService("Players")

local LocalPlayer = Players.LocalPlayer

LocalPlayer.CharacterRemoving:Connect(function(Character)
	for _, Script in next, Character:GetDescendants() do
		if Script:IsA("LuaSourceContainer") then
			Script:Destroy()
			print("Destroyed", Script:GetFullName())
		end
	end
end)

This is a major break in otherwise expected behavior, and should most definitely be addressed.

2 Likes

Any follow up on this? This really can mess with scripts as we were advised to use task.wait yet we fall back on wait because of this.

4 Likes

I don’t feel safe using task.wait because of this issue.

2 Likes

Hello, everyone! I sent this issue to the engineers and let them know that this issue still occurs. Will update its status once they are done with it. Have a nice day!

16 Likes

Thank you! Hope this gets fixed!

This still isn’t fixed, the health regeneration script in my game kept running even after the character died, resulting in thousands of loops running at once dropping the server framerate down to under 20.

8 Likes

Is this issue actively being looked into? I’ve already seen quite a few developers fall into the pitfall of thinking scripts will stop when they’re destroyed, and having massive memory leaks that are hard to diagnose.

A better workaround is to use the heartbeat wait instead of wait() until this is fixed

Doesn’t heartbeat not work with FPS unlockers though? And it updates way more frequently then what the developer wants

Also I have encountered this bug myself, and I found it to be really annoying.

There are now two way to create immortal script

1-

2-

3 Likes

That’s the case for task.wait as well, and any RunService event, as they’re all just fired one after another.

I’m not entirely sure if this has any issues, but this <should> work just fine, it’s better than using a Heartbeat loop with Wait, as that would be 3x slower in Deferred mode, and it’s even worse comparing with Immediate mode.

local function Wait(n: number?): number
    n = if typeof(n) == 'number'
        and n
        or 0

    local timePassed = 0

    local thread = coroutine.running()
    local connection
    connection = RunService.Heartbeat:Connect(function(deltaTime)
        timePassed += deltaTime

        if timePassed >= n then
            connection:Disconnect()
            task.spawn(thread, timePassed)
        end
    end)

    return coroutine.yield()
end

Note that this will have different Wait deltaTime returns than task.wait, because it respects frame time and not actual spent time, this is what you want for visual effects, though using RenderStepped is a better option in that case.


For me this isn’t actually that big of a deal, I can understand how it can be an issue for people with older code and thousands of lines using while true do task.wait() loops, but I think you can take some time to change that to use RunService connections and manage the time passed yourself instead, then it shouldn’t take too long.

local TimePassedSinceLastUpdate = 0
RunService.Heartbeat:Connect(function(deltaTime)
    TimePassedSinceLastUpdate += deltaTime

    if TimePassedSinceLastUpdate < 0.25 then
        return
    end

    -- Code that runs every 0.25 seconds.
end)

RunService connections are disconnected when a script dies. I believe it identifies the script using functions, so it’s a much more modern and better option.

2 Likes

Not sure if this may work but every time a loop runs you could check if the script is disabled or not. If it is, then it breaks / disconnects.

We’ve identified a fix for this, it’ll be a little while until we ship it but when we do I’ll be sure to give you all another update.

15 Likes