diff --git a/doc/api/globals.md b/doc/api/globals.md index 769e5aca37a67f..0de73e58e9414d 100644 --- a/doc/api/globals.md +++ b/doc/api/globals.md @@ -768,6 +768,55 @@ consisting of the runtime name and major version number. console.log(`The user-agent is ${navigator.userAgent}`); // Prints "Node.js/21" ``` +### `navigator.locks` + + + +> Stability: 1 - Experimental + +The `navigator.locks` read-only property returns a [`LockManager`][] instance that +can be used to coordinate access to resources that may be shared across multiple +threads within the same process. This global implementation matches the semantics +of the [browser `LockManager`][] API. + +```mjs +// Request an exclusive lock +await navigator.locks.request('my_resource', async (lock) => { + // The lock has been acquired. + console.log(`Lock acquired: ${lock.name}`); + // Lock is automatically released when the function returns +}); + +// Request a shared lock +await navigator.locks.request('shared_resource', { mode: 'shared' }, async (lock) => { + // Multiple shared locks can be held simultaneously + console.log(`Shared lock acquired: ${lock.name}`); +}); +``` + +```cjs +// Request an exclusive lock +navigator.locks.request('my_resource', async (lock) => { + // The lock has been acquired. + console.log(`Lock acquired: ${lock.name}`); + // Lock is automatically released when the function returns +}).then(() => { + console.log('Lock released'); +}); + +// Request a shared lock +navigator.locks.request('shared_resource', { mode: 'shared' }, async (lock) => { + // Multiple shared locks can be held simultaneously + console.log(`Shared lock acquired: ${lock.name}`); +}).then(() => { + console.log('Shared lock released'); +}); +``` + +See [`worker.locks`][] for detailed API documentation. + ## Class: `PerformanceEntry` + +> Stability: 1 - Experimental + +* {LockManager} + +An instance of a [`LockManager`][LockManager] that can be used to coordinate +access to resources that may be shared across multiple threads within the same +process. The API mirrors the semantics of the +[browser `LockManager`][] + +### Class: `Lock` + + + +The `Lock` interface provides information about a lock that has been granted via +[`locks.request()`][locks.request()] + +#### `lock.name` + + + +* {string} + +The name of the lock. + +#### `lock.mode` + + + +* {string} + +The mode of the lock. Either `shared` or `exclusive`. + +### Class: `LockManager` + + + +The `LockManager` interface provides methods for requesting and introspecting +locks. To obtain a `LockManager` instance use + +```mjs +import { locks } from 'node:worker_threads'; +``` + +```cjs +'use strict'; + +const { locks } = require('node:worker_threads'); +``` + +This implementation matches the [browser `LockManager`][] API. + +#### `locks.request(name[, options], callback)` + + + +* `name` {string} +* `options` {Object} + * `mode` {string} Either `'exclusive'` or `'shared'`. **Default:** `'exclusive'`. + * `ifAvailable` {boolean} If `true`, the request will only be granted if the + lock is not already held. If it cannot be granted, `callback` will be + invoked with `null` instead of a `Lock` instance. **Default:** `false`. + * `steal` {boolean} If `true`, any existing locks with the same name are + released and the request is granted immediately, pre-empting any queued + requests. **Default:** `false`. + * `signal` {AbortSignal} that can be used to abort a + pending (but not yet granted) lock request. +* `callback` {Function} Invoked once the lock is granted (or immediately with + `null` if `ifAvailable` is `true` and the lock is unavailable). The lock is + released automatically when the function returns, or—if the function returns + a promise—when that promise settles. +* Returns: {Promise} Resolves once the lock has been released. + +```mjs +import { locks } from 'node:worker_threads'; + +await locks.request('my_resource', async (lock) => { + // The lock has been acquired. +}); +// The lock has been released here. +``` + +```cjs +'use strict'; + +const { locks } = require('node:worker_threads'); + +locks.request('my_resource', async (lock) => { + // The lock has been acquired. +}).then(() => { + // The lock has been released here. +}); +``` + +#### `locks.query()` + + + +* Returns: {Promise} + +Resolves with a `LockManagerSnapshot` describing the currently held and pending +locks for the current process. + +```mjs +import { locks } from 'node:worker_threads'; + +const snapshot = await locks.query(); +for (const lock of snapshot.held) { + console.log(`held lock: name ${lock.name}, mode ${lock.mode}`); +} +for (const pending of snapshot.pending) { + console.log(`pending lock: name ${pending.name}, mode ${pending.mode}`); +} +``` + +```cjs +'use strict'; + +const { locks } = require('node:worker_threads'); + +locks.query().then((snapshot) => { + for (const lock of snapshot.held) { + console.log(`held lock: name ${lock.name}, mode ${lock.mode}`); + } + for (const pending of snapshot.pending) { + console.log(`pending lock: name ${pending.name}, mode ${pending.mode}`); + } +}); +``` + ## Class: `BroadcastChannel extends EventTarget` + + + + + + + + + diff --git a/test/fixtures/wpt/web-locks/query-empty.https.any.js b/test/fixtures/wpt/web-locks/query-empty.https.any.js new file mode 100644 index 00000000000000..88ffdb7f810d6d --- /dev/null +++ b/test/fixtures/wpt/web-locks/query-empty.https.any.js @@ -0,0 +1,18 @@ +// META: title=Web Locks API: navigator.locks.query method - no locks held +// META: script=resources/helpers.js +// META: global=window,dedicatedworker,sharedworker,serviceworker + +'use strict'; + +promise_test(async t => { + const state = await navigator.locks.query(); + + assert_own_property(state, 'pending', 'State has `pending` property'); + assert_true(Array.isArray(state.pending), + 'State `pending` property is an array'); + assert_array_equals(state.pending, [], 'Pending array is empty'); + + assert_own_property(state, 'held', 'State has `held` property'); + assert_true(Array.isArray(state.held), 'State `held` property is an array'); + assert_array_equals(state.held, [], 'Held array is empty'); +}, 'query() returns dictionary with empty arrays when no locks are held'); diff --git a/test/fixtures/wpt/web-locks/query-ordering.https.html b/test/fixtures/wpt/web-locks/query-ordering.https.html new file mode 100644 index 00000000000000..d5e722baf75b6b --- /dev/null +++ b/test/fixtures/wpt/web-locks/query-ordering.https.html @@ -0,0 +1,131 @@ + + +Web Locks API: navigator.locks.query ordering + + + + + + diff --git a/test/fixtures/wpt/web-locks/query.https.any.js b/test/fixtures/wpt/web-locks/query.https.any.js new file mode 100644 index 00000000000000..14fdeca7a426c4 --- /dev/null +++ b/test/fixtures/wpt/web-locks/query.https.any.js @@ -0,0 +1,227 @@ +// META: title=Web Locks API: navigator.locks.query method +// META: script=resources/helpers.js + +'use strict'; + +// Returns an array of the modes for the locks with matching name. +function modes(list, name) { + return list.filter(item => item.name === name).map(item => item.mode); +} +// Returns an array of the clientIds for the locks with matching name. +function clients(list, name) { + return list.filter(item => item.name === name).map(item => item.clientId); +} + +promise_test(async t => { + const res = uniqueName(t); + + await navigator.locks.request(res, async lock1 => { + // Attempt to request this again - should be blocked. + let lock2_acquired = false; + navigator.locks.request(res, lock2 => { lock2_acquired = true; }); + + // Verify that it was blocked. + await navigator.locks.request(res, {ifAvailable: true}, async lock3 => { + assert_false(lock2_acquired, 'second request should be blocked'); + assert_equals(lock3, null, 'third request should have failed'); + + const state = await navigator.locks.query(); + + assert_own_property(state, 'pending', 'State has `pending` property'); + assert_true(Array.isArray(state.pending), + 'State `pending` property is an array'); + const pending_info = state.pending[0]; + assert_own_property(pending_info, 'name', + 'Pending info dictionary has `name` property'); + assert_own_property(pending_info, 'mode', + 'Pending info dictionary has `mode` property'); + assert_own_property(pending_info, 'clientId', + 'Pending info dictionary has `clientId` property'); + + assert_own_property(state, 'held', 'State has `held` property'); + assert_true(Array.isArray(state.held), + 'State `held` property is an array'); + const held_info = state.held[0]; + assert_own_property(held_info, 'name', + 'Held info dictionary has `name` property'); + assert_own_property(held_info, 'mode', + 'Held info dictionary has `mode` property'); + assert_own_property(held_info, 'clientId', + 'Held info dictionary has `clientId` property'); + }); + }); +}, 'query() returns dictionaries with expected properties'); + + + +promise_test(async t => { + const res = uniqueName(t); + + await navigator.locks.request(res, async lock1 => { + const state = await navigator.locks.query(); + assert_array_equals(modes(state.held, res), ['exclusive'], + 'Held lock should appear once'); + }); + + await navigator.locks.request(res, {mode: 'shared'}, async lock1 => { + const state = await navigator.locks.query(); + assert_array_equals(modes(state.held, res), ['shared'], + 'Held lock should appear once'); + }); +}, 'query() reports individual held locks'); + +promise_test(async t => { + const res1 = uniqueName(t); + const res2 = uniqueName(t); + + await navigator.locks.request(res1, async lock1 => { + await navigator.locks.request(res2, {mode: 'shared'}, async lock2 => { + const state = await navigator.locks.query(); + assert_array_equals(modes(state.held, res1), ['exclusive'], + 'Held lock should appear once'); + assert_array_equals(modes(state.held, res2), ['shared'], + 'Held lock should appear once'); + }); + }); +}, 'query() reports multiple held locks'); + +promise_test(async t => { + const res = uniqueName(t); + + await navigator.locks.request(res, async lock1 => { + // Attempt to request this again - should be blocked. + let lock2_acquired = false; + navigator.locks.request(res, lock2 => { lock2_acquired = true; }); + + // Verify that it was blocked. + await navigator.locks.request(res, {ifAvailable: true}, async lock3 => { + assert_false(lock2_acquired, 'second request should be blocked'); + assert_equals(lock3, null, 'third request should have failed'); + + const state = await navigator.locks.query(); + assert_array_equals(modes(state.pending, res), ['exclusive'], + 'Pending lock should appear once'); + assert_array_equals(modes(state.held, res), ['exclusive'], + 'Held lock should appear once'); + }); + }); +}, 'query() reports pending and held locks'); + +promise_test(async t => { + const res = uniqueName(t); + + await navigator.locks.request(res, {mode: 'shared'}, async lock1 => { + await navigator.locks.request(res, {mode: 'shared'}, async lock2 => { + const state = await navigator.locks.query(); + assert_array_equals(modes(state.held, res), ['shared', 'shared'], + 'Held lock should appear twice'); + }); + }); +}, 'query() reports held shared locks with appropriate count'); + +promise_test(async t => { + const res = uniqueName(t); + + await navigator.locks.request(res, async lock1 => { + let lock2_acquired = false, lock3_acquired = false; + navigator.locks.request(res, {mode: 'shared'}, + lock2 => { lock2_acquired = true; }); + navigator.locks.request(res, {mode: 'shared'}, + lock3 => { lock3_acquired = true; }); + + await navigator.locks.request(res, {ifAvailable: true}, async lock4 => { + assert_equals(lock4, null, 'lock should not be available'); + assert_false(lock2_acquired, 'second attempt should be blocked'); + assert_false(lock3_acquired, 'third attempt should be blocked'); + + const state = await navigator.locks.query(); + assert_array_equals(modes(state.held, res), ['exclusive'], + 'Held lock should appear once'); + + assert_array_equals(modes(state.pending, res), ['shared', 'shared'], + 'Pending lock should appear twice'); + }); + }); +}, 'query() reports pending shared locks with appropriate count'); + +promise_test(async t => { + const res1 = uniqueName(t); + const res2 = uniqueName(t); + + await navigator.locks.request(res1, async lock1 => { + await navigator.locks.request(res2, async lock2 => { + const state = await navigator.locks.query(); + + const res1_clients = clients(state.held, res1); + const res2_clients = clients(state.held, res2); + + assert_equals(res1_clients.length, 1, 'Each lock should have one holder'); + assert_equals(res2_clients.length, 1, 'Each lock should have one holder'); + + assert_array_equals(res1_clients, res2_clients, + 'Both locks should have same clientId'); + }); + }); +}, 'query() reports the same clientId for held locks from the same context'); + +promise_test(async t => { + const res = uniqueName(t); + + const worker = new Worker('resources/worker.js'); + t.add_cleanup(() => { worker.terminate(); }); + + await postToWorkerAndWait( + worker, {op: 'request', name: res, mode: 'shared'}); + + await navigator.locks.request(res, {mode: 'shared'}, async lock => { + const state = await navigator.locks.query(); + const res_clients = clients(state.held, res); + assert_equals(res_clients.length, 2, 'Clients should have same resource'); + assert_not_equals(res_clients[0], res_clients[1], + 'Clients should have different ids'); + }); +}, 'query() reports different ids for held locks from different contexts'); + +promise_test(async t => { + const res1 = uniqueName(t); + const res2 = uniqueName(t); + + const worker = new Worker('resources/worker.js'); + t.add_cleanup(() => { worker.terminate(); }); + + // Acquire 1 in the worker. + await postToWorkerAndWait(worker, {op: 'request', name: res1}) + + // Acquire 2 here. + await new Promise(resolve => { + navigator.locks.request(res2, lock => { + resolve(); + return new Promise(() => {}); // Never released. + }); + }); + + // Request 2 in the worker. + postToWorkerAndWait(worker, {op: 'request', name: res2}); + assert_true((await postToWorkerAndWait(worker, { + op: 'request', name: res2, ifAvailable: true + })).failed, 'Lock request should have failed'); + + // Request 1 here. + navigator.locks.request( + res1, t.unreached_func('Lock should not be acquired')); + + // Verify that we're seeing a deadlock. + const state = await navigator.locks.query(); + const res1_held_clients = clients(state.held, res1); + const res2_held_clients = clients(state.held, res2); + const res1_pending_clients = clients(state.pending, res1); + const res2_pending_clients = clients(state.pending, res2); + + assert_equals(res1_held_clients.length, 1); + assert_equals(res2_held_clients.length, 1); + assert_equals(res1_pending_clients.length, 1); + assert_equals(res2_pending_clients.length, 1); + + assert_equals(res1_held_clients[0], res2_pending_clients[0]); + assert_equals(res2_held_clients[0], res1_pending_clients[0]); +}, 'query() can observe a deadlock'); diff --git a/test/fixtures/wpt/web-locks/resource-names.https.any.js b/test/fixtures/wpt/web-locks/resource-names.https.any.js new file mode 100644 index 00000000000000..1031b3f7ba2e34 --- /dev/null +++ b/test/fixtures/wpt/web-locks/resource-names.https.any.js @@ -0,0 +1,56 @@ +// META: title=Web Locks API: Resources DOMString edge cases +// META: global=window,dedicatedworker,sharedworker,serviceworker + +'use strict'; + +function code_points(s) { + return [...s] + .map(c => '0x' + c.charCodeAt(0).toString(16).toUpperCase()) + .join(' '); +} + +[ + '', // Empty strings + 'abc\x00def', // Embedded NUL + '\uD800', // Unpaired low surrogage + '\uDC00', // Unpaired high surrogage + '\uDC00\uD800', // Swapped surrogate pair + '\uFFFF' // Non-character +].forEach(string => { + promise_test(async t => { + await navigator.locks.request(string, lock => { + assert_equals(lock.name, string, + 'Requested name matches granted name'); + }); + }, 'DOMString: ' + code_points(string)); +}); + +promise_test(async t => { + // '\uD800' treated as a USVString would become '\uFFFD'. + await navigator.locks.request('\uD800', async lock => { + assert_equals(lock.name, '\uD800'); + + // |lock| is held for the duration of this name. It + // Should not block acquiring |lock2| with a distinct + // DOMString. + await navigator.locks.request('\uFFFD', lock2 => { + assert_equals(lock2.name, '\uFFFD'); + }); + + // If we did not time out, this passed. + }); +}, 'Resource names that are not valid UTF-16 are not mangled'); + +promise_test(async t => { + for (const name of ['-', '-foo']) { + await promise_rejects_dom( + t, 'NotSupportedError', + navigator.locks.request(name, lock => {}), + 'Names starting with "-" should be rejected'); + } + let got_lock = false; + await navigator.locks.request('x-anything', lock => { + got_lock = true; + }); + assert_true(got_lock, 'Names with embedded "-" should be accepted'); +}, 'Names cannot start with "-"'); diff --git a/test/fixtures/wpt/web-locks/resources/helpers.js b/test/fixtures/wpt/web-locks/resources/helpers.js new file mode 100644 index 00000000000000..3fb89711ab7a29 --- /dev/null +++ b/test/fixtures/wpt/web-locks/resources/helpers.js @@ -0,0 +1,91 @@ +// Test helpers used by multiple Web Locks API tests. +(() => { + + // Generate a unique resource identifier, using the script path and + // test case name. This is useful to avoid lock interference between + // test cases. + let res_num = 0; + self.uniqueName = (testCase, prefix) => { + return `${self.location.pathname}-${prefix}-${testCase.name}-${++res_num}`; + }; + self.uniqueNameByQuery = () => { + const prefix = new URL(location.href).searchParams.get('prefix'); + return `${prefix}-${++res_num}`; + } + + // Inject an iframe showing the given url into the page, and resolve + // the returned promise when the frame is loaded. + self.iframe = url => new Promise(resolve => { + const element = document.createElement('iframe'); + element.addEventListener( + 'load', () => { resolve(element); }, { once: true }); + element.src = url; + document.documentElement.appendChild(element); + }); + + // Post a message to the target frame, and resolve the returned + // promise when a response comes back. The posted data is annotated + // with unique id to track the response. This assumes the use of + // 'iframe.html' as the frame, which implements this protocol. + let next_request_id = 0; + self.postToFrameAndWait = (frame, data) => { + const iframe_window = frame.contentWindow; + data.rqid = next_request_id++; + iframe_window.postMessage(data, '*'); + return new Promise(resolve => { + const listener = event => { + if (event.source !== iframe_window || event.data.rqid !== data.rqid) + return; + self.removeEventListener('message', listener); + resolve(event.data); + }; + self.addEventListener('message', listener); + }); + }; + + // Post a message to the target worker, and resolve the returned + // promise when a response comes back. The posted data is annotated + // with unique id to track the response. This assumes the use of + // 'worker.js' as the worker, which implements this protocol. + self.postToWorkerAndWait = (worker, data) => { + return new Promise(resolve => { + data.rqid = next_request_id++; + worker.postMessage(data); + const listener = event => { + if (event.data.rqid !== data.rqid) + return; + worker.removeEventListener('message', listener); + resolve(event.data); + }; + worker.addEventListener('message', listener); + }); + }; + + /** + * Request a lock and hold it until the subtest ends. + * @param {*} t test runner object + * @param {string} name lock name + * @param {LockOptions=} options lock options + * @returns + */ + self.requestLockAndHold = (t, name, options = {}) => { + let [promise, resolve] = self.makePromiseAndResolveFunc(); + const released = navigator.locks.request(name, options, () => promise); + // Add a cleanup function that releases the lock by resolving the promise, + // and then waits until the lock is really released, to avoid contaminating + // following tests with temporarily held locks. + t.add_cleanup(() => { + resolve(); + // Cleanup shouldn't fail if the request is aborted. + return released.catch(() => undefined); + }); + return released; + }; + + self.makePromiseAndResolveFunc = () => { + let resolve; + const promise = new Promise(r => { resolve = r; }); + return [promise, resolve]; + }; + +})(); diff --git a/test/fixtures/wpt/web-locks/resources/iframe-parent.html b/test/fixtures/wpt/web-locks/resources/iframe-parent.html new file mode 100644 index 00000000000000..ec63045b4a0561 --- /dev/null +++ b/test/fixtures/wpt/web-locks/resources/iframe-parent.html @@ -0,0 +1,37 @@ + +Helper IFrame + diff --git a/test/fixtures/wpt/web-locks/resources/iframe.html b/test/fixtures/wpt/web-locks/resources/iframe.html new file mode 100644 index 00000000000000..ba63c77bae50d7 --- /dev/null +++ b/test/fixtures/wpt/web-locks/resources/iframe.html @@ -0,0 +1,52 @@ + +Helper IFrame + diff --git a/test/fixtures/wpt/web-locks/resources/parentworker.js b/test/fixtures/wpt/web-locks/resources/parentworker.js new file mode 100644 index 00000000000000..2b2b2c20280c5c --- /dev/null +++ b/test/fixtures/wpt/web-locks/resources/parentworker.js @@ -0,0 +1,10 @@ +// Just transparently forwards things to the child worker + +importScripts("/web-locks/resources/helpers.js"); +const worker = new Worker("/web-locks/resources/worker.js"); + +self.addEventListener("message", async ev => { + const data = await postToWorkerAndWait(worker, ev.data); + data.rqid = ev.data.rqid; + postMessage(data); +}); diff --git a/test/fixtures/wpt/web-locks/resources/partitioned-parent.html b/test/fixtures/wpt/web-locks/resources/partitioned-parent.html new file mode 100644 index 00000000000000..ec19c8dbaa69bb --- /dev/null +++ b/test/fixtures/wpt/web-locks/resources/partitioned-parent.html @@ -0,0 +1,30 @@ + + + + diff --git a/test/fixtures/wpt/web-locks/resources/service-worker.js b/test/fixtures/wpt/web-locks/resources/service-worker.js new file mode 100644 index 00000000000000..027863e33e0457 --- /dev/null +++ b/test/fixtures/wpt/web-locks/resources/service-worker.js @@ -0,0 +1,7 @@ +// Responds to '/clientId' with the request's clientId. +self.addEventListener('fetch', e => { + if (new URL(e.request.url).pathname === '/clientId') { + e.respondWith(new Response(JSON.stringify({clientId: e.clientId}))); + return; + } +}); diff --git a/test/fixtures/wpt/web-locks/resources/sw-controlled-iframe.html b/test/fixtures/wpt/web-locks/resources/sw-controlled-iframe.html new file mode 100644 index 00000000000000..bc5c9bdb838257 --- /dev/null +++ b/test/fixtures/wpt/web-locks/resources/sw-controlled-iframe.html @@ -0,0 +1,35 @@ + + +iframe used in clientId test + diff --git a/test/fixtures/wpt/web-locks/resources/worker.js b/test/fixtures/wpt/web-locks/resources/worker.js new file mode 100644 index 00000000000000..cc71631ba6fa22 --- /dev/null +++ b/test/fixtures/wpt/web-locks/resources/worker.js @@ -0,0 +1,56 @@ +'use strict'; + +// Map of id => function that releases a lock. + +const held = new Map(); +let next_lock_id = 1; + +function processMessage(e) { + const target = this; + + function respond(data) { + target.postMessage(Object.assign(data, {rqid: e.data.rqid})); + } + + switch (e.data.op) { + case 'request': { + const controller = new AbortController(); + navigator.locks.request( + e.data.name, { + mode: e.data.mode || 'exclusive', + ifAvailable: e.data.ifAvailable || false, + signal: e.data.abortImmediately ? controller.signal : undefined, + }, lock => { + if (lock === null) { + respond({ack: 'request', failed: true}); + return; + } + let lock_id = next_lock_id++; + let release; + const promise = new Promise(r => { release = r; }); + held.set(lock_id, release); + respond({ack: 'request', lock_id: lock_id}); + return promise; + }).catch(e => { + respond({ack: 'request', error: e.name}); + }); + if (e.data.abortImmediately) { + controller.abort(); + } + break; + } + + case 'release': + held.get(e.data.lock_id)(); + held.delete(e.data.lock_id); + respond({ack: 'release', lock_id: e.data.lock_id}); + break; + } +} + +self.addEventListener('message', processMessage); + +self.addEventListener('connect', ev => { + // Shared worker case + ev.ports[0].onmessage = processMessage; +}); diff --git a/test/fixtures/wpt/web-locks/secure-context.https.any.js b/test/fixtures/wpt/web-locks/secure-context.https.any.js new file mode 100644 index 00000000000000..29ae7aea475f2d --- /dev/null +++ b/test/fixtures/wpt/web-locks/secure-context.https.any.js @@ -0,0 +1,14 @@ +// META: title=Web Locks API: API requires secure context +// META: global=window,dedicatedworker,sharedworker,serviceworker + +'use strict'; + +test(t => { + assert_true(self.isSecureContext); + assert_idl_attribute(navigator, 'locks', + 'navigator.locks exists in secure context'); + assert_true('LockManager' in self, + 'LockManager is present in secure contexts'); + assert_true('Lock' in self, + 'Lock interface is present in secure contexts'); +}, 'API presence in secure contexts'); diff --git a/test/fixtures/wpt/web-locks/signal.https.any.js b/test/fixtures/wpt/web-locks/signal.https.any.js new file mode 100644 index 00000000000000..5a37e3ae87182e --- /dev/null +++ b/test/fixtures/wpt/web-locks/signal.https.any.js @@ -0,0 +1,261 @@ +// META: title=Web Locks API: AbortSignal integration +// META: script=resources/helpers.js +// META: global=window,dedicatedworker,sharedworker,serviceworker + +'use strict'; + +promise_test(async t => { + const res = uniqueName(t); + + // These cases should not work: + for (const signal of ['string', 12.34, false, {}, Symbol(), () => {}, self]) { + await promise_rejects_js( + t, TypeError, + navigator.locks.request( + res, {signal}, t.unreached_func('callback should not run')), + 'Bindings should throw if the signal option is a not an AbortSignal'); + } +}, 'The signal option must be an AbortSignal'); + +promise_test(async t => { + const res = uniqueName(t); + const controller = new AbortController(); + controller.abort(); + + await promise_rejects_dom( + t, 'AbortError', + navigator.locks.request(res, {signal: controller.signal}, + t.unreached_func('callback should not run')), + 'Request should reject with AbortError'); +}, 'Passing an already aborted signal aborts'); + +promise_test(async t => { + const res = uniqueName(t); + + const controller = new AbortController(); + const reason = 'My dog ate it.'; + controller.abort(reason); + + const promise = + navigator.locks.request(res, {signal: controller.signal}, + t.unreached_func('callback should not run')); + + await promise_rejects_exactly( + t, reason, promise, "Rejection should give the abort reason"); +}, 'Passing an already aborted signal rejects with the custom abort reason.'); + +promise_test(async t => { + const res = uniqueName(t); + + const controller = new AbortController(); + controller.abort(); + + const promise = + navigator.locks.request(res, {signal: controller.signal}, + t.unreached_func('callback should not run')); + + await promise_rejects_exactly( + t, controller.signal.reason, promise, + "Rejection should give the abort reason"); +}, 'Passing an already aborted signal rejects with the default abort reason.'); + +promise_test(async t => { + const res = uniqueName(t); + + // Grab a lock and hold it until this subtest completes. + requestLockAndHold(t, res); + + const controller = new AbortController(); + + const promise = + navigator.locks.request(res, {signal: controller.signal}, + t.unreached_func('callback should not run')); + + // Verify the request is enqueued: + const state = await navigator.locks.query(); + assert_equals(state.held.filter(lock => lock.name === res).length, 1, + 'Number of held locks'); + assert_equals(state.pending.filter(lock => lock.name === res).length, 1, + 'Number of pending locks'); + + const rejected = promise_rejects_dom( + t, 'AbortError', promise, 'Request should reject with AbortError'); + + controller.abort(); + + await rejected; + +}, 'An aborted request results in AbortError'); + +promise_test(async t => { + const res = uniqueName(t); + + // Grab a lock and hold it until this subtest completes. + requestLockAndHold(t, res); + + const controller = new AbortController(); + + const promise = + navigator.locks.request(res, {signal: controller.signal}, lock => {}); + + // Verify the request is enqueued: + const state = await navigator.locks.query(); + assert_equals(state.held.filter(lock => lock.name === res).length, 1, + 'Number of held locks'); + assert_equals(state.pending.filter(lock => lock.name === res).length, 1, + 'Number of pending locks'); + + const rejected = promise_rejects_dom( + t, 'AbortError', promise, 'Request should reject with AbortError'); + + let callback_called = false; + t.step_timeout(() => { + callback_called = true; + controller.abort(); + }, 10); + + await rejected; + assert_true(callback_called, 'timeout should have caused the abort'); + +}, 'Abort after a timeout'); + +promise_test(async t => { + const res = uniqueName(t); + + const controller = new AbortController(); + + let got_lock = false; + await navigator.locks.request( + res, {signal: controller.signal}, async lock => { got_lock = true; }); + + assert_true(got_lock, 'Lock should be acquired if abort is not signaled.'); + +}, 'Signal that is not aborted'); + +promise_test(async t => { + const res = uniqueName(t); + + const controller = new AbortController(); + + let got_lock = false; + const p = navigator.locks.request( + res, {signal: controller.signal}, lock => { got_lock = true; }); + + // Even though lock is grantable, this abort should be processed synchronously. + controller.abort(); + + await promise_rejects_dom(t, 'AbortError', p, 'Request should abort'); + + assert_false(got_lock, 'Request should be aborted if signal is synchronous'); + + await navigator.locks.request(res, lock => { got_lock = true; }); + assert_true(got_lock, 'Subsequent request should not be blocked'); + +}, 'Synchronously signaled abort'); + +promise_test(async t => { + const res = uniqueName(t); + + const controller = new AbortController(); + + // Make a promise that resolves when the lock is acquired. + const [acquired_promise, acquired_func] = makePromiseAndResolveFunc(); + + // Request the lock. + let release_func; + const released_promise = navigator.locks.request( + res, {signal: controller.signal}, lock => { + acquired_func(); + + // Hold lock until release_func is called. + const [waiting_promise, waiting_func] = makePromiseAndResolveFunc(); + release_func = waiting_func; + return waiting_promise; + }); + + // Wait for the lock to be acquired. + await acquired_promise; + + // Signal an abort. + controller.abort(); + + // Release the lock. + release_func('resolved ok'); + + assert_equals(await released_promise, 'resolved ok', + 'Lock released promise should not reject'); + +}, 'Abort signaled after lock granted'); + +promise_test(async t => { + const res = uniqueName(t); + + const controller = new AbortController(); + + // Make a promise that resolves when the lock is acquired. + const [acquired_promise, acquired_func] = makePromiseAndResolveFunc(); + + // Request the lock. + let release_func; + const released_promise = navigator.locks.request( + res, {signal: controller.signal}, lock => { + acquired_func(); + + // Hold lock until release_func is called. + const [waiting_promise, waiting_func] = makePromiseAndResolveFunc(); + release_func = waiting_func; + return waiting_promise; + }); + + // Wait for the lock to be acquired. + await acquired_promise; + + // Release the lock. + release_func('resolved ok'); + + // Signal an abort. + controller.abort(); + + assert_equals(await released_promise, 'resolved ok', + 'Lock released promise should not reject'); + +}, 'Abort signaled after lock released'); + +promise_test(async t => { + const res = uniqueName(t); + + const controller = new AbortController(); + const first = requestLockAndHold(t, res, { signal: controller.signal }); + const next = navigator.locks.request(res, () => "resolved"); + controller.abort(); + + await promise_rejects_dom(t, "AbortError", first, "Request should abort"); + assert_equals( + await next, + "resolved", + "The next request is processed after abort" + ); +}, "Abort should process the next pending lock request"); + +promise_test(async t => { + const res = uniqueName(t); + + const controller = new AbortController(); + const promise = requestLockAndHold(t, res, { signal: controller.signal }); + + const reason = "My cat handled it"; + controller.abort(reason); + + await promise_rejects_exactly(t, reason, promise, "Rejection should give the abort reason"); +}, "Aborted promise should reject with the custom abort reason"); + +promise_test(async t => { + const res = uniqueName(t); + + const controller = new AbortController(); + const promise = requestLockAndHold(t, res, { signal: controller.signal }); + + controller.abort(); + + await promise_rejects_exactly(t, controller.signal.reason, promise, "Should be the same reason"); +}, "Aborted promise should reject with the default abort reason"); diff --git a/test/fixtures/wpt/web-locks/steal.https.any.js b/test/fixtures/wpt/web-locks/steal.https.any.js new file mode 100644 index 00000000000000..d165b9d179fb14 --- /dev/null +++ b/test/fixtures/wpt/web-locks/steal.https.any.js @@ -0,0 +1,91 @@ +// META: title=Web Locks API: steal option +// META: script=resources/helpers.js +// META: global=window,dedicatedworker,sharedworker,serviceworker + +'use strict'; + +const never_settled = new Promise(resolve => { /* never */ }); + +promise_test(async t => { + const res = uniqueName(t); + let callback_called = false; + await navigator.locks.request(res, {steal: true}, lock => { + callback_called = true; + assert_not_equals(lock, null, 'Lock should be granted'); + }); + assert_true(callback_called, 'Callback should be called'); +}, 'Lock available'); + +promise_test(async t => { + const res = uniqueName(t); + let callback_called = false; + + // Grab and hold the lock. + navigator.locks.request(res, lock => never_settled).catch(_ => {}); + + // Steal it. + await navigator.locks.request(res, {steal: true}, lock => { + callback_called = true; + assert_not_equals(lock, null, 'Lock should be granted'); + }); + + assert_true(callback_called, 'Callback should be called'); +}, 'Lock not available'); + +promise_test(async t => { + const res = uniqueName(t); + + // Grab and hold the lock. + const promise = navigator.locks.request(res, lock => never_settled); + const assertion = promise_rejects_dom( + t, 'AbortError', promise, `Initial request's promise should reject`); + + // Steal it. + await navigator.locks.request(res, {steal: true}, lock => {}); + + await assertion; + +}, `Broken lock's release promise rejects`); + +promise_test(async t => { + const res = uniqueName(t); + + // Grab and hold the lock. + navigator.locks.request(res, lock => never_settled).catch(_ => {}); + + // Make a request for it. + let request_granted = false; + const promise = navigator.locks.request(res, lock => { + request_granted = true; + }); + + // Steal it. + await navigator.locks.request(res, {steal: true}, lock => { + assert_false(request_granted, 'Steal should override request'); + }); + + await promise; + assert_true(request_granted, 'Request should eventually be granted'); + +}, `Requested lock's release promise is deferred`); + +promise_test(async t => { + const res = uniqueName(t); + + // Grab and hold the lock. + navigator.locks.request(res, lock => never_settled).catch(_ => {}); + + // Steal it. + let saw_abort = false; + const first_steal = navigator.locks.request( + res, {steal: true}, lock => never_settled).catch(error => { + saw_abort = true; + }); + + // Steal it again. + await navigator.locks.request(res, {steal: true}, lock => {}); + + await first_steal; + assert_true(saw_abort, 'First steal should have aborted'); + +}, 'Last caller wins'); diff --git a/test/fixtures/wpt/web-locks/storage-buckets.tentative.https.any.js b/test/fixtures/wpt/web-locks/storage-buckets.tentative.https.any.js new file mode 100644 index 00000000000000..a6b4f59a95d715 --- /dev/null +++ b/test/fixtures/wpt/web-locks/storage-buckets.tentative.https.any.js @@ -0,0 +1,56 @@ +// META: title=Web Locks API: Storage Buckets have independent lock sets +// META: script=resources/helpers.js +// META: script=/storage/buckets/resources/util.js +// META: global=window,dedicatedworker,sharedworker,serviceworker + +'use strict'; + +/** + * Returns whether bucket1 and bucket2 share locks + * @param {*} t test runner object + * @param {*} bucket1 Storage bucket + * @param {*} bucket2 Storage bucket + */ +async function locksAreShared(t, bucket1, bucket2) { + const lock_name = self.uniqueName(t); + let callback_called = false; + let locks_are_shared; + await bucket1.locks.request(lock_name, async lock => { + await bucket2.locks.request( + lock_name, { ifAvailable: true }, async lock => { + callback_called = true; + locks_are_shared = lock == null; + }); + }); + assert_true(callback_called, 'callback should be called'); + return locks_are_shared; +} + +promise_test(async t => { + await prepareForBucketTest(t); + + const inboxBucket = await navigator.storageBuckets.open('inbox'); + const draftsBucket = await navigator.storageBuckets.open('drafts'); + + assert_true( + await locksAreShared(t, navigator, navigator), + 'The default bucket should share locks with itself'); + + assert_true( + await locksAreShared(t, inboxBucket, inboxBucket), + 'A non default bucket should share locks with itself'); + + assert_false( + await locksAreShared(t, navigator, inboxBucket), + 'The default bucket shouldn\'t share locks with a non default bucket'); + + assert_false( + await locksAreShared(t, draftsBucket, inboxBucket), + 'Two different non default buckets shouldn\'t share locks'); + + const inboxBucket2 = await navigator.storageBuckets.open('inbox'); + + assert_true( + await self.locksAreShared(t, inboxBucket, inboxBucket2), + 'A two instances of the same non default bucket should share locks with theirselves'); +}, 'Storage buckets have independent locks'); diff --git a/test/fixtures/wpt/web-locks/workers.https.html b/test/fixtures/wpt/web-locks/workers.https.html new file mode 100644 index 00000000000000..9fe38dbe38c798 --- /dev/null +++ b/test/fixtures/wpt/web-locks/workers.https.html @@ -0,0 +1,122 @@ + + +Web Locks API: Workers + + + + + diff --git a/test/parallel/test-bootstrap-modules.js b/test/parallel/test-bootstrap-modules.js index caf3b315f78872..a66a3ec9ce360f 100644 --- a/test/parallel/test-bootstrap-modules.js +++ b/test/parallel/test-bootstrap-modules.js @@ -126,9 +126,11 @@ if (isMainThread) { ].forEach(expected.beforePreExec.add.bind(expected.beforePreExec)); } else { // Worker. [ + 'Internal Binding locks', 'NativeModule diagnostics_channel', 'NativeModule internal/abort_controller', 'NativeModule internal/error_serdes', + 'NativeModule internal/locks', 'NativeModule internal/perf/event_loop_utilization', 'NativeModule internal/process/worker_thread_only', 'NativeModule internal/streams/add-abort-signal', diff --git a/test/parallel/test-web-locks-query.js b/test/parallel/test-web-locks-query.js new file mode 100644 index 00000000000000..962abd92f130dd --- /dev/null +++ b/test/parallel/test-web-locks-query.js @@ -0,0 +1,92 @@ +'use strict'; + +require('../common'); +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const { Worker } = require('worker_threads'); + +describe('Web Locks - query missing WPT tests', () => { + it('should report different ids for held locks from different contexts', async () => { + const worker = new Worker(` + const { parentPort } = require('worker_threads'); + + navigator.locks.request('different-contexts-resource', { mode: 'shared' }, async (lock) => { + const state = await navigator.locks.query(); + const heldLocks = state.held.filter(l => l.name === 'different-contexts-resource'); + + parentPort.postMessage({ clientId: heldLocks[0].clientId }); + + await new Promise(resolve => { + parentPort.once('message', () => resolve()); + }); + }).catch(err => parentPort.postMessage({ error: err.message })); + `, { eval: true }); + + const workerResult = await new Promise((resolve) => { + worker.once('message', resolve); + }); + + await navigator.locks.request('different-contexts-resource', { mode: 'shared' }, async (lock) => { + const state = await navigator.locks.query(); + const heldLocks = state.held.filter((l) => l.name === 'different-contexts-resource'); + + const mainClientId = heldLocks[0].clientId; + + assert.notStrictEqual(mainClientId, workerResult.clientId); + + worker.postMessage('release'); + }); + + await worker.terminate(); + }); + + it('should observe a deadlock scenario', async () => { + const worker = new Worker(` + const { parentPort } = require('worker_threads'); + + navigator.locks.request('deadlock-resource-1', async (lock1) => { + parentPort.postMessage({ acquired: 'resource1' }); + + await new Promise(resolve => { + parentPort.once('message', () => resolve()); + }); + + const result = await navigator.locks.request('deadlock-resource-2', + { ifAvailable: true }, (lock2) => lock2 !== null); + + parentPort.postMessage({ acquired2: result }); + + await new Promise(resolve => { + parentPort.once('message', () => resolve()); + }); + }).catch(err => parentPort.postMessage({ error: err.message })); + `, { eval: true }); + + const step1 = await new Promise((resolve) => { + worker.once('message', resolve); + }); + assert.strictEqual(step1.acquired, 'resource1'); + + await navigator.locks.request('deadlock-resource-2', async (lock2) => { + worker.postMessage('try-resource2'); + + const step2 = await new Promise((resolve) => { + worker.once('message', resolve); + }); + assert.strictEqual(step2.acquired2, false); + + const canGetResource1 = await navigator.locks.request('deadlock-resource-1', + { ifAvailable: true }, (lock1) => lock1 !== null); + + assert.strictEqual(canGetResource1, false); + + const state = await navigator.locks.query(); + const resource2Lock = state.held.find((l) => l.name === 'deadlock-resource-2'); + assert(resource2Lock); + + worker.postMessage('release'); + }); + + await worker.terminate(); + }); +}); diff --git a/test/parallel/test-web-locks.js b/test/parallel/test-web-locks.js new file mode 100644 index 00000000000000..e938bf89f3a52b --- /dev/null +++ b/test/parallel/test-web-locks.js @@ -0,0 +1,222 @@ +'use strict'; +// Flags: --expose-gc + +const common = require('../common'); +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const { Worker } = require('node:worker_threads'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +describe('Web Locks with worker threads', () => { + it('should handle exclusive locks', async () => { + const worker = new Worker(` + const { parentPort } = require('worker_threads'); + const assert = require('node:assert'); + + navigator.locks.request('exclusive-test', async (lock) => { + assert.strictEqual(lock.mode, 'exclusive'); + parentPort.postMessage({ success: true }); + }).catch(err => parentPort.postMessage({ error: err.message })); + `, { eval: true }); + + const result = await new Promise((resolve) => { + worker.once('message', resolve); + }); + + assert.strictEqual(result.success, true); + await worker.terminate(); + + await navigator.locks.request('exclusive-test', async (lock) => { + assert.strictEqual(lock.mode, 'exclusive'); + assert.strictEqual(lock.name, 'exclusive-test'); + }); + }); + + it('should handle shared locks', async () => { + const worker = new Worker(` + const { parentPort } = require('worker_threads'); + const assert = require('node:assert'); + + navigator.locks.request('shared-test', { mode: 'shared' }, async (lock) => { + assert.strictEqual(lock.mode, 'shared'); + parentPort.postMessage({ success: true }); + }).catch(err => parentPort.postMessage({ error: err.message })); + `, { eval: true }); + + const result = await new Promise((resolve) => { + worker.once('message', resolve); + }); + assert.strictEqual(result.success, true); + + await navigator.locks.request('shared-test', { mode: 'shared' }, async (lock1) => { + await navigator.locks.request('shared-test', { mode: 'shared' }, async (lock2) => { + assert.strictEqual(lock1.mode, 'shared'); + assert.strictEqual(lock2.mode, 'shared'); + }); + }); + + await worker.terminate(); + }); + + it('should handle steal option - no existing lock', async () => { + await navigator.locks.request('steal-simple', { steal: true }, async (lock) => { + assert.strictEqual(lock.name, 'steal-simple'); + assert.strictEqual(lock.mode, 'exclusive'); + }); + }); + + it('should handle steal option - existing lock', async () => { + let originalLockRejected = false; + + const originalLockPromise = navigator.locks.request('steal-target', async (lock) => { + assert.strictEqual(lock.name, 'steal-target'); + return 'original-completed'; + }).catch((err) => { + originalLockRejected = true; + assert.strictEqual(err.name, 'AbortError'); + assert.strictEqual(err.message, 'The operation was aborted'); + return 'original-rejected'; + }); + + const stealResult = await navigator.locks.request('steal-target', { steal: true }, async (stolenLock) => { + assert.strictEqual(stolenLock.name, 'steal-target'); + assert.strictEqual(stolenLock.mode, 'exclusive'); + return 'steal-completed'; + }); + + assert.strictEqual(stealResult, 'steal-completed'); + + const originalResult = await originalLockPromise; + assert.strictEqual(originalLockRejected, true); + assert.strictEqual(originalResult, 'original-rejected'); + }); + + it('should handle ifAvailable option', async () => { + await navigator.locks.request('ifavailable-test', async () => { + const result = await navigator.locks.request('ifavailable-test', { ifAvailable: true }, (lock) => { + return lock; // should be null + }); + + assert.strictEqual(result, null); + + const availableResult = await navigator.locks.request('ifavailable-different-resource', + { ifAvailable: true }, (lock) => { + return lock !== null; + }); + + assert.strictEqual(availableResult, true); + }); + }); + + it('should handle AbortSignal', async () => { + const worker = new Worker(` + const { parentPort } = require('worker_threads'); + const assert = require('node:assert'); + + const controller = new AbortController(); + + navigator.locks.request('signal-after-grant', { signal: controller.signal }, async (lock) => { + parentPort.postMessage({ acquired: true }); + + setTimeout(() => controller.abort(), 50); + + await new Promise(resolve => setTimeout(resolve, 100)); + return 'completed successfully'; + }).then(result => { + parentPort.postMessage({ resolved: result }); + }).catch(err => { + parentPort.postMessage({ rejected: err.name }); + }); + `, { eval: true }); + + const acquired = await new Promise((resolve) => { + worker.once('message', resolve); + }); + assert.strictEqual(acquired.acquired, true); + + const result = await new Promise((resolve) => { + worker.once('message', resolve); + }); + assert.strictEqual(result.resolved, 'completed successfully'); + + await worker.terminate(); + }); + + it('should handle many concurrent locks without hanging', async () => { + if (global.gc) global.gc(); + const before = process.memoryUsage().rss; + + let callbackCount = 0; + let resolveCount = 0; + + const promises = []; + for (let i = 0; i < 100; i++) { + const promise = navigator.locks.request(`test-${i}`, async (lock) => { + callbackCount++; + const innerPromise = navigator.locks.request(`inner-${i}`, async () => { + resolveCount++; + return 'done'; + }); + await innerPromise; + return `completed-${lock.name}`; + }); + + promises.push(promise); + } + + await Promise.all(promises); + + if (global.gc) global.gc(); + + const after = process.memoryUsage().rss; + + assert.strictEqual(callbackCount, 100); + assert.strictEqual(resolveCount, 100); + assert(after < before * 3); + }); + + it('should preserve AsyncLocalStorage context across lock callback', async () => { + const als = new AsyncLocalStorage(); + const store = { id: 'lock' }; + + als.run(store, () => { + navigator.locks + .request('als-context-test', async () => { + assert.strictEqual(als.getStore(), store); + }) + .then(common.mustCall()); + }); + }); + + it('should clean up when worker is terminated with a pending lock', async () => { + // Acquire the lock in the main thread so that the worker's request will be pending + await navigator.locks.request('cleanup-test', async () => { + // Launch a worker that requests the same lock + const worker = new Worker(` + const { parentPort } = require('worker_threads'); + + parentPort.postMessage({ requesting: true }); + + navigator.locks.request('cleanup-test', async () => { + return 'should-not-complete'; + }).catch(err => { + parentPort.postMessage({ error: err.name }); + }); + `, { eval: true }); + + const requestSignal = await new Promise((resolve) => { + worker.once('message', resolve); + }); + + assert.strictEqual(requestSignal.requesting, true); + + await worker.terminate(); + + }); + + // Request the lock again to make sure cleanup succeeded + await navigator.locks.request('cleanup-test', async (lock) => { + assert.strictEqual(lock.name, 'cleanup-test'); + }); + }); +}); diff --git a/test/sequential/test-async-wrap-getasyncid.js b/test/sequential/test-async-wrap-getasyncid.js index a75207b66e6633..5568cde0bc2a3a 100644 --- a/test/sequential/test-async-wrap-getasyncid.js +++ b/test/sequential/test-async-wrap-getasyncid.js @@ -71,6 +71,7 @@ const { getSystemErrorName } = require('util'); delete providers.QUIC_ENDPOINT; delete providers.QUIC_SESSION; delete providers.QUIC_STREAM; + delete providers.LOCKS; const objKeys = Object.keys(providers); if (objKeys.length > 0) diff --git a/test/wpt/status/web-locks.json b/test/wpt/status/web-locks.json new file mode 100644 index 00000000000000..5e5bf3ba197237 --- /dev/null +++ b/test/wpt/status/web-locks.json @@ -0,0 +1,45 @@ +{ + "idlharness.https.any.js": { + "fail": { + "expected": [ + "LockManager interface: existence and properties of interface object", + "LockManager interface object length", + "LockManager interface object name", + "LockManager interface: existence and properties of interface prototype object", + "LockManager interface: existence and properties of interface prototype object's \"constructor\" property", + "LockManager interface: existence and properties of interface prototype object's @@unscopables property", + "LockManager interface: operation request(DOMString, LockGrantedCallback)", + "LockManager interface: operation request(DOMString, LockOptions, LockGrantedCallback)", + "LockManager interface: operation query()", + "LockManager must be primary interface of navigator.locks", + "Lock interface: existence and properties of interface object", + "Lock interface object length", + "Lock interface object name", + "Lock interface: existence and properties of interface prototype object", + "Lock interface: existence and properties of interface prototype object's \"constructor\" property", + "Lock interface: existence and properties of interface prototype object's @@unscopables property", + "Lock interface: attribute name", + "Lock interface: attribute mode", + "Lock must be primary interface of lock" + ] + } + }, + "non-secure-context.any.js": { + "skip": "navigator.locks is only present in secure contexts" + }, + "query.https.any.js": { + "fail": { + "expected": [ + "query() reports different ids for held locks from different contexts", + "query() can observe a deadlock" + ], + "note": "Browser-specific test" + } + }, + "secure-context.https.any.js": { + "skip": "Different secure context behavior in Node.js" + }, + "storage-buckets.tentative.https.any.js": { + "skip": "Node.js does not implement Storage Buckets API" + } +} diff --git a/test/wpt/test-web-locks.js b/test/wpt/test-web-locks.js new file mode 100644 index 00000000000000..f7080a23757de5 --- /dev/null +++ b/test/wpt/test-web-locks.js @@ -0,0 +1,9 @@ +'use strict'; + +const { WPTRunner } = require('../common/wpt'); + +// Run serially to avoid cross-test interference on the shared LockManager. +const runner = new WPTRunner('web-locks', { concurrency: 1 }); + +runner.pretendGlobalThisAs('Window'); +runner.runJsTests(); diff --git a/tools/doc/type-parser.mjs b/tools/doc/type-parser.mjs index 1a54eb29d15f27..b6d0a69d46743b 100644 --- a/tools/doc/type-parser.mjs +++ b/tools/doc/type-parser.mjs @@ -333,6 +333,10 @@ const customTypesMap = { 'quic.OnHeadersCallback': 'quic.html#callback-onheaderscallback', 'quic.OnTrailersCallback': 'quic.html#callback-ontrailerscallback', 'quic.OnPullCallback': 'quic.html#callback-onpullcallback', + + 'Lock': 'worker_threads.html#class-lock', + 'LockManager': 'worker_threads.html#class-lockmanager', + 'LockManagerSnapshot': 'https://developer.mozilla.org/en-US/docs/Web/API/LockManagerSnapshot', }; const arrayPart = /(?:\[])+$/; diff --git a/typings/globals.d.ts b/typings/globals.d.ts index 1bd3f46d0e2567..7b53a869b9ca11 100644 --- a/typings/globals.d.ts +++ b/typings/globals.d.ts @@ -7,6 +7,7 @@ import { HttpParserBinding } from './internalBinding/http_parser'; import { InspectorBinding } from './internalBinding/inspector'; import { FsBinding } from './internalBinding/fs'; import { FsDirBinding } from './internalBinding/fs_dir'; +import { LocksBinding } from './internalBinding/locks'; import { MessagingBinding } from './internalBinding/messaging'; import { OptionsBinding } from './internalBinding/options'; import { OSBinding } from './internalBinding/os'; @@ -33,6 +34,7 @@ interface InternalBindingMap { fs_dir: FsDirBinding; http_parser: HttpParserBinding; inspector: InspectorBinding; + locks: LocksBinding; messaging: MessagingBinding; modules: ModulesBinding; options: OptionsBinding; diff --git a/typings/internalBinding/locks.d.ts b/typings/internalBinding/locks.d.ts new file mode 100644 index 00000000000000..f0a31d60bd9ac3 --- /dev/null +++ b/typings/internalBinding/locks.d.ts @@ -0,0 +1,31 @@ +declare namespace InternalLocksBinding { + interface LockInfo { + readonly name: string; + readonly mode: 'shared' | 'exclusive'; + readonly clientId: string; + } + + interface LockManagerSnapshot { + readonly held: LockInfo[]; + readonly pending: LockInfo[]; + } + + type LockGrantedCallback = (lock: LockInfo | null) => Promise | any; +} + +export interface LocksBinding { + readonly LOCK_MODE_SHARED: 'shared'; + readonly LOCK_MODE_EXCLUSIVE: 'exclusive'; + readonly LOCK_STOLEN_ERROR: 'LOCK_STOLEN'; + + request( + name: string, + clientId: string, + mode: string, + steal: boolean, + ifAvailable: boolean, + callback: InternalLocksBinding.LockGrantedCallback + ): Promise; + + query(): Promise; +}