Coder Social home page Coder Social logo

roblox-lua-promise's Introduction

Roblox Lua Promise

An implementation of Promise similar to Promise/A+.

View docs

Why you should use Promises

The way Roblox models asynchronous operations by default is by yielding (stopping) the thread and then resuming it when the future value is available. This model is not ideal because:

  • Functions you call can yield without warning, or only yield sometimes, leading to unpredictable and surprising results. Accidentally yielding the thread is the source of a large class of bugs and race conditions that Roblox developers run into.
  • It is difficult to deal with running multiple asynchronous operations concurrently and then retrieve all of their values at the end without extraneous machinery.
  • When an asynchronous operation fails or an error is encountered, Lua functions usually either raise an error or return a success value followed by the actual value. Both of these methods lead to repeating the same tired patterns many times over for checking if the operation was successful.
  • Yielding lacks easy access to introspection and the ability to cancel an operation if the value is no longer needed.

This Promise implementation attempts to satisfy these traits:

  • An object that represents a unit of asynchronous work
  • Composability
  • Predictable timing

roblox-lua-promise's People

Contributors

ddavness avatar dependabot[bot] avatar evaera avatar fablerbx avatar howmanysmall avatar jackdotink avatar lpghatguy avatar oltrep avatar quenty avatar reselim avatar validark avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

roblox-lua-promise's Issues

`Promise.defer` should use `task.defer`

Currently, Promise.defer uses RunService.Heartbeat, which should be replaced with task.defer instead. The current implementation of Promise.defer results in the Promise being resumed in the next frame, rather than the next invocation point.

support tables with __call metamethod in handlers

The library currently checks for:

	assert(
		successHandler == nil or type(successHandler) == "function",
		string.format(ERROR_NON_FUNCTION, "Promise:andThen")
	)
	assert(
		failureHandler == nil or type(failureHandler) == "function",
		string.format(ERROR_NON_FUNCTION, "Promise:andThen")
	)

which will fail for callable tables that use the __call metamethod, so this code fails the assertion above:

		local wrapped = {}
		setmetatable(wrapped, {
			__call = _wrapped,
		})
		wrapped.cancel = _cancel

                Promise.new():andThen(wrapped, wrapped)

a helper method that could be used in the library might look like:

local function isCallable(value)
  if typeof(value) == "function" then
    return true
  end
  if typeof(value) == "table" then
    local mt = getmetatable(value)
    if mt and rawget(mt, "__call") then
      return true
    end
  end
  return false
end

Global event for promise rejection

It would be nice if the library allowed to set a callback for any promise rejections that are not handled, similar to how browsers fire the unhandledrejection event.

I'm not sure what's the best way to expose that functionality though, maybe a static function to assign on Promise? That would allow a single function to run.

Promise.onUnhandledRejection = function(rejection)
    ...
end

Maybe a bindable/custom event? (bindable would lose metatables on the rejection values)

Promise.onUnhandledRejection:Connect(function(rejection)
    ...
end)

Let me know what are your thoughts about this ๐Ÿ™‚

๐Ÿ‘‹

_queuedResolve, _queuedReject, and _queuedFinally can unnecessarily prevent garbage collection

_queuedResolve = {},
_queuedReject = {},
_queuedFinally = {},

If these are not cleared when a promise completes, this can lead to unnecessary prevention of GC. An :andThen or :finally can include callbacks with upvalue references. Since promises keep strong references to these callbacks, the callbacks and their upvalue references will never be collected.

Here's a realistic example:

local gameReadyPromise = getGameReadyPromise() -- this promise exists forever and completes when all modules have loaded
-- ...
game.Players.PlayerAdded:Connect(function(player)
    gameReadyPromise:andThen(function()
        print(player.Name, "has joined the game!")
        -- load the player into the game
    end)
end)

If any players join the game before gameReadyPromise completes then their player objects can never be garbage collected ultimately due to references originating in _queuedResolve.

Here's a simple test:

local function testGc(callback)
	local gcTester = {}
	local weakRef = setmetatable({gcTester}, {__mode = "v"})
	callback(gcTester)
	gcTester = nil
	wait(10)
	return #weakRef == 0 and "GCed" or "Not GCed"
end

local persistentPromise = Promise.delay(0)

local didGetGced = testGc(function(upvalueGcTester)
	persistentPromise:andThen(function()
		local thisUsesUpvalue = upvalueGcTester
	end)
	persistentPromise:await()
	-- the above callback will *never* be called again, so ideally the callback
	-- and upvalueGcTester would be garbage collected, but they won't be:
end)

