task.yield<T>(value: T) -> T

It’s a hassle to deal with race conditions when working with synchronous code processes or code processes that read values/properties/variables outside of a function’s environment

The only real workaround developers have is to either write a custom yield function or tack on a repeat loop, both of which contain something akin to:

repeat task.wait() until x end

example:

---pretend that data is loaded/handled in modulescript "DataModule"
---this is a weak example but it should help get the point across
local function LoadItems(Player: Player)
	if not DataModule.Cache[Player] then
		repeat task.wait() until DataModule.Cache[Player]
	end

	local SavedData = DataModule.Cache[Player]
	--... do things with saved items in SavedData
end

…it’s reminiscent of how old-school scripts had a wait(2) on line 1 to prevent dependencies from breaking


a cleaner, less-tacky solution to this problem would be:

task.yield<T>(condition: () -> T) -> T
task.yield<T>(value: T) -> T

it behaves similarly to assert(), except condition/value is “tried” until it returns/becomes something truthy, then yield will return that truthy value

I’m unsure if the 2nd overload is possible because it’s unknown how variables are handled under-the-hood, but value would be treated like a pointer to the variable being “tried”

---pretend that data is loaded/handled in modulescript "DataModule"
local function LoadItems(Player: Player)
	local SavedData = task.yield(DataModule.Cache[Player]) --uses the 2nd overload in the proposed request
	--... do things with saved items in SavedData
end
7 Likes

This doesn’t functionally work without significant extensions to Luau’s semantics supporting some kind of macro system: task.yield(DataModule.Cache[Player]) is the same as task.yield(nil) because the arguments you’re passing to a call get evaluated before that call gets a chance to do anything.

I would recommend looking into promise libraries, they can get you the semantics you want here.

5 Likes

task.yield(function() return DataModule.Cache[Player] end) would still work, right?

At that point it would just be a repeat task.wait() loop, so no.

What if some sort of lambda system was made?

The value ... in arguments is like a variable that is called like a function when it’s value is asked for. What if lambdas did the same thing?

local value: Lambda<number> = lambda {math.random()}
print(value) --> 0.1844987442
print(value) --> 0.8471027478
print(value) --> 0.2984728347

This would make it extremely easy to write very confusing code and code that is harder to statically analyze.

I think the right solution to your problem is having your systems expose signals that you can wait on, and going to task.wait() loops if the implementation cost of that would be too high.

In the OP’s case, something akin to…

local function loadSavedData(player: Player)
     if DataModule.Cache[player] == nil then
         repeat
             DataModule.CacheLoaded:Wait()
         until DataModule.Cache[player] ~= nil
    end

    return DataModule.Cache[player]
end

…or promises as tnavarts referred to.

3 Likes

Encountering race conditions is a sign that you need to look into defensively architecturing your codebase and rely less on timing. Some libraries that can help here are Promise, Future and Rx (via Observables).

Waiting at the top of a script to avoid race conditions is a code smell. Race conditions indicate an architecturing problem. Defensive architecture (e.g. via the aforementioned libraries, events, etc) will get you where you need to be without introducing bloat and noob traps in the API.

5 Likes

This most definitely would need a different name, because coroutine.yield exists but means something completely different, so reusing the name like this would not be nice. task.await could make sense, but yeah it’s very close to writing a loop manually.

3 Likes

As aforementioned, typically you’ll never experience race conditions in a better codebase. You should instead implement a callback function into “DataModule”, that is called whenever the data cache was loaded.

Just to note I don’t actually structure my code this way, and only wrote it as an example case
Although the race condition issue is still the same

Yeah, this is what I was worried about when writing this request

The only reason that kept me hopeful is that certain constructors that appear to have an optional value will throw errors when nil is passed, namely Random.new(nil) will throw an error but Random.new() won’t (despite arg Seed being annotated as optional), so I thought that the constructor was doing some non-Lua things with the Seed argument that was passed

I’m currently writing a Luau library that has modules that complement some of the the existing Luau libraries (Vector3, CFrame, math, etc) and when I have to reference it (which is a lot, as the library is functionally a swiss army knife), I always find myself having to do a “setup ritual” of sorts, where I’m basically pasting this at the beginning of every script:

local RS				= game:GetService("ReplicatedStorage")
local Shared			= RS:WaitForChild("Shared")
---... other definitions
---						@module src.Packages.LuauLib --RobloxLSP emmylua annotation
local LuauLib			= require(Shared.LuauLib)

I’ve seen in other languages (and vanilla Lua as well) that libraries can be imported with a string reference, and although this probably isn’t possible because of the explorer architecture, it would be nice to have some way to effectively cut out the “setup ritual”

This is the closest thing to what I have in mind… and from what I know, if this was implemented I think it would be a lot cleaner than what’s currently available

Would adding task.await upend/have conflicts with the entire task library?

2 Likes

The lore behind the feature request is that I sometimes find myself referencing non-Instances with repeat spam and Instances via WaitForChild() chains or which both feel equally awful when I know that a better alternative may be possible

The Promise library pretty much fits the request/case to the T, but perhaps some of its functionalities can be implemented into the task library?

local x = task.yield(someTable.Key) -- or task.yield(someTable, "Key")?

… seems to be much less tacky than

local x = nil
-- repeat task.wait() until someTable.Key
repeat
	task.wait()
until someTable.Key
x = someTable.Key
1 Like

The better answer here is making it easier to distribute and consume 3rd party libraries for common patterns like promises and maids. That way it’s almost as convenient but you still have the flexibility to tweak those libraries if necessary rather being stuck with whatever behavior we chose for a builtin.

That’s something that we’re working towards solving.

5 Likes

That sounds really nice and I’m looking forward to that

Is it a stretch to imagine that these 3rd party libraries will be treated as custom services? Or is it too early to ask