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;
+}