print("Did upvalueGcTester get collected?:", didGetGced)

This can be solved by clearing out or replacing _queuedResolve, _queuedReject, and _queuedFinally when the promise completes (such as in _finalize).

Unhandled Promise Rejection warning

I'm not really sure if this is intended behavior or not, but I'm creating a new promise to fetch data from the datastore and calling :await() on that promise. Then I'm intentionally rejecting the promise just to see what gets returned.

I see the success returning false and the error returned in my output log as expected, but then I also get a warning saying I have an unhandled promise rejection. I thought calling :await() essentially stops this from happening since self._unhandledRejection gets set to false?

Here's how I'm creating and using the promise

function DSEventQueue:AddEvent(method, ...)
    print("[DSEventQueue::AddEvent] Adding event")
    local args = { ... }

    return Promise.new(function(resolve, reject, onCancel)
        -- insert the datastore event into the queue
        table.insert(self.events, {
            method = method,
            args = args,

            resolve = resolve,
            reject = reject
        })

        if #self.events == 1 then
            connectHeartbeatTick(self)
        end
    end)
end
local promise = getDSEventQueue:AddEvent(self.datastore.GetAsync, self.datastore, self.key)

local retValues = table.pack(promise:await())
local success = table.remove(retValues, 1)

Inside the heartbeat tick

dsEventQueue.connection = RunService.Heartbeat:Connect(function(dt)
    local budget = DataStoreService:GetRequestBudgetForRequestType(dsEventQueue.requestType)

    if #dsEventQueue.events == 0 then
        dsEventQueue.connection:Disconnect()
        dsEventQueue.connection = nil
        return
    end

    if tick() - dsEventQueue.processTime >= dsEventQueue.rate and budget > 0 then
        dsEventQueue.processTime = tick()

        
        local event = table.remove(dsEventQueue.events, 1)
        local method = event.method
        local args = event.args
        local resolve = event.resolve
        local reject = event.reject


        local retValues  = table.pack(pcall(method, table.unpack(args)))
        local success = table.remove(retValues, 1)

        if success then
            resolve(table.unpack(retValues))
        else
            reject(table.unpack(retValues))
        end
    end
end)

Output log
I'm basically just grabbing the value of a key whose length is larger than 50 characters which results in an error from the datastore

Roblox doesn't call __tostring on error objects

If an error occurs during execution of a Promise which has :expect() called on it then the only error reported is Error occurred, no output from Lua.

The Promise library collects the correct information about this error but doesn't report it correctly. This appears to be due to expectHelper attempting to call error(..., 3). The error function can only accept a string as it's first argument and if given a table gives you that error shown earlier.

Create tags for releases

Semantic versioning would help us know when the API has changed in a breaking way and prevent us from relying on the head of the master branch to always be 100% stable. These could be released as versions via Github Releases.

Promise.all should not error for non-promise input values

Currently, Promise.all will give the error Non-promise value passed into Promise.all at index i if you attempt to pass in non-promise values:

-- We need to check that each value is a promise here so that we can produce
-- a proper error rather than a rejected promise with our error.
for i, promise in pairs(promises) do
if not Promise.is(promise) then
error(string.format(ERROR_NON_PROMISE_IN_LIST, "Promise.all", tostring(i)), 3)
end
end
.

This is a bit different from the javascript Promise.all implementation, which will resolve non-promise values:

const promise1 = Promise.resolve(3);
const promise2 = 42;
const promise3 = new Promise((resolve, reject) => {
  setTimeout(resolve, 100, 'foo');
});

Promise.all([promise1, promise2, promise3]).then((values) => {
  console.log(values);
});
// Expected output: Array [3, 42, "foo"]

While javascript is not always the best place for inspiration, I think this is a convenient feature. You may have some data in a list that can be resolved synchronously, while you may have other data is resolved asynchronously.

For instance, you may want to fetch some data from the network, while other data may already be in a cache. It may not make sense to add the overhead of creating a Promise object for each cached item, which can be resolved synchronously, especially if you're looking up thousands of keys.

local cache = {
    a = 5
}

local function fetch(key)
    if cache[key] then
        return cache[key]
    end

    return networkFetch(key):andThen(function(result)
        cache[key] = result
        return result
    end)
end

local keys = { "a", "b", "c" }
local results = map(keys, fetch)

