diff --git a/package-lock.json b/package-lock.json index 3872776..af95e72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@eslint/markdown": "^7.5.1", "@stylistic/eslint-plugin": "^5.5.0", "@types/fs-extra": "^11.0.4", - "@vitest/coverage-v8": "^4.0.8", + "@vitest/coverage-v8": "^4.0.12", "eslint": "^9.39.1", "fs-extra": "^11.3.2", "globals": "^16.5.0", @@ -22,7 +22,7 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.2", - "vitest": "^4.0.8" + "vitest": "^4.0.12" }, "engines": { "node": "^24.x" @@ -1547,14 +1547,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.8.tgz", - "integrity": "sha512-wQgmtW6FtPNn4lWUXi8ZSYLpOIb92j3QCujxX3sQ81NTfQ/ORnE0HtK7Kqf2+7J9jeveMGyGyc4NWc5qy3rC4A==", + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.12.tgz", + "integrity": "sha512-d+w9xAFJJz6jyJRU4BUU7MH409Ush7FWKNkxJU+jASKg6WX33YT0zc+YawMR1JesMWt9QRFQY/uAD3BTn23FaA==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.0.8", + "@vitest/utils": "4.0.12", "ast-v8-to-istanbul": "^0.3.8", "debug": "^4.4.3", "istanbul-lib-coverage": "^3.2.2", @@ -1569,8 +1569,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.0.8", - "vitest": "4.0.8" + "@vitest/browser": "4.0.12", + "vitest": "4.0.12" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1579,17 +1579,17 @@ } }, "node_modules/@vitest/expect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.8.tgz", - "integrity": "sha512-Rv0eabdP/xjAHQGr8cjBm+NnLHNoL268lMDK85w2aAGLFoVKLd8QGnVon5lLtkXQCoYaNL0wg04EGnyKkkKhPA==", + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.12.tgz", + "integrity": "sha512-is+g0w8V3/ZhRNrRizrJNr8PFQKwYmctWlU4qg8zy5r9aIV5w8IxXLlfbbxJCwSpsVl2PXPTm2/zruqTqz3QSg==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.8", - "@vitest/utils": "4.0.8", - "chai": "^6.2.0", + "@vitest/spy": "4.0.12", + "@vitest/utils": "4.0.12", + "chai": "^6.2.1", "tinyrainbow": "^3.0.3" }, "funding": { @@ -1597,13 +1597,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.8.tgz", - "integrity": "sha512-9FRM3MZCedXH3+pIh+ME5Up2NBBHDq0wqwhOKkN4VnvCiKbVxddqH9mSGPZeawjd12pCOGnl+lo/ZGHt0/dQSg==", + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.12.tgz", + "integrity": "sha512-GsmA/tD5Ht3RUFoz41mZsMU1AXch3lhmgbTnoSPTdH231g7S3ytNN1aU0bZDSyxWs8WA7KDyMPD5L4q6V6vj9w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.8", + "@vitest/spy": "4.0.12", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1624,9 +1624,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.8.tgz", - "integrity": "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg==", + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.12.tgz", + "integrity": "sha512-R7nMAcnienG17MvRN8TPMJiCG8rrZJblV9mhT7oMFdBXvS0x+QD6S1G4DxFusR2E0QIS73f7DqSR1n87rrmE+g==", "dev": true, "license": "MIT", "dependencies": { @@ -1637,13 +1637,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.8.tgz", - "integrity": "sha512-mdY8Sf1gsM8hKJUQfiPT3pn1n8RF4QBcJYFslgWh41JTfrK1cbqY8whpGCFzBl45LN028g0njLCYm0d7XxSaQQ==", + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.12.tgz", + "integrity": "sha512-hDlCIJWuwlcLumfukPsNfPDOJokTv79hnOlf11V+n7E14rHNPz0Sp/BO6h8sh9qw4/UjZiKyYpVxK2ZNi+3ceQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.8", + "@vitest/utils": "4.0.12", "pathe": "^2.0.3" }, "funding": { @@ -1651,13 +1651,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.8.tgz", - "integrity": "sha512-Nar9OTU03KGiubrIOFhcfHg8FYaRaNT+bh5VUlNz8stFhCZPNrJvmZkhsr1jtaYvuefYFwK2Hwrq026u4uPWCw==", + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.12.tgz", + "integrity": "sha512-2jz9zAuBDUSbnfyixnyOd1S2YDBrZO23rt1bicAb6MA/ya5rHdKFRikPIDpBj/Dwvh6cbImDmudegnDAkHvmRQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.8", + "@vitest/pretty-format": "4.0.12", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1666,9 +1666,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.8.tgz", - "integrity": "sha512-nvGVqUunyCgZH7kmo+Ord4WgZ7lN0sOULYXUOYuHr55dvg9YvMz3izfB189Pgp28w0vWFbEEfNc/c3VTrqrXeA==", + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.12.tgz", + "integrity": "sha512-GZjI9PPhiOYNX8Nsyqdw7JQB+u0BptL5fSnXiottAUBHlcMzgADV58A7SLTXXQwcN1yZ6gfd1DH+2bqjuUlCzw==", "dev": true, "license": "MIT", "funding": { @@ -1676,13 +1676,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.8.tgz", - "integrity": "sha512-pdk2phO5NDvEFfUTxcTP8RFYjVj/kfLSPIN5ebP2Mu9kcIMeAQTbknqcFEyBcC4z2pJlJI9aS5UQjcYfhmKAow==", + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.12.tgz", + "integrity": "sha512-DVS/TLkLdvGvj1avRy0LSmKfrcI9MNFvNGN6ECjTUHWJdlcgPDOXhjMis5Dh7rBH62nAmSXnkPbE+DZ5YD75Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.8", + "@vitest/pretty-format": "4.0.12", "tinyrainbow": "^3.0.3" }, "funding": { @@ -2580,9 +2580,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -4421,20 +4421,20 @@ } }, "node_modules/vitest": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.8.tgz", - "integrity": "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg==", + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.12.tgz", + "integrity": "sha512-pmW4GCKQ8t5Ko1jYjC3SqOr7TUKN7uHOHB/XGsAIb69eYu6d1ionGSsb5H9chmPf+WeXt0VE7jTXsB1IvWoNbw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vitest/expect": "4.0.8", - "@vitest/mocker": "4.0.8", - "@vitest/pretty-format": "4.0.8", - "@vitest/runner": "4.0.8", - "@vitest/snapshot": "4.0.8", - "@vitest/spy": "4.0.8", - "@vitest/utils": "4.0.8", + "@vitest/expect": "4.0.12", + "@vitest/mocker": "4.0.12", + "@vitest/pretty-format": "4.0.12", + "@vitest/runner": "4.0.12", + "@vitest/snapshot": "4.0.12", + "@vitest/spy": "4.0.12", + "@vitest/utils": "4.0.12", "debug": "^4.4.3", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", @@ -4460,12 +4460,13 @@ }, "peerDependencies": { "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", "@types/debug": "^4.1.12", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.8", - "@vitest/browser-preview": "4.0.8", - "@vitest/browser-webdriverio": "4.0.8", - "@vitest/ui": "4.0.8", + "@vitest/browser-playwright": "4.0.12", + "@vitest/browser-preview": "4.0.12", + "@vitest/browser-webdriverio": "4.0.12", + "@vitest/ui": "4.0.12", "happy-dom": "*", "jsdom": "*" }, @@ -4473,6 +4474,9 @@ "@edge-runtime/vm": { "optional": true }, + "@opentelemetry/api": { + "optional": true + }, "@types/debug": { "optional": true }, diff --git a/package.json b/package.json index 9bed208..754857b 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@eslint/markdown": "^7.5.1", "@stylistic/eslint-plugin": "^5.5.0", "@types/fs-extra": "^11.0.4", - "@vitest/coverage-v8": "^4.0.8", + "@vitest/coverage-v8": "^4.0.12", "eslint": "^9.39.1", "fs-extra": "^11.3.2", "globals": "^16.5.0", @@ -56,6 +56,6 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.2", - "vitest": "^4.0.8" + "vitest": "^4.0.12" } } diff --git a/src/lock.test.ts b/src/lock.test.ts index f464de5..0edd151 100644 --- a/src/lock.test.ts +++ b/src/lock.test.ts @@ -1,6 +1,8 @@ -import { describe, test } from "vitest"; +import { describe, test, vi } from "vitest"; +import type { BeforeEachListener, AfterEachListener } from "@vitest/runner"; import { lock } from "./index.js"; -describe("simple use", () => { +describe("simple use", (args) => { + useUnhandleRejectionLogging(args); { const name = "simple lock"; test.concurrent("simple lock", async ({ expect }) => { @@ -128,8 +130,8 @@ describe("simple use", () => { } ], }); - await expect(lock1.release()).resolves.toBe(true); - await expect(lock2.release()).resolves.toBe(true); + await expect(lock1.release()).resolves.toBeUndefined(); + await expect(lock2.release()).resolves.toBeUndefined(); await expect(query()).resolves.toEqual({ held: [ @@ -165,9 +167,18 @@ describe("simple use", () => { const controller = new AbortController(); const signal = controller.signal; await using _ = await request(); - const lock2Wait = request({ signal }); - controller.abort(); - await expect(lock2Wait).rejects.toThrow(new DOMException("This operation was aborted", "AbortError")); + let reasonWait: Promise; + let abortWait: Promise; + { + await using _ = fakeTimeer(); + reasonWait = request({ signal }).catch(reason => reason); + abortWait = timeout().then(() => controller.abort()); + } + await Promise.allSettled([reasonWait, abortWait]); + await expect(reasonWait).resolves.toEqual(expect.objectContaining({ + message: "This operation was aborted", + name: "AbortError", + })); } // `signal` only affects the lock acquisition and does not affect the release. { @@ -175,7 +186,7 @@ describe("simple use", () => { const signal = controller.signal; await using lock1 = await request({ signal }); controller.abort(); - await expect(lock1.release()).resolves.toBe(true); + await expect(lock1.release()).resolves.toBeUndefined(); } }); } @@ -205,8 +216,8 @@ describe("simple use", () => { }); expect(lock3).toBeDefined(); expect(lock3.name).toBe(name); - await expect(lock1.release()).resolves.toBe(false); - await expect(lock3.release()).resolves.toBe(true); + await expect(lock1.release()).resolves.toBeUndefined(); + await expect(lock3.release()).resolves.toBeUndefined(); await using lock2 = await lock2Wait; expect(lock2).toBeDefined(); if (!lock2) return; @@ -223,12 +234,16 @@ describe("simple use", () => { ifAvailable: true, steal: true, }); - expect(lockWait).rejects.toThrow(new DOMException("ifAvailable and steal are mutually exclusive", "NotSupportedError")); + await expect(lockWait).rejects.toThrowError(expect.objectContaining({ + message: "ifAvailable and steal are mutually exclusive", + name: "NotSupportedError" + })); } }); } }); -describe("hard error pattern", () => { +describe("hard error pattern", (args) => { + useUnhandleRejectionLogging(args); const name = "not found navigator.locks"; test("not found navigator.locks", async ({ expect }) => { const locks = (globalThis.navigator as unknown as { locks: LockManager }).locks; @@ -237,7 +252,9 @@ describe("hard error pattern", () => { value: undefined, }); try { - expect(() => lock(name)).toThrow(new Error("navigator.locks is not found. required options.locks argument.")); + expect(() => lock(name)).toThrowError(expect.objectContaining({ + message: "navigator.locks is not found. required options.locks argument." + })); const { request, query } = lock(name, { locks }); { @@ -265,3 +282,99 @@ describe("hard error pattern", () => { } }); }); + +/** + * Promise base setTimeout with abort signal + * @param ms + * @param options + * @returns + */ +async function timeout(ms?: number, options?: { signal?: AbortSignal }) { + const { resolve, promise } = Promise.withResolvers(); + const clear = setTimeout(resolve, ms); + if (options?.signal) { + options.signal.addEventListener("abort", abort, { once: true }); + } + try { + return await promise; + } finally { + if (options?.signal) { + options.signal.removeEventListener("abort", abort); + } + } + function abort() { + clearTimeout(clear); + resolve(); + } +} + +/** + * Handle unhandledRejection event and return disposable to off the event. + * @param onUnhandledRejection + * @returns + */ +function unhandleRejection(onUnhandledRejection?: (reason: unknown, promise: Promise) => void) { + onUnhandledRejection ??= () => undefined; + process.on("unhandledRejection", onUnhandledRejection); + return { + [Symbol.dispose]: off, + }; + function off() { + process.off("unhandledRejection", onUnhandledRejection!); + } +} + +/** + * use fake timer and return async disposable to restore real timer. + * @returns + */ +function fakeTimeer() { + vi.useFakeTimers(); + return { + advanceTimersByTimeAsync, + runAllTimersAsync, + [Symbol.asyncDispose]: runAllTimersAsync, + }; + async function advanceTimersByTimeAsync(time: number) { + await vi.advanceTimersByTimeAsync(time); + } + async function runAllTimersAsync() { + await vi.runAllTimersAsync(); + } +} + +/** + * describe unhandledRejection logging utility + * @param param0 + */ +function useUnhandleRejectionLogging({ beforeEach, afterEach } + : { + beforeEach: (fn: BeforeEachListener, timeout?: number) => void, + afterEach: (fn: AfterEachListener, timeout?: number) => void + }) { + type HandlerInstance = { + [Symbol.dispose]: () => void, + reasones?: unknown[] | undefined, + } + const handles = new Map(); + beforeEach(({ task: { id } }) => { + const instance = {} as HandlerInstance; + const disposable = unhandleRejection(callback.bind(instance)); + (instance as { [Symbol.dispose]?: () => void })[Symbol.dispose] = disposable[Symbol.dispose].bind(disposable); + + handles.set(id, instance); + }); + afterEach(({ task: { id } }) => { + const instance = handles.get(id); + { + using _ = instance; + } + if (!(instance?.reasones)) return; + for (const reason of instance.reasones) { + console.error(reason); + } + }); + function callback(this: HandlerInstance, reason: unknown) { + (this.reasones ??= []).push(reason); + } +} \ No newline at end of file diff --git a/src/request.ts b/src/request.ts index f7ecb54..63f45b0 100644 --- a/src/request.ts +++ b/src/request.ts @@ -25,17 +25,14 @@ export async function request(this: InnerLock, options?: LockOptions): Promise(); - // case3: LockManager.request() result - const { resolve: requestResolve, promise: requestPromise, reject: requestReject } = Promise.withResolvers(); - // #endregion // Request the lock using LockManager API - (options + const requestPromise = (options ? this.locks.request(this.name, options, callback) - : this.locks.request(this.name, callback)) - .then(requestResolve, requestReject); - + : this.locks.request(this.name, callback)); + let resolved = false; + requestPromise.finally(() => resolved = true); // Wait for either successful acquisition or rejection const result = await Promise.race([ callbackPromise, @@ -47,7 +44,10 @@ export async function request(this: InnerLock, options?: LockOptions): Promise; + } // Wait for the callback to resolve with the actual lock const lock = await callbackPromise; @@ -55,7 +55,7 @@ export async function request(this: InnerLock, options?: LockOptions): Promise { - await release(); + if (resolved) return; + releaseResolve(); + return await requestPromise; } } -/** - * Helper to return true - */ -export function returnTrue() { - return true; -} - -/** - * Helper to return false - */ -export function returnFalse() { - return false; +export function returnUndefined() { + return undefined; } diff --git a/src/types/Releasable.ts b/src/types/Releasable.ts index 7c99e3a..a1f80de 100644 --- a/src/types/Releasable.ts +++ b/src/types/Releasable.ts @@ -1,4 +1,4 @@ /** * A type representing an object that can release a lock asynchronously. */ -export type Releasable = { release(): Promise; }; +export type Releasable = { release(): Promise; };