diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd80229..aa85c41 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,15 +24,15 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Enable corepack + run: corepack enable + - uses: actions/setup-node@v3 with: node-version: "latest" cache: "yarn" cache-dependency-path: "yarn.lock" - - name: Update yarn - run: yarn set version stable - - name: Install packages run: yarn install --immutable @@ -57,6 +57,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Enable corepack + run: corepack enable + - uses: Roblox/setup-foreman@v1 with: token: ${{ secrets.GITHUB_TOKEN }} @@ -67,9 +70,6 @@ jobs: cache: "yarn" cache-dependency-path: "yarn.lock" - - name: Update yarn - run: yarn set version stable - - name: Install packages run: yarn install --immutable @@ -136,6 +136,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Enable corepack + run: corepack enable + - uses: Roblox/setup-foreman@v1 with: token: ${{ secrets.GITHUB_TOKEN }} @@ -146,9 +149,6 @@ jobs: cache: "yarn" cache-dependency-path: "yarn.lock" - - name: Update yarn - run: yarn set version stable - - name: Install packages run: yarn install --immutable diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 11d118e..dc429eb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,6 +16,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Enable corepack + run: corepack enable + - uses: Roblox/setup-foreman@v1 with: token: ${{ secrets.GITHUB_TOKEN }} @@ -26,9 +29,6 @@ jobs: cache: "yarn" cache-dependency-path: "yarn.lock" - - name: Update yarn - run: yarn set version stable - - name: Install packages run: yarn install --immutable diff --git a/.gitignore b/.gitignore index 62af76b..ed01ac4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ /luacov.*.out node_modules/ -.vscode .yarn build diff --git a/.luau-analyze.json b/.luau-analyze.json new file mode 100644 index 0000000..89f7b87 --- /dev/null +++ b/.luau-analyze.json @@ -0,0 +1,6 @@ +{ + "luau-lsp.require.mode": "relativeToFile", + "luau-lsp.require.directoryAliases": { + "@pkg": "node_modules/.luau-aliases" + } +} diff --git a/.luaurc b/.luaurc new file mode 100644 index 0000000..9a3da68 --- /dev/null +++ b/.luaurc @@ -0,0 +1,10 @@ +{ + "languageMode": "strict", + "lintErrors": true, + "lint": { + "*": true + }, + "aliases": { + "pkg": "./node_modules/.luau-aliases" + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..06bf049 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "luau-lsp.completion.imports.requireStyle": "alwaysRelative", + "luau-lsp.completion.imports.enabled": true, + "luau-lsp.sourcemap.autogenerate": false, + "luau-lsp.sourcemap.enabled": false +} \ No newline at end of file diff --git a/.yarnrc.yml b/.yarnrc.yml index 4b8bff8..3186f3f 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,7 +1 @@ -compressionLevel: mixed - -enableGlobalCache: false - nodeLinker: node-modules - -yarnPath: .yarn/releases/yarn-4.0.2.cjs diff --git a/lib/init.lua b/lib/init.lua index 41d1e64..fe912e0 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -376,9 +376,7 @@ function Promise.defer(executor) local traceback = debug.traceback(nil, 2) local promise promise = Promise._new(traceback, function(resolve, reject, onCancel) - local connection - connection = Promise._timeEvent:Connect(function() - connection:Disconnect() + task.defer(function() local ok, _, result = runExecutor(traceback, executor, resolve, reject, onCancel) if not ok then @@ -1034,10 +1032,10 @@ end --[=[ Returns a Promise that resolves after `seconds` seconds have passed. The Promise resolves with the actual amount of time that was waited. - This function is **not** a wrapper around `wait`. `Promise.delay` uses a custom scheduler which provides more accurate timing. As an optimization, cancelling this Promise instantly removes the task from the scheduler. + This function is a wrapper around `task.delay`. :::warning - Passing `NaN`, infinity, or a number less than 1/60 is equivalent to passing 1/60. + Passing NaN, +Infinity, -Infinity, 0, or any other number less than the duration of a Heartbeat will cause the promise to resolve on the very next Heartbeat. ::: ```lua @@ -1049,102 +1047,14 @@ end @param seconds number @return Promise ]=] -do - -- uses a sorted doubly linked list (queue) to achieve O(1) remove operations and O(n) for insert - - -- the initial node in the linked list - local first - local connection - - function Promise.delay(seconds) - assert(type(seconds) == "number", "Bad argument #1 to Promise.delay, must be a number.") - -- If seconds is -INF, INF, NaN, or less than 1 / 60, assume seconds is 1 / 60. - -- This mirrors the behavior of wait() - if not (seconds >= 1 / 60) or seconds == math.huge then - seconds = 1 / 60 - end - - return Promise._new(debug.traceback(nil, 2), function(resolve, _, onCancel) - local startTime = Promise._getTime() - local endTime = startTime + seconds - - local node = { - resolve = resolve, - startTime = startTime, - endTime = endTime, - } - - if connection == nil then -- first is nil when connection is nil - first = node - connection = Promise._timeEvent:Connect(function() - local threadStart = Promise._getTime() - - while first ~= nil and first.endTime < threadStart do - local current = first - first = current.next - - if first == nil then - connection:Disconnect() - connection = nil - else - first.previous = nil - end - - current.resolve(Promise._getTime() - current.startTime) - end - end) - else -- first is non-nil - if first.endTime < endTime then -- if `node` should be placed after `first` - -- we will insert `node` between `current` and `next` - -- (i.e. after `current` if `next` is nil) - local current = first - local next = current.next - - while next ~= nil and next.endTime < endTime do - current = next - next = current.next - end - - -- `current` must be non-nil, but `next` could be `nil` (i.e. last item in list) - current.next = node - node.previous = current - - if next ~= nil then - node.next = next - next.previous = node - end - else - -- set `node` to `first` - node.next = first - first.previous = node - first = node - end - end - - onCancel(function() - -- remove node from queue - local next = node.next - - if first == node then - if next == nil then -- if `node` is the first and last - connection:Disconnect() - connection = nil - else -- if `node` is `first` and not the last - next.previous = nil - end - first = next - else - local previous = node.previous - -- since `node` is not `first`, then we know `previous` is non-nil - previous.next = next - - if next ~= nil then - next.previous = previous - end - end - end) +function Promise.delay(seconds) + assert(type(seconds) == "number", "Bad argument #1 to Promise.delay, must be a number.") + local startTime = Promise._getTime() + return Promise._new(debug.traceback(nil, 2), function(resolve) + task.delay(seconds, function() + resolve(Promise._getTime() - startTime) end) - end + end) end --[=[ @@ -1221,8 +1131,16 @@ end function Promise.prototype:_andThen(traceback, successHandler, failureHandler) self._unhandledRejection = false + -- If we are already cancelled, we return a cancelled Promise + if self._status == Promise.Status.Cancelled then + local promise = Promise.new(function() end) + promise:cancel() + + return promise + end + -- Create a new promise to follow this part of the chain - return Promise._new(traceback, function(resolve, reject) + return Promise._new(traceback, function(resolve, reject, onCancel) -- Our default callbacks just pass values onto the next promise. -- This lets success and failure cascade correctly! @@ -1240,20 +1158,21 @@ function Promise.prototype:_andThen(traceback, successHandler, failureHandler) -- If we haven't resolved yet, put ourselves into the queue table.insert(self._queuedResolve, successCallback) table.insert(self._queuedReject, failureCallback) + + onCancel(function() + -- These are guaranteed to exist because the cancellation handler is guaranteed to only + -- be called at most once + if self._status == Promise.Status.Started then + table.remove(self._queuedResolve, table.find(self._queuedResolve, successCallback)) + table.remove(self._queuedReject, table.find(self._queuedReject, failureCallback)) + end + end) elseif self._status == Promise.Status.Resolved then -- This promise has already resolved! Trigger success immediately. successCallback(unpack(self._values, 1, self._valuesLength)) elseif self._status == Promise.Status.Rejected then -- This promise died a terrible death! Trigger failure immediately. failureCallback(unpack(self._values, 1, self._valuesLength)) - elseif self._status == Promise.Status.Cancelled then - -- We don't want to call the success handler or the failure handler, - -- we just reject this promise outright. - reject(Error.new({ - error = "Promise is cancelled", - kind = Error.Kind.AlreadyCancelled, - context = "Promise created at\n\n" .. traceback, - })) end end, self) end @@ -1497,26 +1416,47 @@ end Used to set a handler for when the promise resolves, rejects, or is cancelled. ]] -function Promise.prototype:_finally(traceback, finallyHandler, onlyOk) - if not onlyOk then - self._unhandledRejection = false - end +function Promise.prototype:_finally(traceback, finallyHandler) + self._unhandledRejection = false + + local promise = Promise._new(traceback, function(resolve, reject, onCancel) + local handlerPromise + + onCancel(function() + -- The finally Promise is not a proper consumer of self. We don't care about the resolved value. + -- All we care about is running at the end. Therefore, if self has no other consumers, it's safe to + -- cancel. We don't need to hold out cancelling just because there's a finally handler. + self:_consumerCancelled(self) + + if handlerPromise then + handlerPromise:cancel() + end + end) - -- Return a promise chained off of this promise - return Promise._new(traceback, function(resolve, reject) local finallyCallback = resolve if finallyHandler then - finallyCallback = createAdvancer(traceback, finallyHandler, resolve, reject) - end - - if onlyOk then - local callback = finallyCallback finallyCallback = function(...) - if self._status == Promise.Status.Rejected then - return resolve(self) + local ok, _, resultList = runExecutor(traceback, finallyHandler, ...) + local result = resultList[1] + if not ok then + return reject(result) end - return callback(...) + if Promise.is(result) then + handlerPromise = result + + result + :finally(function(status) + if status ~= Promise.Status.Rejected then + resolve(self) + end + end) + :catch(function(...) + reject(...) + end) + else + resolve(self) + end end end @@ -1527,7 +1467,9 @@ function Promise.prototype:_finally(traceback, finallyHandler, onlyOk) -- The promise already settled or was cancelled, run the callback now. finallyCallback(self._status) end - end, self) + end) + + return promise end --[=[ @@ -1626,38 +1568,6 @@ function Promise.prototype:finallyReturn(...) end) end ---[[ - Similar to finally, except rejections are propagated through it. -]] -function Promise.prototype:done(finallyHandler) - assert( - finallyHandler == nil or isCallable(finallyHandler), - string.format(ERROR_NON_FUNCTION, "Promise:done") - ) - return self:_finally(debug.traceback(nil, 2), finallyHandler, true) -end - ---[[ - Calls a callback on `done` with specific arguments. -]] -function Promise.prototype:doneCall(callback, ...) - assert(isCallable(callback), string.format(ERROR_NON_FUNCTION, "Promise:doneCall")) - local length, values = pack(...) - return self:_finally(debug.traceback(nil, 2), function() - return callback(unpack(values, 1, length)) - end, true) -end - ---[[ - Shorthand for a done handler that returns the given value. -]] -function Promise.prototype:doneReturn(...) - local length, values = pack(...) - return self:_finally(debug.traceback(nil, 2), function() - return unpack(values, 1, length) - end, true) -end - --[=[ Yields the current thread until the given Promise completes. Returns the Promise's status, followed by the values that the promise resolved or rejected with. @@ -1671,9 +1581,15 @@ function Promise.prototype:awaitStatus() if self._status == Promise.Status.Started then local thread = coroutine.running() - self:finally(function() - task.spawn(thread) - end) + self + :finally(function() + task.spawn(thread) + end) + -- The finally promise can propagate rejections, so we attach a catch handler to prevent the unhandled + -- rejection warning from appearing + :catch( + function() end + ) coroutine.yield() end diff --git a/package.json b/package.json index e6f06ad..73189c4 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,11 @@ "main": "lib/init.lua", "scripts": { "prepare": "npmluau", - "build-assets": "sh ./scripts/build-assets.sh" + "build-assets": "sh ./scripts/build-assets.sh", + "clean": "rm -rf roblox build node_modules" }, "devDependencies": { - "npmluau": "^0.1.1" + "npmluau": "^0.1.2" }, - "packageManager": "yarn@4.0.2" + "packageManager": "yarn@4.6.0" } diff --git a/yarn.lock b/yarn.lock index 8a0fc79..59d4659 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3,38 +3,38 @@ __metadata: version: 8 - cacheKey: 10 + cacheKey: 10c0 "@jsdotlua/promise@workspace:.": version: 0.0.0-use.local resolution: "@jsdotlua/promise@workspace:." dependencies: - npmluau: "npm:^0.1.1" + npmluau: "npm:^0.1.2" languageName: unknown linkType: soft "commander@npm:^11.0.0": version: 11.1.0 resolution: "commander@npm:11.1.0" - checksum: 66bd2d8a0547f6cb1d34022efb25f348e433b0e04ad76a65279b1b09da108f59a4d3001ca539c60a7a46ea38bcf399fc17d91adad76a8cf43845d8dcbaf5cda1 + checksum: 13cc6ac875e48780250f723fb81c1c1178d35c5decb1abb1b628b3177af08a8554e76b2c0f29de72d69eef7c864d12613272a71fabef8047922bc622ab75a179 languageName: node linkType: hard -"npmluau@npm:^0.1.1": - version: 0.1.1 - resolution: "npmluau@npm:0.1.1" +"npmluau@npm:^0.1.2": + version: 0.1.2 + resolution: "npmluau@npm:0.1.2" dependencies: commander: "npm:^11.0.0" walkdir: "npm:^0.4.1" bin: npmluau: main.js - checksum: 7d8a06998a72e7f002ded33b4c52a5a54431be7ecb8468ddcd28aaeba6d78eeca28fe9db175962e7a30cd12b2405f15a8d2a58f7e7bd5fdba67b5e4f800536b5 + checksum: 665e23da3e4329faaa324bc98cebc23e7a540bde6dabbf8b6033dc7360fc74d635688fe712a919462bfc3d4db8c54c503c7e0cd0bf92c704b55afe4087ff0e3c languageName: node linkType: hard "walkdir@npm:^0.4.1": version: 0.4.1 resolution: "walkdir@npm:0.4.1" - checksum: 54cbe7afc5fb811a55748b0bfa077a9a4aa43f568eb5857db9785af9728e1ad8b1ecf6b9ce6f14b405c6124939a92522e36aaa0397f3f52a9a7a08496f2eebe1 + checksum: 88e635aa9303e9196e4dc15013d2bd4afca4c8c8b4bb27722ca042bad213bb882d3b9141b3b0cca6bfb274f7889b30cf58d6374844094abec0016f335c5414dc languageName: node linkType: hard