Promise.all(results):andThen(...)

I think it would be great to align to the javascript implementation here, but let me know what you think! As far as implementation goes, you could add a check if the value is not a promise in the resolve loop, and add immediately call resolveOne here: https://github.com/evaera/roblox-lua-promise/blob/master/lib/init.lua#L546-L549

Thanks for taking the time to consider this proposal!

Chaining broken

local w = require(script.Promise)

w.async(function(resolve, reject)
	wait(.2)
	print('foo')
	resolve('bar')
end):andThen(function(data)
	wait(.2)
	print(data)
	
	return w.async(function(resolve, reject)
		wait(.2)
		resolve(1)
	end)
end):andThen(function(data)
	print(data)
end)

Shows
foo nil bar

Expected
foo bar 1

Promise.retry

function Promise.retry(callback, times, ...)
  local args, length = {...}, select("#", ...)

  return callback(...):catch(function(...)
    if times > 0 then
      return retry(callback, times - 1, unpack(args, 1, length)) 
    else
      return Promise.reject(...)
    end
  end)
end

Add Promise:timeout(seconds)

Shorthand for:

Promise.race(
  Promise.delay(seconds):andThen(function() return Promise.reject("Timed out.") end),
  promise
) 

Promise.fromEvent

Promise.fromEvent(RunService.Heartbeat, function(resolve)
  -- function runs multiple times
  if condition then
    resolve() -- automatically disconnects
  end
end)

Cannot publish any library

Attempting to use Wally Publish always returns a server error regardless of the .toml.

Error:

Error: 500 Internal Server Error

<html><head>
<meta http-equiv="content-type" content="text/html;charset=utf-8">
<title>500 Server Error</title>
</head>
<body text=#000000 bgcolor=#ffffff>
<h1>Error: Server Error</h1>
<h2>The server encountered an error and could not complete your request.<p>Please try again in 30 seconds.</h2>
<h2></h2>
</body></html>

Project I'm trying to upload: https://github.com/prooheckcp/RoactMotion

Add Promise.try

This is a utility that is a shorthand for:

Promise.resolve():andThen(callback). It begins a Promise chain such that any errors will turn into rejections.

Promise.reduce(array, mapFn, initialValue)

I encountered a use case where I need to do an array reduce operation with a callback that may return a promise. When the reduce callback returns the first promise, the reduction will continue after each promise resolves.

If that's a feature you would like for this library, I can submit a pull request for it!

Promise:now()

Returns a chained Promise which is resolved if the Promise is resolved, or rejected if the Promise is not

Queued callbacks do not begin on new threads

Description of the bug: The following error is thrown when yielding in the chain andThen after the resolution of Promise.delay: Promise:726: attempt to index nil with 'next' .
Frequency of the error (in the this artificial example posted below): 100%

Reproduction steps

Run the following server script:

local ServerStorage = game:GetService("ServerStorage")

local Promise = require(ServerStorage.src.Promise)


Promise.delay(5):andThen(function ()
    wait(2)
end)

Error in finally handler does not propagate to subsequent catch after a :timeout

if an error is thrown inside a finally handler of a promise that has been timed out, the promise is cancelled and the Timeout Error does not propagate to the subsequent catch handlers. This leads to unexpected silent failures that gives the appearance the promise is yielding forever in cases where timeout rejection is expected

Error inside handler

    Promises.resolve():andThen(function(...)
          return Promises.delay(10):finally(function()
            error("Something went wrong") -- This will silently Error
        end)
    end)
    :timeout(1)
    :catch(function() -- Silent failure
        warn("The Promise was Rejected :( ") -- This won't run
    end)

No Error inside handler, Works as intended.

    Promises.resolve():andThen(function(...)
          return Promises.delay(10):finally(function()
               warn("Did some work here")
        end)
    end)
    :timeout(1)
    :catch(function() -- This will run!
        warn("The Promise was Rejected :( ") -- Promise was rejected because of Timeout
    end)

In the second example, if there's no error in the finally block, the catch handler is correctly invoked due to the :timeout. The expected behavior is for the catch handler to always be triggered by the :timeout, even if there is an error in the finally block.

coroutine.close issue

This error happens when a promise is finished and I'm pretty sure its around the ":finally" things.

I think the cause of this bug is because coroutine.close is not a valid function.
This bug is not too bad though.

Log:

ServerScriptService.Script:2680: invalid argument #1 to 'defer' (function or thread expected)

The auto-filling doesn't work?

