Better `Event:Wait()` with a timeout?

Every now and then, I run into a situation where having something like this would be useful:

local intValue = Instance.new("IntValue")
intValue.Value = 0

-- Do something....
local result = intValue.Changed:Wait(30)

The value passed to the Wait method is a timeout in seconds. If the timeout expires before the event fires, then nil is returned. Currently, the only real way to do this in LuaU is the following:

-- Performs the :Wait() functionality on an IntValue but has a timeout.
-- Checks the conditions on every frame.
local function intWait(intval: IntValue, timeout: number): number
	-- Setup
	local changed = false
	local result = nil
	local count = 0

	-- Changed event on intval.
	intval.Changed:Connect(function()
		changed = true
		result = intval.Value
	end)

	-- Wait for the value to change or the timeout to expire.
	while count < timeout and changed == false do
		count += task.wait()
	end

	-- Return
	return result
end

Now the way that a wait function is usually implemented is that instead of running a loop to spin the task and increment a counter in user space, a system call is made to the kernel which sets the sleep timer property on the proc structure for the scheduler to handle. Each time the scheduler looks at it, it compares the timestamp from the sleep value to the system clock. If value > clock, then the wait is over and it proceeds with the context switch to that thread, unless it’s waiting for some other event.

Roblox’s wait() and task.wait() implementations are based on the engine video frame rate of either 1/30 or 1/60 respectively. Those numbers can differ on the client due to variable frame rates though. To get that kind of timing, the engine performs all tasks in the frame and then waits for the video refresh hardware interrupt to flip the rendered frame to the active display frame then proceeds to perform the full physics simulation and graphics rendering workflow for the next frame.

But to implement something like a :WaitForChild(), you would check if the condition was true. If it is true, then you return the result, otherwise you yield the thread. You accumulate the time between yields until you reach the threshold and return a default value and print a warning. I figure that an implementation of :Wait(timeout) would be something like a WaitForChild().

Basically, what I am looking for is the most efficient way to implement a :Wait(timeout) with the tools that we have available to us. I would rather not burn CPU cycles in Lua to do this though. Is there a better way to implement this?

I did some looking around, but the main implementation that I found uses LoadLibrary() which is long depreciated.

https://devforum.roblox.com/t/creating-an-efficient-timeout-for-wait/1032100
https://devforum.roblox.com/t/timeout-argument-for-eventwait/32737
https://devforum.roblox.com/t/waiting-event-for-a-certain-time/1572180

I have found an old feature request on this too that someone posted. The final suggestion was to add an Event:TimedWait(timeout) method to resolve the issue of return values.

3 Likes

A sneaky way to do this without waiting each frame is to proxy the response through another event:

local function waitEvent(event: RBXScriptSignal, timeout: number?): any
	local proxy = Instance.new("BindableEvent")
	timeout = timeout or 5 --5 seconds default timeout
	local connection, thread
	connection = event:Once(function(...)
		task.cancel(thread)
		proxy:Fire(true, ...)
	end)
	thread = task.delay(timeout, function()
		connection:Disconnect()
		proxy:Fire(false)
	end)
	--ensure all values get returned
	local data = {proxy.Event:Wait()}
	proxy:Destroy() --avoid memory leaks
	return table.unpack(data) --convert the data back to tuple
end

--example usage
local responded, child = waitEvent(workspace.ChildAdded, 10)
if responded then
	print(child)
else
	print("No child was added during the specified timeframe!")
end

Fighting events with more events I guess.

3 Likes

This should work for most cases, but it could break if you start doing weird coroutine stuff to the yielding thread.

local function WaitWithTimeout(Event: RBXScriptSignal, Timeout: number): ...any
	task.delay(
		Timeout,
		task.spawn,
		coroutine.running()
	)
	
	return Event:Wait()
end

However, you should strive to make your logic act more reactive instead of invocative, because it’s much easier to reason with.

1 Like

Now that is some ingenious code right there. Create a connection to cancel the task.delay upon the even firing, but disconnect the connection and destroy the event if it times out. Brilliant.

@index_self Interesting code. It’s complex yet subtle.

I’m not sure why you’re saying this, but most of my code is reactive. The code that is invocative is for things that run in the background, like the round timer, dynamic environmental effects, etc…