So, I don't know if I'm making some sort of mistake somewhere or if this is a roblx issue, but for some reason, when I try to use this module the roblox auto-filling doesn't work. What I mean by this is that normally, when you have a module and you require it and then try to call a module function (eg: module.FunctionName()) roblox will give you a list of the functions in that module so it's easier for you to script, I don't know if I explained this correctly, but moving on. Basically, my issue is that when I try to use the promise module, roblox seems to not see that it is a module and simply say "error type" in that little "Auto-filling window"; I don't know why this is happening and it's pretty problematic for me since I've never used promises before and I'm just starting to learn how to use them, if I'm gonna have to memorize how every function is written without ever having used the promise library/module it's gonna be very hard.
Just for clarification, the module DOES work, it's just that without auto-filling and very little knowledge on the module using it is pretty hard.
image

Queued callbacks from a cancelled sub-promise should not be run

local h = Promise.new(function(resolve)
    wait(3)

    resolve()
end)

local andthen1 = h:andThen(function()
    print("this should NOT print, but it does today")
end)

local andthen2 = h:andThen(function()
    print("this should print")
end)

andthen1:cancel()

x = Promise.new(executor), with executor function executor,
and then chain onto it with andThen.

In effect, calling x:andThen(cb) is registering a callback (cb) to be run when x resolves. it returns a new promise, y, whose executor you do not control; it's controlled by the promise library, and it is what calls cb internally.

This is different, because the code inside cb is not related to promise y in the same way that the code in executor is related to promise x

I think this is confusing behavior, so we can just change it in a new version

Implement Typings For Strict-Typing Luau Users

Super low-priority issue that serves as merely an inconvenience- I've completely transitioned into using strict typings for all of my projects (sadly, I haven't had the opportunity to take up TypeScript just yet). It would be super helpful if we could have a few types implemented for Promises (namely, a Promise type) so I won't have to use "any" everywhere in my code.

Stabilize source field

Promises have an accessible, yet considered private by convention, _source field to them. It be great if the library could 'stabilize' it by removing the underscore or adding a method like getSource to the API.

A use case for that could be a test runner that keeps track of running promises and want to print out the source of each one to locate them.

Add Promise.some

Promise.some(array<Promise>, amount)

Resolves when at least amount Promises have resolved with an array of the resolved values in the order that they resolved.

Rejects if enough input Promises rejected that the promise can no longer resolve. Still pending Promises become cancelled if they have no more consumers.

Promise.any

Promise.some with 1 amount, except resolved value is value directly instead of an array with one element. This is different than Promise.race, because Promise.race rejects if any input rejects, but Promise.any will only reject if all input promises reject.

Promise.fromEvent as command

I'm trying to find a solution how to use Promise.fromEvent so

Promise.fromEvent(robloxEvent)
 :tap(function ()
	print("event occurred")
end)

Will be triggered each time the event fires, is there known guide how to do this? or walkaround ?

Thanks ๐Ÿ™

Add Promise.delay

An alternative to wait(), but instead using consistent timing with its own scheduler. Cancelling should remove it from the queued task list.

Should resolve with the amount of time waited.

Promise.promisify should use pcall

Promise.promisify should reject the promise if the function errors instead even after a yield, so it needs to be pcalled on top of using coroutine.wrap.

Promise:finally counts as a consumer, but it probably shouldn't

Somewhat related to #59. Right now, finally acts as a consumer of the root promise. This means if another consumer of the root promise (that is, a sibling of the finally promise) is cancelled, the root itself will not be cancelled because it still has a second consumer (the finally promise itself).

If we make it so that finally forwards rejection values and doesn't reset the state (#59), then we should probably also make it so that finally does not count as a proper consumer. This way attaching a finally to a chain is more or less transparent and doesn't affect the logistics of cancellation.

An example where this causes problems:

local require = require(game:GetService("ReplicatedStorage"):WaitForChild("Nevermore"))
local Promise = require("Promise")

local function wrapPromise(x)
    return Promise.resolve(x)
end

local test = function()
    -- 'a' always has the state 'Started'.
    local a = Promise.delay(2)

    a:finally(function()
        print("Test - this should be called on cancel.")
    end)

    return a
end

local prom = test()

wait()
prom:cancel() -- This will print 'test' to the console immediately.


local prom2 = wrapPromise(test())

wait()
prom2:cancel() -- This will print 'test' to the console after **2 seconds**, as the promise can't be cancelled.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.