worker: add web locks api

PR-URL: https://github.com/nodejs/node/pull/58666
Fixes: https://github.com/nodejs/node/pull/36502
Refs: https://w3c.github.io/web-locks
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Ethan Arrowood <ethan@arrowood.dev>
Reviewed-By: Filip Skokan <panva.ip@gmail.com>
Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Anna Henningsen <anna@addaleax.net>
This commit is contained in:
ishabi 2025-05-30 00:57:31 +02:00 committed by James M Snell
parent 0629a175c0
commit 062e8b5a74
70 changed files with 5030 additions and 0 deletions

View file

@ -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`
<!-- YAML
added: REPLACEME
-->
> 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`
<!-- YAML
@ -1263,6 +1312,7 @@ A browser-compatible implementation of [`WritableStreamDefaultWriter`][].
[`CountQueuingStrategy`]: webstreams.md#class-countqueuingstrategy
[`DecompressionStream`]: webstreams.md#class-decompressionstream
[`EventTarget` and `Event` API]: events.md#eventtarget-and-event-api
[`LockManager`]: worker_threads.md#class-lockmanager
[`MessageChannel`]: worker_threads.md#class-messagechannel
[`MessagePort`]: worker_threads.md#class-messageport
[`PerformanceEntry`]: perf_hooks.md#class-performanceentry
@ -1313,6 +1363,8 @@ A browser-compatible implementation of [`WritableStreamDefaultWriter`][].
[`setTimeout`]: timers.md#settimeoutcallback-delay-args
[`structuredClone`]: https://developer.mozilla.org/en-US/docs/Web/API/structuredClone
[`window.navigator`]: https://developer.mozilla.org/en-US/docs/Web/API/Window/navigator
[`worker.locks`]: worker_threads.md#workerlocks
[browser `LockManager`]: https://developer.mozilla.org/en-US/docs/Web/API/LockManager
[buffer section]: buffer.md
[built-in objects]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects
[timers]: timers.md

View file

@ -755,6 +755,153 @@ if (isMainThread) {
}
```
## `worker.locks`
<!-- YAML
added: REPLACEME
-->
> 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`
<!-- YAML
added: REPLACEME
-->
The `Lock` interface provides information about a lock that has been granted via
[`locks.request()`][locks.request()]
#### `lock.name`
<!-- YAML
added: REPLACEME
-->
* {string}
The name of the lock.
#### `lock.mode`
<!-- YAML
added: REPLACEME
-->
* {string}
The mode of the lock. Either `shared` or `exclusive`.
### Class: `LockManager`
<!-- YAML
added: REPLACEME
-->
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)`
<!-- YAML
added: REPLACEME
-->
* `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()`
<!-- YAML
added: REPLACEME
-->
* 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`
<!-- YAML
@ -1937,6 +2084,7 @@ thread spawned will spawn another until the application crashes.
[Addons worker support]: addons.md#worker-support
[ECMAScript module loader]: esm.md#data-imports
[HTML structured clone algorithm]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
[LockManager]: #class-lockmanager
[Signals events]: process.md#signal-events
[Web Workers]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API
[`'close'` event]: #event-close
@ -1992,7 +2140,9 @@ thread spawned will spawn another until the application crashes.
[`worker.terminate()`]: #workerterminate
[`worker.threadId`]: #workerthreadid_1
[async-resource-worker-pool]: async_context.md#using-asyncresource-for-a-worker-thread-pool
[browser `LockManager`]: https://developer.mozilla.org/en-US/docs/Web/API/LockManager
[browser `MessagePort`]: https://developer.mozilla.org/en-US/docs/Web/API/MessagePort
[child processes]: child_process.md
[contextified]: vm.md#what-does-it-mean-to-contextify-an-object
[locks.request()]: #locksrequestname-options-callback
[v8.serdes]: v8.md#serialization-api

293
lib/internal/locks.js Normal file
View file

@ -0,0 +1,293 @@
'use strict';
const {
ObjectDefineProperties,
Promise,
PromiseResolve,
Symbol,
SymbolToStringTag,
} = primordials;
const {
ERR_ILLEGAL_CONSTRUCTOR,
ERR_INVALID_THIS,
} = require('internal/errors');
const {
kEmptyObject,
lazyDOMException,
} = require('internal/util');
const {
validateAbortSignal,
validateFunction,
} = require('internal/validators');
const { threadId } = require('internal/worker');
const {
converters,
createEnumConverter,
createDictionaryConverter,
} = require('internal/webidl');
const locks = internalBinding('locks');
const kName = Symbol('kName');
const kMode = Symbol('kMode');
const kConstructLock = Symbol('kConstructLock');
const kConstructLockManager = Symbol('kConstructLockManager');
// WebIDL dictionary LockOptions
const convertLockOptions = createDictionaryConverter([
{
key: 'mode',
converter: createEnumConverter('LockMode', [
'shared',
'exclusive',
]),
defaultValue: () => 'exclusive',
},
{
key: 'ifAvailable',
converter: (value) => !!value,
defaultValue: () => false,
},
{
key: 'steal',
converter: (value) => !!value,
defaultValue: () => false,
},
{
key: 'signal',
converter: converters.object,
},
]);
// https://w3c.github.io/web-locks/#api-lock
class Lock {
constructor(symbol = undefined, name, mode) {
if (symbol !== kConstructLock) {
throw new ERR_ILLEGAL_CONSTRUCTOR();
}
this[kName] = name;
this[kMode] = mode;
}
get name() {
if (this instanceof Lock) {
return this[kName];
}
throw new ERR_INVALID_THIS('Lock');
}
get mode() {
if (this instanceof Lock) {
return this[kMode];
}
throw new ERR_INVALID_THIS('Lock');
}
}
ObjectDefineProperties(Lock.prototype, {
name: { __proto__: null, enumerable: true },
mode: { __proto__: null, enumerable: true },
[SymbolToStringTag]: {
__proto__: null,
value: 'Lock',
writable: false,
enumerable: false,
configurable: true,
},
});
// Helper to create Lock objects from internal C++ lock data
function createLock(internalLock) {
return internalLock === null ? null : new Lock(kConstructLock, internalLock.name, internalLock.mode);
}
// Convert LOCK_STOLEN_ERROR to AbortError DOMException
function convertLockError(error) {
if (error?.message === locks.LOCK_STOLEN_ERROR) {
return lazyDOMException('The operation was aborted', 'AbortError');
}
return error;
}
// https://w3c.github.io/web-locks/#api-lock-manager
class LockManager {
constructor(symbol = undefined) {
if (symbol !== kConstructLockManager) {
throw new ERR_ILLEGAL_CONSTRUCTOR();
}
}
/**
* Request a Web Lock for a named resource.
* @param {string} name - The name of the lock resource
* @param {object} [options] - Lock options (optional)
* @param {string} [options.mode] - Lock mode: 'exclusive' or 'shared' default is exclusive
* @param {boolean} [options.ifAvailable] - Only grant if immediately available
* @param {boolean} [options.steal] - Steal existing locks with same name
* @param {AbortSignal} [options.signal] - Signal to abort pending lock request
* @param {Function} callback - Function called when lock is granted
* @returns {Promise} Promise that resolves when the lock is released
* @throws {TypeError} When name is not a string or callback is not a function
* @throws {DOMException} When validation fails or operation is not supported
*/
// https://w3c.github.io/web-locks/#api-lock-manager-request
async request(name, options, callback) {
if (callback === undefined) {
callback = options;
options = undefined;
}
name = converters.DOMString(name);
validateFunction(callback, 'callback');
if (options === undefined || typeof options === 'function') {
options = kEmptyObject;
}
// Convert LockOptions dictionary
options = convertLockOptions(options);
const { mode, ifAvailable, steal, signal } = options;
validateAbortSignal(signal, 'options.signal');
if (signal) {
signal.throwIfAborted();
}
if (name.startsWith('-')) {
// If name starts with U+002D HYPHEN-MINUS (-), then reject promise with a
// "NotSupportedError" DOMException.
throw lazyDOMException('Lock name may not start with hyphen',
'NotSupportedError');
}
if (ifAvailable === true && steal === true) {
// If both options' steal dictionary member and option's
// ifAvailable dictionary member are true, then reject promise with a
// "NotSupportedError" DOMException.
throw lazyDOMException('ifAvailable and steal are mutually exclusive',
'NotSupportedError');
}
if (mode !== locks.LOCK_MODE_EXCLUSIVE && steal === true) {
// If options' steal dictionary member is true and options' mode
// dictionary member is not "exclusive", then return a promise rejected
// with a "NotSupportedError" DOMException.
throw lazyDOMException(`mode: "${locks.LOCK_MODE_SHARED}" and steal are mutually exclusive`,
'NotSupportedError');
}
if (signal && (steal === true || ifAvailable === true)) {
// If options' signal dictionary member is present, and either of
// options' steal dictionary member or options' ifAvailable dictionary
// member is true, then return a promise rejected with a
// "NotSupportedError" DOMException.
throw lazyDOMException('signal cannot be used with steal or ifAvailable',
'NotSupportedError');
}
const clientId = `node-${process.pid}-${threadId}`;
// Handle requests with AbortSignal
if (signal) {
return new Promise((resolve, reject) => {
let lockGranted = false;
const abortListener = () => {
if (!lockGranted) {
reject(signal.reason || lazyDOMException('The operation was aborted', 'AbortError'));
}
};
signal.addEventListener('abort', abortListener, { once: true });
const wrappedCallback = (lock) => {
return PromiseResolve().then(() => {
if (signal.aborted) {
return undefined;
}
lockGranted = true;
return callback(createLock(lock));
});
};
try {
const released = locks.request(
name,
clientId,
mode,
steal,
ifAvailable,
wrappedCallback,
);
// When released promise settles, clean up listener and resolve main promise
released
.then(resolve, (error) => reject(convertLockError(error)))
.finally(() => {
signal.removeEventListener('abort', abortListener);
});
} catch (error) {
signal.removeEventListener('abort', abortListener);
reject(convertLockError(error));
}
});
}
// When ifAvailable: true and lock is not available, C++ passes null to indicate no lock granted
const wrapCallback = (internalLock) => {
const lock = createLock(internalLock);
return callback(lock);
};
// Standard request without signal
try {
return await locks.request(name, clientId, mode, steal, ifAvailable, wrapCallback);
} catch (error) {
const convertedError = convertLockError(error);
throw convertedError;
}
}
/**
* Query the current state of locks for this environment.
* @returns {Promise<{held: Array<object>, pending: Array<object>}>} Promise resolving to lock manager snapshot
*/
// https://w3c.github.io/web-locks/#api-lock-manager-query
async query() {
if (this instanceof LockManager) {
return locks.query();
}
throw new ERR_INVALID_THIS('LockManager');
}
}
ObjectDefineProperties(LockManager.prototype, {
request: { __proto__: null, enumerable: true },
query: { __proto__: null, enumerable: true },
[SymbolToStringTag]: {
__proto__: null,
value: 'LockManager',
writable: false,
enumerable: false,
configurable: true,
},
});
ObjectDefineProperties(LockManager.prototype.request, {
length: {
__proto__: null,
value: 2,
writable: false,
enumerable: false,
configurable: true,
},
});
module.exports = {
Lock,
LockManager,
locks: new LockManager(kConstructLockManager),
};

View file

@ -81,6 +81,7 @@ function getNavigatorPlatform(arch, platform) {
class Navigator {
// Private properties are used to avoid brand validations.
#availableParallelism;
#locks;
#userAgent;
#platform;
#languages;
@ -100,6 +101,14 @@ class Navigator {
return this.#availableParallelism;
}
/**
* @returns {LockManager}
*/
get locks() {
this.#locks ??= require('internal/locks').locks;
return this.#locks;
}
/**
* @returns {string}
*/
@ -140,6 +149,7 @@ ObjectDefineProperties(Navigator.prototype, {
languages: kEnumerableProperty,
userAgent: kEnumerableProperty,
platform: kEnumerableProperty,
locks: kEnumerableProperty,
});
module.exports = {

View file

@ -29,6 +29,8 @@ const {
isMarkedAsUntransferable,
} = require('internal/buffer');
const { locks } = require('internal/locks');
module.exports = {
isInternalThread,
isMainThread,
@ -49,4 +51,5 @@ module.exports = {
BroadcastChannel,
setEnvironmentData,
getEnvironmentData,
locks,
};

View file

@ -120,6 +120,7 @@
'src/node_http_parser.cc',
'src/node_http2.cc',
'src/node_i18n.cc',
'src/node_locks.cc',
'src/node_main_instance.cc',
'src/node_messaging.cc',
'src/node_metadata.cc',
@ -251,6 +252,7 @@
'src/node_http2_state.h',
'src/node_i18n.h',
'src/node_internals.h',
'src/node_locks.h',
'src/node_main_instance.h',
'src/node_mem.h',
'src/node_mem-inl.h',

View file

@ -51,6 +51,7 @@ namespace node {
V(HTTP2SETTINGS) \
V(HTTPINCOMINGMESSAGE) \
V(HTTPCLIENTREQUEST) \
V(LOCKS) \
V(JSSTREAM) \
V(JSUDPWRAP) \
V(MESSAGEPORT) \

View file

@ -98,6 +98,7 @@
V(changes_string, "changes") \
V(channel_string, "channel") \
V(chunks_sent_since_last_write_string, "chunksSentSinceLastWrite") \
V(client_id_string, "clientId") \
V(clone_unsupported_type_str, "Cannot clone object of unsupported type.") \
V(clone_transfer_needed_str, \
"Object that needs transfer was found in message but not listed in " \
@ -193,6 +194,7 @@
V(h2_string, "h2") \
V(handle_string, "handle") \
V(hash_algorithm_string, "hashAlgorithm") \
V(held_string, "held") \
V(help_text_string, "helpText") \
V(homedir_string, "homedir") \
V(host_string, "host") \
@ -254,6 +256,7 @@
V(messageerror_string, "messageerror") \
V(mgf1_hash_algorithm_string, "mgf1HashAlgorithm") \
V(minttl_string, "minttl") \
V(mode_string, "mode") \
V(module_string, "module") \
V(modulus_string, "modulus") \
V(modulus_length_string, "modulusLength") \
@ -300,6 +303,7 @@
V(path_string, "path") \
V(pathname_string, "pathname") \
V(pending_handle_string, "pendingHandle") \
V(pending_string, "pending") \
V(permission_string, "permission") \
V(phase_string, "phase") \
V(pid_string, "pid") \
@ -449,6 +453,7 @@
V(intervalhistogram_constructor_template, v8::FunctionTemplate) \
V(js_transferable_constructor_template, v8::FunctionTemplate) \
V(libuv_stream_wrap_ctor_template, v8::FunctionTemplate) \
V(lock_holder_constructor_template, v8::FunctionTemplate) \
V(message_port_constructor_template, v8::FunctionTemplate) \
V(module_wrap_constructor_template, v8::FunctionTemplate) \
V(microtask_queue_ctor_template, v8::FunctionTemplate) \

View file

@ -59,6 +59,7 @@
V(internal_only_v8) \
V(js_stream) \
V(js_udp_wrap) \
V(locks) \
V(messaging) \
V(modules) \
V(module_wrap) \

View file

@ -53,6 +53,7 @@ static_assert(static_cast<int>(NM_F_LINKED) ==
V(fs) \
V(fs_dir) \
V(http_parser) \
V(locks) \
V(messaging) \
V(mksnapshot) \
V(modules) \

View file

@ -79,6 +79,7 @@ class ExternalReferenceRegistry {
V(heap_utils) \
V(http_parser) \
V(internal_only_v8) \
V(locks) \
V(messaging) \
V(mksnapshot) \
V(module_wrap) \

931
src/node_locks.cc Normal file
View file

@ -0,0 +1,931 @@
#include "node_locks.h"
#include "base_object-inl.h"
#include "env-inl.h"
#include "node_errors.h"
#include "node_external_reference.h"
#include "node_internals.h"
#include "util-inl.h"
#include "v8.h"
namespace node::worker::locks {
using node::errors::TryCatchScope;
using v8::Array;
using v8::Context;
using v8::Exception;
using v8::Function;
using v8::FunctionCallbackInfo;
using v8::FunctionTemplate;
using v8::HandleScope;
using v8::Isolate;
using v8::Local;
using v8::MaybeLocal;
using v8::NewStringType;
using v8::Object;
using v8::ObjectTemplate;
using v8::Promise;
using v8::String;
using v8::Value;
static constexpr const char* kSharedMode = "shared";
static constexpr const char* kExclusiveMode = "exclusive";
static constexpr const char* kLockStolenError = "LOCK_STOLEN";
// Reject two promises and return `false` on failure.
static bool RejectBoth(Local<Context> ctx,
Local<Promise::Resolver> first,
Local<Promise::Resolver> second,
Local<Value> reason) {
if (first->Reject(ctx, reason).IsNothing()) return false;
if (second->Reject(ctx, reason).IsNothing()) return false;
return true;
}
static MaybeLocal<Object> CreateLockInfoObject(Isolate* isolate,
Local<Context> context,
const std::u16string& name,
Lock::Mode mode,
const std::string& client_id);
Lock::Lock(Environment* env,
const std::u16string& name,
Mode mode,
const std::string& client_id,
Local<Promise::Resolver> waiting,
Local<Promise::Resolver> released)
: env_(env), name_(name), mode_(mode), client_id_(client_id) {
waiting_promise_.Reset(env_->isolate(), waiting);
released_promise_.Reset(env_->isolate(), released);
}
LockRequest::LockRequest(Environment* env,
Local<Promise::Resolver> waiting,
Local<Promise::Resolver> released,
Local<Function> callback,
const std::u16string& name,
Lock::Mode mode,
const std::string& client_id,
bool steal,
bool if_available)
: env_(env),
name_(name),
mode_(mode),
client_id_(client_id),
steal_(steal),
if_available_(if_available) {
waiting_promise_.Reset(env_->isolate(), waiting);
released_promise_.Reset(env_->isolate(), released);
callback_.Reset(env_->isolate(), callback);
}
bool LockManager::IsGrantable(const LockRequest* request) const {
// Steal requests bypass all normal granting rules
if (request->steal()) return true;
auto held_locks_iter = held_locks_.find(request->name());
// No existing locks for this resource name
if (held_locks_iter == held_locks_.end()) return true;
// Exclusive requests cannot coexist with any existing locks
if (request->mode() == Lock::Mode::Exclusive) return false;
// For shared requests, check if any existing lock is exclusive
for (const auto& existing_lock : held_locks_iter->second) {
if (existing_lock->mode() == Lock::Mode::Exclusive) return false;
}
// All existing locks are shared, so this shared request can be granted
return true;
}
// Called when the user callback promise fulfills
static void OnLockCallbackFulfilled(const FunctionCallbackInfo<Value>& info) {
HandleScope handle_scope(info.GetIsolate());
Environment* env = Environment::GetCurrent(info);
BaseObjectPtr<LockHolder> lock_holder{
BaseObject::FromJSObject<LockHolder>(info.Data())};
std::shared_ptr<Lock> lock = lock_holder->lock();
// Release the lock and continue processing the queue.
LockManager::GetCurrent()->ReleaseLockAndProcessQueue(
env, lock, info[0], false);
}
// Called when the user callback promise rejects
static void OnLockCallbackRejected(const FunctionCallbackInfo<Value>& info) {
HandleScope handle_scope(info.GetIsolate());
Environment* env = Environment::GetCurrent(info);
BaseObjectPtr<LockHolder> lock_holder{
BaseObject::FromJSObject<LockHolder>(info.Data())};
std::shared_ptr<Lock> lock = lock_holder->lock();
LockManager::GetCurrent()->ReleaseLockAndProcessQueue(
env, lock, info[0], true);
}
// Called when the promise returned from the user's callback resolves
static void OnIfAvailableFulfill(const FunctionCallbackInfo<Value>& info) {
HandleScope handle_scope(info.GetIsolate());
USE(info.Data().As<Promise::Resolver>()->Resolve(
info.GetIsolate()->GetCurrentContext(), info[0]));
}
// Called when the promise returned from the user's callback rejects
static void OnIfAvailableReject(const FunctionCallbackInfo<Value>& info) {
USE(info.Data().As<Promise::Resolver>()->Reject(
info.GetIsolate()->GetCurrentContext(), info[0]));
}
void LockManager::CleanupStolenLocks(Environment* env) {
std::vector<std::u16string> resources_to_clean;
// Iterate held locks and remove entries that were stolen from other envs.
{
Mutex::ScopedLock scoped_lock(mutex_);
for (auto resource_iter = held_locks_.begin();
resource_iter != held_locks_.end();
++resource_iter) {
auto& resource_locks = resource_iter->second;
bool has_stolen_from_other_env = false;
// Check if this resource has stolen locks from other environments
for (const auto& lock_ptr : resource_locks) {
if (lock_ptr->is_stolen() && lock_ptr->env() != env) {
has_stolen_from_other_env = true;
break;
}
}
if (has_stolen_from_other_env) {
resources_to_clean.push_back(resource_iter->first);
}
}
}
// Clean up resources
for (const auto& resource_name : resources_to_clean) {
Mutex::ScopedLock scoped_lock(mutex_);
auto resource_iter = held_locks_.find(resource_name);
if (resource_iter != held_locks_.end()) {
auto& resource_locks = resource_iter->second;
// Remove stolen locks from other environments
for (auto lock_iter = resource_locks.begin();
lock_iter != resource_locks.end();) {
if ((*lock_iter)->is_stolen() && (*lock_iter)->env() != env) {
lock_iter = resource_locks.erase(lock_iter);
} else {
++lock_iter;
}
}
if (resource_locks.empty()) {
held_locks_.erase(resource_iter);
}
}
}
}
/**
* Web Locks algorithm implementation
* https://w3c.github.io/web-locks/#algorithms
*/
void LockManager::ProcessQueue(Environment* env) {
Isolate* isolate = env->isolate();
HandleScope handle_scope(isolate);
Local<Context> context = env->context();
// Remove locks that were stolen from this Environment first
CleanupStolenLocks(env);
while (true) {
std::unique_ptr<LockRequest> grantable_request;
std::unique_ptr<LockRequest> if_available_request;
std::unordered_set<Environment*> other_envs_to_wake;
/**
* First pass over pending_queue_
* 1- Build first_seen_for_resource: the oldest pending request
* for every resource name we encounter
* 2- Decide what to do with each entry:
* If it belongs to another Environment, remember that env so we
* can wake it later
* For our Environment, pick one of:
* * grantable_request can be granted now
* * if_available_request user asked for ifAvailable and the
* resource is currently busy
* * otherwise we skip and keep scanning
*/
{
std::unordered_map<std::u16string, LockRequest*> first_seen_for_resource;
Mutex::ScopedLock scoped_lock(mutex_);
for (auto queue_iter = pending_queue_.begin();
queue_iter != pending_queue_.end();
++queue_iter) {
LockRequest* request = queue_iter->get();
// Collect unique environments to wake up later
if (request->env() != env) {
other_envs_to_wake.insert(request->env());
}
// During a single pass, the first time we see a resource name is the
// earliest pending request
auto& first_for_resource = first_seen_for_resource[request->name()];
if (first_for_resource == nullptr) {
first_for_resource = request; // Mark as first seen for this resource
}
bool has_earlier_request_for_same_resource =
(first_for_resource != request);
bool should_wait_for_earlier_requests = false;
if (has_earlier_request_for_same_resource) {
// Check if this request is compatible with the earliest pending
// request first_for_resource
if (request->mode() == Lock::Mode::Exclusive ||
first_for_resource->mode() == Lock::Mode::Exclusive) {
// Exclusive locks are incompatible with everything
should_wait_for_earlier_requests = true;
}
// If both are shared, they're compatible and can proceed
}
// Only process requests from the current environment
if (request->env() != env) {
continue;
}
if (should_wait_for_earlier_requests || !IsGrantable(request)) {
if (request->if_available()) {
// ifAvailable request when resource not available: grant with null
if_available_request = std::move(*queue_iter);
pending_queue_.erase(queue_iter);
break;
}
continue;
}
// Found a request that can be granted normally
grantable_request = std::move(*queue_iter);
pending_queue_.erase(queue_iter);
break;
}
}
// Wake each environment only once
for (Environment* target_env : other_envs_to_wake) {
WakeEnvironment(target_env);
}
/**
* 1- We call the user callback immediately with `null` to signal
* that the lock was not granted - Check wrapCallback function in
* locks.js 2- Depending on what the callback returns we settle the two
* internal promises
* 3- No lock is added to held_locks_ in this path, so nothing to
* remove later
*/
if (if_available_request) {
Local<Value> null_arg = Null(isolate);
Local<Value> callback_result;
{
TryCatchScope try_catch_scope(env);
if (!if_available_request->callback()
->Call(context, Undefined(isolate), 1, &null_arg)
.ToLocal(&callback_result)) {
if (!RejectBoth(context,
if_available_request->waiting_promise(),
if_available_request->released_promise(),
try_catch_scope.Exception()))
return;
return;
}
}
if (callback_result->IsPromise()) {
Local<Promise> p = callback_result.As<Promise>();
Local<Function> on_fulfilled;
Local<Function> on_rejected;
CHECK(Function::New(context,
OnIfAvailableFulfill,
if_available_request->released_promise())
.ToLocal(&on_fulfilled));
CHECK(Function::New(context,
OnIfAvailableReject,
if_available_request->released_promise())
.ToLocal(&on_rejected));
{
TryCatchScope try_catch_scope(env);
if (p->Then(context, on_fulfilled, on_rejected).IsEmpty()) {
if (!try_catch_scope.CanContinue()) return;
Local<Value> err_val;
if (try_catch_scope.HasCaught() &&
!try_catch_scope.Exception().IsEmpty()) {
err_val = try_catch_scope.Exception();
} else {
err_val = Exception::Error(FIXED_ONE_BYTE_STRING(
isolate, "Failed to attach promise handlers"));
}
RejectBoth(context,
if_available_request->waiting_promise(),
if_available_request->released_promise(),
err_val);
return;
}
}
// After handlers are attached, resolve waiting_promise with the
// promise.
if (if_available_request->waiting_promise()
->Resolve(context, p)
.IsNothing())
return;
return;
}
// Non-promise callback result: settle both promises right away.
if (if_available_request->waiting_promise()
->Resolve(context, callback_result)
.IsNothing())
return;
if (if_available_request->released_promise()
->Resolve(context, callback_result)
.IsNothing())
return;
return;
}
if (!grantable_request) return;
/**
* 1- We grant the lock immediately even if other envs hold it
* 2- All existing locks with the same name are marked stolen, their
* released_promise is rejected, and their owners are woken so they
* can observe the rejection
* 3- We remove stolen locks that belong to this env right away; other
* envs will clean up in their next queue pass
*/
if (grantable_request->steal()) {
std::unordered_set<Environment*> envs_to_notify;
{
Mutex::ScopedLock scoped_lock(mutex_);
auto held_locks_iter = held_locks_.find(grantable_request->name());
if (held_locks_iter != held_locks_.end()) {
// Mark existing locks as stolen and collect environments to notify
for (auto& existing_lock : held_locks_iter->second) {
existing_lock->mark_stolen();
envs_to_notify.insert(existing_lock->env());
// Immediately reject the stolen lock's released_promise
Local<String> error_string;
if (!String::NewFromUtf8(isolate, kLockStolenError)
.ToLocal(&error_string)) {
return;
}
Local<Value> error = Exception::Error(error_string);
if (existing_lock->released_promise()
->Reject(context, error)
.IsNothing())
return;
}
// Remove stolen locks from current environment immediately
for (auto lock_iter = held_locks_iter->second.begin();
lock_iter != held_locks_iter->second.end();) {
if ((*lock_iter)->env() == env) {
lock_iter = held_locks_iter->second.erase(lock_iter);
} else {
++lock_iter;
}
}
if (held_locks_iter->second.empty()) {
held_locks_.erase(held_locks_iter);
}
}
}
// Wake other environments
for (Environment* target_env : envs_to_notify) {
if (target_env != env) {
WakeEnvironment(target_env);
}
}
}
// Create and store the new granted lock
auto granted_lock =
std::make_shared<Lock>(env,
grantable_request->name(),
grantable_request->mode(),
grantable_request->client_id(),
grantable_request->waiting_promise(),
grantable_request->released_promise());
{
Mutex::ScopedLock scoped_lock(mutex_);
held_locks_[grantable_request->name()].push_back(granted_lock);
}
// Create and store the new granted lock
Local<Object> lock_info;
if (!CreateLockInfoObject(isolate,
context,
grantable_request->name(),
grantable_request->mode(),
grantable_request->client_id())
.ToLocal(&lock_info)) {
return;
}
// Call user callback
Local<Value> callback_arg = lock_info;
Local<Value> callback_result;
{
TryCatchScope try_catch_scope(env);
if (!grantable_request->callback()
->Call(context, Undefined(isolate), 1, &callback_arg)
.ToLocal(&callback_result)) {
if (!RejectBoth(context,
grantable_request->waiting_promise(),
grantable_request->released_promise(),
try_catch_scope.Exception()))
return;
return;
}
}
// Create LockHolder BaseObjects to safely manage the lock's lifetime
// until the user's callback promise settles.
auto lock_resolve_holder = LockHolder::Create(env, granted_lock);
auto lock_reject_holder = LockHolder::Create(env, granted_lock);
Local<Function> on_fulfilled_callback;
Local<Function> on_rejected_callback;
// Create fulfilled callback first
if (!Function::New(
context, OnLockCallbackFulfilled, lock_resolve_holder->object())
.ToLocal(&on_fulfilled_callback)) {
return;
}
// Create rejected callback second
if (!Function::New(
context, OnLockCallbackRejected, lock_reject_holder->object())
.ToLocal(&on_rejected_callback)) {
return;
}
// Handle promise chain
if (callback_result->IsPromise()) {
Local<Promise> promise = callback_result.As<Promise>();
{
TryCatchScope try_catch_scope(env);
if (promise->Then(context, on_fulfilled_callback, on_rejected_callback)
.IsEmpty()) {
if (!try_catch_scope.CanContinue()) return;
Local<Value> err_val;
if (try_catch_scope.HasCaught() &&
!try_catch_scope.Exception().IsEmpty()) {
err_val = try_catch_scope.Exception();
} else {
err_val = Exception::Error(FIXED_ONE_BYTE_STRING(
isolate, "Failed to attach promise handlers"));
}
RejectBoth(context,
grantable_request->waiting_promise(),
grantable_request->released_promise(),
err_val);
return;
}
}
// Lock granted: waiting_promise resolves now with the promise returned
// by the callback; on_fulfilled/on_rejected will release the lock when
// that promise settles.
if (grantable_request->waiting_promise()
->Resolve(context, callback_result)
.IsNothing()) {
return;
}
} else {
if (grantable_request->waiting_promise()
->Resolve(context, callback_result)
.IsNothing()) {
return;
}
Local<Value> promise_args[] = {callback_result};
if (on_fulfilled_callback
->Call(context, Undefined(isolate), 1, promise_args)
.IsEmpty()) {
// Callback threw an error, handle it like a rejected promise
// The error is already propagated through the TryCatch in the
// callback
return;
}
}
}
}
/**
* name : string resource identifier
* clientId : string client identifier
* mode : string lock mode
* steal : boolean whether to steal existing locks
* ifAvailable : boolean only grant if immediately available
* callback : Function - JS callback
*/
void LockManager::Request(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
Isolate* isolate = env->isolate();
HandleScope scope(isolate);
Local<Context> context = env->context();
CHECK_EQ(args.Length(), 6);
CHECK(args[0]->IsString()); // name
CHECK(args[1]->IsString()); // clientId
CHECK(args[2]->IsString()); // mode
CHECK(args[3]->IsBoolean()); // steal
CHECK(args[4]->IsBoolean()); // ifAvailable
CHECK(args[5]->IsFunction()); // callback
Local<String> resource_name_str = args[0].As<String>();
TwoByteValue resource_name_utf16(isolate, resource_name_str);
std::u16string resource_name(
reinterpret_cast<const char16_t*>(*resource_name_utf16),
resource_name_utf16.length());
String::Utf8Value client_id_utf8(isolate, args[1]);
std::string client_id(*client_id_utf8);
String::Utf8Value mode_utf8(isolate, args[2]);
std::string mode_str(*mode_utf8);
bool steal = args[3]->BooleanValue(isolate);
bool if_available = args[4]->BooleanValue(isolate);
Local<Function> callback = args[5].As<Function>();
Lock::Mode lock_mode =
mode_str == kSharedMode ? Lock::Mode::Shared : Lock::Mode::Exclusive;
Local<Promise::Resolver> waiting_promise;
Local<Promise::Resolver> released_promise;
if (!Promise::Resolver::New(context).ToLocal(&waiting_promise) ||
!Promise::Resolver::New(context).ToLocal(&released_promise)) {
return;
}
// Mark both internal promises as handled to prevent unhandled rejection
// warnings
waiting_promise->GetPromise()->MarkAsHandled();
released_promise->GetPromise()->MarkAsHandled();
LockManager* manager = GetCurrent();
{
Mutex::ScopedLock scoped_lock(manager->mutex_);
// Register cleanup hook for the environment only once
if (manager->registered_envs_.insert(env).second) {
env->AddCleanupHook(LockManager::OnEnvironmentCleanup, env);
}
auto lock_request = std::make_unique<LockRequest>(env,
waiting_promise,
released_promise,
callback,
resource_name,
lock_mode,
client_id,
steal,
if_available);
// Steal requests get priority by going to front of queue
if (steal) {
manager->pending_queue_.emplace_front(std::move(lock_request));
} else {
manager->pending_queue_.push_back(std::move(lock_request));
}
}
manager->ProcessQueue(env);
args.GetReturnValue().Set(released_promise->GetPromise());
}
void LockManager::Query(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
Isolate* isolate = env->isolate();
HandleScope scope(isolate);
Local<Context> context = env->context();
Local<Promise::Resolver> resolver;
if (!Promise::Resolver::New(context).ToLocal(&resolver)) {
return;
}
// Always set the return value first so Javascript gets a promise
args.GetReturnValue().Set(resolver->GetPromise());
Local<Object> result = Object::New(isolate);
Local<Array> held_list = Array::New(isolate);
Local<Array> pending_list = Array::New(isolate);
LockManager* manager = GetCurrent();
{
Mutex::ScopedLock scoped_lock(manager->mutex_);
uint32_t index = 0;
Local<Object> lock_info;
for (const auto& resource_entry : manager->held_locks_) {
for (const auto& held_lock : resource_entry.second) {
if (held_lock->env() == env) {
if (!CreateLockInfoObject(isolate,
context,
held_lock->name(),
held_lock->mode(),
held_lock->client_id())
.ToLocal(&lock_info)) {
THROW_ERR_OPERATION_FAILED(env,
"Failed to create lock info object");
return;
}
if (held_list->Set(context, index++, lock_info).IsNothing()) {
THROW_ERR_OPERATION_FAILED(env, "Failed to build held locks array");
return;
}
}
}
}
index = 0;
for (const auto& pending_request : manager->pending_queue_) {
if (pending_request->env() == env) {
if (!CreateLockInfoObject(isolate,
context,
pending_request->name(),
pending_request->mode(),
pending_request->client_id())
.ToLocal(&lock_info)) {
THROW_ERR_OPERATION_FAILED(env, "Failed to create lock info object");
return;
}
if (pending_list->Set(context, index++, lock_info).IsNothing()) {
THROW_ERR_OPERATION_FAILED(env,
"Failed to build pending locks array");
return;
}
}
}
}
if (result->Set(context, env->held_string(), held_list).IsNothing() ||
result->Set(context, env->pending_string(), pending_list).IsNothing()) {
THROW_ERR_OPERATION_FAILED(env, "Failed to build query result object");
return;
}
if (resolver->Resolve(context, result).IsNothing()) return;
}
// Runs after the user callback (or its returned promise) settles.
void LockManager::ReleaseLockAndProcessQueue(Environment* env,
std::shared_ptr<Lock> lock,
Local<Value> callback_result,
bool was_rejected) {
{
Mutex::ScopedLock scoped_lock(mutex_);
ReleaseLock(lock.get());
}
Local<Context> context = env->context();
// For stolen locks, the released_promise was already rejected when marked as
// stolen.
if (!lock->is_stolen()) {
if (was_rejected) {
// Propagate rejection from the user callback
if (lock->released_promise()
->Reject(context, callback_result)
.IsNothing())
return;
} else {
// Propagate fulfilment
if (lock->released_promise()
->Resolve(context, callback_result)
.IsNothing())
return;
}
}
ProcessQueue(env);
}
// Remove a lock from held_locks_ when it's no longer needed
void LockManager::ReleaseLock(Lock* lock) {
const std::u16string& resource_name = lock->name();
auto resource_iter = held_locks_.find(resource_name);
if (resource_iter == held_locks_.end()) return;
auto& resource_locks = resource_iter->second;
for (auto lock_iter = resource_locks.begin();
lock_iter != resource_locks.end();
++lock_iter) {
if (lock_iter->get() == lock) {
resource_locks.erase(lock_iter);
if (resource_locks.empty()) held_locks_.erase(resource_iter);
break;
}
}
}
// Wakeup of target Environment's event loop
void LockManager::WakeEnvironment(Environment* target_env) {
if (target_env == nullptr || target_env->is_stopping()) return;
// Schedule ProcessQueue in the target Environment on its event loop.
target_env->SetImmediateThreadsafe([](Environment* env_to_wake) {
if (env_to_wake != nullptr && !env_to_wake->is_stopping()) {
LockManager::GetCurrent()->ProcessQueue(env_to_wake);
}
});
}
// Remove all held locks and pending requests that belong to an Environment
// that is being destroyed
void LockManager::CleanupEnvironment(Environment* env_to_cleanup) {
Mutex::ScopedLock scoped_lock(mutex_);
// Remove every held lock that belongs to this Environment.
for (auto resource_iter = held_locks_.begin();
resource_iter != held_locks_.end();) {
auto& resource_locks = resource_iter->second;
for (auto lock_iter = resource_locks.begin();
lock_iter != resource_locks.end();) {
if ((*lock_iter)->env() == env_to_cleanup) {
lock_iter = resource_locks.erase(lock_iter);
} else {
++lock_iter;
}
}
if (resource_locks.empty()) {
resource_iter = held_locks_.erase(resource_iter);
} else {
++resource_iter;
}
}
// Remove every pending request submitted by this Environment.
for (auto request_iter = pending_queue_.begin();
request_iter != pending_queue_.end();) {
if ((*request_iter)->env() == env_to_cleanup) {
request_iter = pending_queue_.erase(request_iter);
} else {
++request_iter;
}
}
// Finally, remove it from registered_envs_
registered_envs_.erase(env_to_cleanup);
}
// Cleanup hook wrapper
void LockManager::OnEnvironmentCleanup(void* arg) {
Environment* env = static_cast<Environment*>(arg);
LockManager::GetCurrent()->CleanupEnvironment(env);
}
static MaybeLocal<Object> CreateLockInfoObject(Isolate* isolate,
Local<Context> context,
const std::u16string& name,
Lock::Mode mode,
const std::string& client_id) {
Local<Object> obj = Object::New(isolate);
Environment* env = Environment::GetCurrent(context);
// TODO(ilyasshabi): Add ToV8Value that directly accepts std::u16string
// so we can avoid the manual String::NewFromTwoByte()
Local<String> name_string;
if (!String::NewFromTwoByte(isolate,
reinterpret_cast<const uint16_t*>(name.data()),
NewStringType::kNormal,
static_cast<int>(name.length()))
.ToLocal(&name_string)) {
return MaybeLocal<Object>();
}
if (obj->Set(context, env->name_string(), name_string).IsNothing()) {
return MaybeLocal<Object>();
}
Local<String> mode_string;
if (!String::NewFromUtf8(
isolate,
mode == Lock::Mode::Exclusive ? kExclusiveMode : kSharedMode)
.ToLocal(&mode_string)) {
return MaybeLocal<Object>();
}
if (obj->Set(context, env->mode_string(), mode_string).IsNothing()) {
return MaybeLocal<Object>();
}
Local<String> client_id_string;
if (!String::NewFromUtf8(isolate, client_id.c_str())
.ToLocal(&client_id_string)) {
return MaybeLocal<Object>();
}
if (obj->Set(context, env->client_id_string(), client_id_string)
.IsNothing()) {
return MaybeLocal<Object>();
}
return obj;
}
LockManager LockManager::current_;
void CreatePerIsolateProperties(IsolateData* isolate_data,
Local<ObjectTemplate> target) {
Isolate* isolate = isolate_data->isolate();
SetMethod(isolate, target, "request", LockManager::Request);
SetMethod(isolate, target, "query", LockManager::Query);
Local<String> shared_mode;
if (String::NewFromUtf8(isolate, kSharedMode).ToLocal(&shared_mode)) {
target->Set(FIXED_ONE_BYTE_STRING(isolate, "LOCK_MODE_SHARED"),
shared_mode);
}
Local<String> exclusive_mode;
if (String::NewFromUtf8(isolate, kExclusiveMode).ToLocal(&exclusive_mode)) {
target->Set(FIXED_ONE_BYTE_STRING(isolate, "LOCK_MODE_EXCLUSIVE"),
exclusive_mode);
}
Local<String> stolen_error;
if (String::NewFromUtf8(isolate, kLockStolenError).ToLocal(&stolen_error)) {
target->Set(FIXED_ONE_BYTE_STRING(isolate, "LOCK_STOLEN_ERROR"),
stolen_error);
}
}
void CreatePerContextProperties(Local<Object> target,
Local<Value> unused,
Local<Context> context,
void* priv) {}
void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
registry->Register(LockManager::Request);
registry->Register(LockManager::Query);
registry->Register(OnLockCallbackFulfilled);
registry->Register(OnLockCallbackRejected);
registry->Register(OnIfAvailableFulfill);
registry->Register(OnIfAvailableReject);
}
BaseObjectPtr<LockHolder> LockHolder::Create(Environment* env,
std::shared_ptr<Lock> lock) {
Local<Object> obj;
if (!GetConstructorTemplate(env)
->InstanceTemplate()
->NewInstance(env->context())
.ToLocal(&obj)) {
return nullptr;
}
return MakeBaseObject<LockHolder>(env, obj, std::move(lock));
}
Local<FunctionTemplate> LockHolder::GetConstructorTemplate(Environment* env) {
IsolateData* isolate_data = env->isolate_data();
Local<FunctionTemplate> tmpl =
isolate_data->lock_holder_constructor_template();
if (tmpl.IsEmpty()) {
Isolate* isolate = isolate_data->isolate();
tmpl = NewFunctionTemplate(isolate, nullptr);
tmpl->SetClassName(FIXED_ONE_BYTE_STRING(isolate, "LockHolder"));
tmpl->InstanceTemplate()->SetInternalFieldCount(
LockHolder::kInternalFieldCount);
isolate_data->set_lock_holder_constructor_template(tmpl);
}
return tmpl;
}
} // namespace node::worker::locks
NODE_BINDING_CONTEXT_AWARE_INTERNAL(
locks, node::worker::locks::CreatePerContextProperties)
NODE_BINDING_PER_ISOLATE_INIT(locks,
node::worker::locks::CreatePerIsolateProperties)
NODE_BINDING_EXTERNAL_REFERENCE(locks,
node::worker::locks::RegisterExternalReferences)

180
src/node_locks.h Normal file
View file

@ -0,0 +1,180 @@
#ifndef SRC_NODE_LOCKS_H_
#define SRC_NODE_LOCKS_H_
#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS
#include <deque>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include "base_object.h"
#include "env.h"
#include "node_mutex.h"
#include "v8.h"
namespace node::worker::locks {
class Lock final {
public:
enum class Mode { Shared, Exclusive };
Lock(Environment* env,
const std::u16string& name,
Mode mode,
const std::string& client_id,
v8::Local<v8::Promise::Resolver> waiting,
v8::Local<v8::Promise::Resolver> released);
~Lock() = default;
Lock(const Lock&) = delete;
Lock& operator=(const Lock&) = delete;
// Resource name for this lock as DOMString
const std::u16string& name() const { return name_; }
// Lock mode (shared or exclusive).
Mode mode() const { return mode_; }
// Client identifier string.
const std::string& client_id() const { return client_id_; }
// Environment that owns this lock.
Environment* env() const { return env_; }
// Returns true if this lock was stolen by another request.
bool is_stolen() const { return stolen_; }
// Marks this lock as stolen.
void mark_stolen() { stolen_ = true; }
// Promise that resolves when the user callback completes.
v8::Local<v8::Promise::Resolver> waiting_promise() {
return waiting_promise_.Get(env_->isolate());
}
// Promise that resolves when the lock is finally released.
v8::Local<v8::Promise::Resolver> released_promise() {
return released_promise_.Get(env_->isolate());
}
private:
Environment* env_;
std::u16string name_;
Mode mode_;
std::string client_id_;
bool stolen_ = false;
v8::Global<v8::Promise::Resolver> waiting_promise_;
v8::Global<v8::Promise::Resolver> released_promise_;
};
class LockHolder final : public BaseObject {
public:
LockHolder(Environment* env,
v8::Local<v8::Object> obj,
std::shared_ptr<Lock> lock)
: BaseObject(env, obj), lock_(std::move(lock)) {
MakeWeak();
}
~LockHolder() = default;
LockHolder(const LockHolder&) = delete;
LockHolder& operator=(const LockHolder&) = delete;
std::shared_ptr<Lock> lock() const { return lock_; }
SET_NO_MEMORY_INFO()
SET_MEMORY_INFO_NAME(LockHolder)
SET_SELF_SIZE(LockHolder)
static BaseObjectPtr<LockHolder> Create(Environment* env,
std::shared_ptr<Lock> lock);
private:
static v8::Local<v8::FunctionTemplate> GetConstructorTemplate(
Environment* env);
std::shared_ptr<Lock> lock_;
};
class LockRequest final {
public:
LockRequest(Environment* env,
v8::Local<v8::Promise::Resolver> waiting,
v8::Local<v8::Promise::Resolver> released,
v8::Local<v8::Function> callback,
const std::u16string& name,
Lock::Mode mode,
const std::string& client_id,
bool steal,
bool if_available);
~LockRequest() = default;
LockRequest(const LockRequest&) = delete;
LockRequest& operator=(const LockRequest&) = delete;
const std::u16string& name() const { return name_; }
Lock::Mode mode() const { return mode_; }
const std::string& client_id() const { return client_id_; }
bool steal() const { return steal_; }
// Returns true if this is an ifAvailable request.
bool if_available() const { return if_available_; }
Environment* env() const { return env_; }
v8::Local<v8::Promise::Resolver> waiting_promise() {
return waiting_promise_.Get(env_->isolate());
}
v8::Local<v8::Promise::Resolver> released_promise() {
return released_promise_.Get(env_->isolate());
}
v8::Local<v8::Function> callback() { return callback_.Get(env_->isolate()); }
private:
Environment* env_;
std::u16string name_;
Lock::Mode mode_;
std::string client_id_;
bool steal_;
bool if_available_;
v8::Global<v8::Promise::Resolver> waiting_promise_;
v8::Global<v8::Promise::Resolver> released_promise_;
v8::Global<v8::Function> callback_;
};
class LockManager final {
public:
static void Request(const v8::FunctionCallbackInfo<v8::Value>& args);
static void Query(const v8::FunctionCallbackInfo<v8::Value>& args);
void ProcessQueue(Environment* env);
void CleanupEnvironment(Environment* env);
static void OnEnvironmentCleanup(void* arg);
static LockManager* GetCurrent() { return &current_; }
void ReleaseLockAndProcessQueue(Environment* env,
std::shared_ptr<Lock> lock,
v8::Local<v8::Value> result,
bool was_rejected = false);
private:
LockManager() = default;
~LockManager() = default;
LockManager(const LockManager&) = delete;
LockManager& operator=(const LockManager&) = delete;
bool IsGrantable(const LockRequest* req) const;
void CleanupStolenLocks(Environment* env);
void ReleaseLock(Lock* lock);
void WakeEnvironment(Environment* env);
static LockManager current_;
mutable Mutex mutex_;
// All entries for a given Environment* are purged in CleanupEnvironment().
std::unordered_map<std::u16string, std::deque<std::shared_ptr<Lock>>>
held_locks_;
std::deque<std::unique_ptr<LockRequest>> pending_queue_;
std::unordered_set<Environment*> registered_envs_;
};
} // namespace node::worker::locks
#endif // NODE_WANT_INTERNALS
#endif // SRC_NODE_LOCKS_H_

View file

@ -33,6 +33,7 @@ Last update:
- user-timing: https://github.com/web-platform-tests/wpt/tree/5ae85bf826/user-timing
- wasm/jsapi: https://github.com/web-platform-tests/wpt/tree/cde25e7e3c/wasm/jsapi
- wasm/webapi: https://github.com/web-platform-tests/wpt/tree/fd1b23eeaa/wasm/webapi
- web-locks: https://github.com/web-platform-tests/wpt/tree/10a122a6bc/web-locks
- WebCryptoAPI: https://github.com/web-platform-tests/wpt/tree/591c95ce61/WebCryptoAPI
- webidl/ecmascript-binding/es-exceptions: https://github.com/web-platform-tests/wpt/tree/2f96fa1996/webidl/ecmascript-binding/es-exceptions
- webmessaging/broadcastchannel: https://github.com/web-platform-tests/wpt/tree/6495c91853/webmessaging/broadcastchannel

View file

@ -0,0 +1,45 @@
[SecureContext]
interface mixin NavigatorLocks {
readonly attribute LockManager locks;
};
Navigator includes NavigatorLocks;
WorkerNavigator includes NavigatorLocks;
[SecureContext, Exposed=(Window,Worker)]
interface LockManager {
Promise<any> request(DOMString name,
LockGrantedCallback callback);
Promise<any> request(DOMString name,
LockOptions options,
LockGrantedCallback callback);
Promise<LockManagerSnapshot> query();
};
callback LockGrantedCallback = Promise<any> (Lock? lock);
enum LockMode { "shared", "exclusive" };
dictionary LockOptions {
LockMode mode = "exclusive";
boolean ifAvailable = false;
boolean steal = false;
AbortSignal signal;
};
dictionary LockManagerSnapshot {
sequence<LockInfo> held;
sequence<LockInfo> pending;
};
dictionary LockInfo {
DOMString name;
LockMode mode;
DOMString clientId;
};
[SecureContext, Exposed=(Window,Worker)]
interface Lock {
readonly attribute DOMString name;
readonly attribute LockMode mode;
};

View file

@ -91,6 +91,10 @@
"commit": "fd1b23eeaaf9a01555d4fa29cf79ed11a4c44a50",
"path": "wasm/webapi"
},
"web-locks": {
"commit": "10a122a6bc3670a44e0fa8b9a99f6aa9012d30f1",
"path": "web-locks"
},
"WebCryptoAPI": {
"commit": "591c95ce6174690b92833cd92859ce2807714591",
"path": "WebCryptoAPI"

5
test/fixtures/wpt/web-locks/META.yml vendored Normal file
View file

@ -0,0 +1,5 @@
spec: https://w3c.github.io/web-locks/
suggested_reviewers:
- inexorabletash
- pwnall
- saschanaz

5
test/fixtures/wpt/web-locks/README.md vendored Normal file
View file

@ -0,0 +1,5 @@
This directory contains a test suite for the proposed Web Locks API.
Explainer: https://github.com/w3c/web-locks/
Spec: https://w3c.github.io/web-locks/

View file

@ -0,0 +1,3 @@
features:
- name: web-locks
files: "**"

View file

@ -0,0 +1,136 @@
// META: title=Web Locks API: navigator.locks.request method
// META: script=resources/helpers.js
// META: global=window,dedicatedworker,sharedworker,serviceworker
'use strict';
promise_test(async t => {
const res = uniqueName(t);
await promise_rejects_js(t, TypeError, navigator.locks.request());
await promise_rejects_js(t, TypeError, navigator.locks.request(res));
}, 'navigator.locks.request requires a name and a callback');
promise_test(async t => {
const res = uniqueName(t);
await promise_rejects_js(
t, TypeError,
navigator.locks.request(res, {mode: 'foo'}, lock => {}));
await promise_rejects_js(
t, TypeError,
navigator.locks.request(res, {mode: null }, lock => {}));
assert_equals(await navigator.locks.request(
res, {mode: 'exclusive'}, lock => lock.mode), 'exclusive',
'mode is exclusive');
assert_equals(await navigator.locks.request(
res, {mode: 'shared'}, lock => lock.mode), 'shared',
'mode is shared');
}, 'mode must be "shared" or "exclusive"');
promise_test(async t => {
const res = uniqueName(t);
await promise_rejects_dom(
t, 'NotSupportedError',
navigator.locks.request(
res, {steal: true, ifAvailable: true}, lock => {}),
"A NotSupportedError should be thrown if both " +
"'steal' and 'ifAvailable' are specified.");
}, "The 'steal' and 'ifAvailable' options are mutually exclusive");
promise_test(async t => {
const res = uniqueName(t);
await promise_rejects_dom(
t, 'NotSupportedError',
navigator.locks.request(res, {mode: 'shared', steal: true}, lock => {}),
'Request with mode=shared and steal=true should fail');
}, "The 'steal' option must be used with exclusive locks");
promise_test(async t => {
const res = uniqueName(t);
const controller = new AbortController();
await promise_rejects_dom(
t, 'NotSupportedError',
navigator.locks.request(
res, {signal: controller.signal, steal: true}, lock => {}),
'Request with signal and steal=true should fail');
}, "The 'signal' and 'steal' options are mutually exclusive");
promise_test(async t => {
const res = uniqueName(t);
const controller = new AbortController();
await promise_rejects_dom(
t, 'NotSupportedError',
navigator.locks.request(
res, {signal: controller.signal, ifAvailable: true}, lock => {}),
'Request with signal and ifAvailable=true should fail');
}, "The 'signal' and 'ifAvailable' options are mutually exclusive");
promise_test(async t => {
const res = uniqueName(t);
await promise_rejects_js(
t, TypeError, navigator.locks.request(res, undefined));
await promise_rejects_js(
t, TypeError, navigator.locks.request(res, null));
await promise_rejects_js(
t, TypeError, navigator.locks.request(res, 123));
await promise_rejects_js(
t, TypeError, navigator.locks.request(res, 'abc'));
await promise_rejects_js(
t, TypeError, navigator.locks.request(res, []));
await promise_rejects_js(
t, TypeError, navigator.locks.request(res, {}));
await promise_rejects_js(
t, TypeError, navigator.locks.request(res, new Promise(r => {})));
}, 'callback must be a function');
promise_test(async t => {
const res = uniqueName(t);
let release;
const promise = new Promise(r => { release = r; });
let returned = navigator.locks.request(res, lock => { return promise; });
const order = [];
returned.then(() => { order.push('returned'); });
promise.then(() => { order.push('holding'); });
release();
await Promise.all([returned, promise]);
assert_array_equals(order, ['holding', 'returned']);
}, 'navigator.locks.request\'s returned promise resolves after' +
' lock is released');
promise_test(async t => {
const res = uniqueName(t);
const test_error = {name: 'test'};
const p = navigator.locks.request(res, lock => {
throw test_error;
});
assert_equals(Promise.resolve(p), p, 'request() result is a Promise');
await promise_rejects_exactly(t, test_error, p, 'result should reject');
}, 'Returned Promise rejects if callback throws synchronously');
promise_test(async t => {
const res = uniqueName(t);
const test_error = {name: 'test'};
const p = navigator.locks.request(res, async lock => {
throw test_error;
});
assert_equals(Promise.resolve(p), p, 'request() result is a Promise');
await promise_rejects_exactly(t, test_error, p, 'result should reject');
}, 'Returned Promise rejects if callback throws asynchronously');
promise_test(async t => {
const res = uniqueName(t);
let then_invoked = false;
const test_error = { then: _ => { then_invoked = true; } };
const p = navigator.locks.request(res, async lock => {
throw test_error;
});
assert_equals(Promise.resolve(p), p, 'request() result is a Promise');
await promise_rejects_exactly(t, test_error, p, 'result should reject');
assert_false(then_invoked, 'then() should not be invoked');
}, 'If callback throws a thenable, its then() should not be invoked');

View file

@ -0,0 +1,64 @@
<!DOCTYPE html>
<meta charset=utf-8>
<meta name="timeout" content="long">
<title>Web Locks API: bfcache</title>
<link rel=help href="https://w3c.github.io/web-locks/">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/common/utils.js"></script>
<script src="/common/dispatcher/dispatcher.js"></script>
<script src="/html/browsers/browsing-the-web/back-forward-cache/resources/helper.sub.js"></script>
<script type="module">
import { runWebLocksBfcacheTest } from "./helpers.js";
runWebLocksBfcacheTest({
funcBeforeNavigation: async () => {
const controller = new AbortController();
const promise = navigator.locks.request(
uniqueNameByQuery(),
{ signal: controller.signal },
() => new Promise(() => { })
);
controller.abort();
await promise.catch(() => { });
},
shouldBeCached: true
}, "An immediately aborted lock on main thread should not prevent bfcache");
runWebLocksBfcacheTest({
funcBeforeNavigation: async () => {
window.worker = new Worker("/web-locks/resources/worker.js");
await postToWorkerAndWait(worker, {
op: "request",
name: uniqueNameByQuery(),
abortImmediately: true
});
},
shouldBeCached: true
}, "An immediately aborted lock on a worker should not prevent bfcache");
runWebLocksBfcacheTest({
funcBeforeNavigation: async () => {
window.worker = new Worker("/web-locks/resources/parentworker.js");
await postToWorkerAndWait(worker, {
op: "request",
name: uniqueNameByQuery(),
abortImmediately: true
});
},
shouldBeCached: true
}, "An immediately aborted lock on a nested worker should not prevent bfcache");
runWebLocksBfcacheTest({
funcBeforeNavigation: async () => {
window.worker = new SharedWorker("/web-locks/resources/worker.js");
worker.port.start();
await postToWorkerAndWait(worker.port, {
op: "request",
name: uniqueNameByQuery(),
abortImmediately: true
});
},
shouldBeCached: true
}, "An immediately aborted lock on a shared worker should not prevent bfcache");
</script>

View file

@ -0,0 +1,45 @@
<!DOCTYPE html>
<meta charset=utf-8>
<meta name="timeout" content="long">
<title>Web Locks API: bfcache</title>
<link rel=help href="https://w3c.github.io/web-locks/">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/common/utils.js"></script>
<script src="/common/dispatcher/dispatcher.js"></script>
<script src="/html/browsers/browsing-the-web/back-forward-cache/resources/helper.sub.js"></script>
<script type="module">
import { runWebLocksBfcacheTest } from "./helpers.js";
runWebLocksBfcacheTest({
funcBeforeNavigation: () => {
navigator.locks.request(uniqueNameByQuery(), () => new Promise(() => { }));
},
shouldBeCached: false
}, "A held lock on the main thread must prevent bfcache");
runWebLocksBfcacheTest({
funcBeforeNavigation: async () => {
window.worker = new Worker("/web-locks/resources/worker.js");
await postToWorkerAndWait(worker, { op: "request", name: uniqueNameByQuery() });
},
shouldBeCached: false
}, "A held lock on a worker must prevent bfcache");
runWebLocksBfcacheTest({
funcBeforeNavigation: async () => {
window.worker = new Worker("/web-locks/resources/parentworker.js");
await postToWorkerAndWait(worker, { op: "request", name: uniqueNameByQuery() });
},
shouldBeCached: false
}, "A held lock on a nested worker must prevent bfcache");
runWebLocksBfcacheTest({
funcBeforeNavigation: async () => {
window.worker = new SharedWorker("/web-locks/resources/worker.js");
worker.port.start();
await postToWorkerAndWait(worker.port, { op: "request", name: uniqueNameByQuery() });
},
shouldBeCached: false
}, "A held lock on a shared worker must prevent bfcache");
</script>

View file

@ -0,0 +1,15 @@
export function runWebLocksBfcacheTest(params, description) {
runBfcacheTest(
{
scripts: ["/web-locks/resources/helpers.js"],
openFunc: url =>
window.open(
url + `&prefix=${location.pathname}-${description}`,
"_blank",
"noopener"
),
...params,
},
description
);
}

View file

@ -0,0 +1,46 @@
<!DOCTYPE html>
<meta charset=utf-8>
<meta name="timeout" content="long">
<title>Web Locks API: bfcache</title>
<link rel=help href="https://w3c.github.io/web-locks/">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/common/utils.js"></script>
<script src="/common/dispatcher/dispatcher.js"></script>
<script src="/html/browsers/browsing-the-web/back-forward-cache/resources/helper.sub.js"></script>
<script type="module">
import { runWebLocksBfcacheTest } from "./helpers.js";
runWebLocksBfcacheTest({
funcBeforeNavigation: async () => {
navigator.locks.request(uniqueNameByQuery(), () => new Promise(() => { }));
window.worker = new Worker("/web-locks/resources/worker.js");
const { lock_id } = await postToWorkerAndWait(worker, { op: "request", name: uniqueNameByQuery() });
await postToWorkerAndWait(worker, { op: "release", lock_id });
},
shouldBeCached: false,
}, "A held lock on main thread must prevent bfcache even after worker releases locks");
runWebLocksBfcacheTest({
funcBeforeNavigation: async () => {
const controller = new AbortController();
navigator.locks.request(uniqueNameByQuery(), { signal: controller.signal }, () => new Promise(() => { }));
window.worker = new Worker("/web-locks/resources/worker.js");
await postToWorkerAndWait(worker, { op: "request", name: uniqueNameByQuery() });
controller.abort();
},
shouldBeCached: false,
}, "A held lock on worker must prevent bfcache even after main thread releases locks");
runWebLocksBfcacheTest({
funcBeforeNavigation: async () => {
const controller = new AbortController();
navigator.locks.request(uniqueNameByQuery(), { signal: controller.signal }, () => new Promise(() => { }));
window.worker = new SharedWorker("/web-locks/resources/worker.js");
worker.port.start();
await postToWorkerAndWait(worker.port, { op: "request", name: uniqueNameByQuery() });
controller.abort();
},
shouldBeCached: false,
}, "A held lock on shared worker must prevent bfcache even after main thread releases locks");
</script>

View file

@ -0,0 +1,48 @@
<!DOCTYPE html>
<meta charset=utf-8>
<meta name="timeout" content="long">
<title>Web Locks API: bfcache</title>
<link rel=help href="https://w3c.github.io/web-locks/">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/common/utils.js"></script>
<script src="/common/dispatcher/dispatcher.js"></script>
<script src="/html/browsers/browsing-the-web/back-forward-cache/resources/helper.sub.js"></script>
<script type="module">
import { runWebLocksBfcacheTest } from "./helpers.js";
runWebLocksBfcacheTest({
funcBeforeNavigation: async () => {
await navigator.locks.request(uniqueNameByQuery(), () => { });
},
shouldBeCached: true,
}, "A released lock on the main thread should not prevent bfcache");
runWebLocksBfcacheTest({
funcBeforeNavigation: async () => {
window.worker = new Worker("/web-locks/resources/worker.js");
const { lock_id } = await postToWorkerAndWait(worker, { op: "request", name: uniqueNameByQuery() });
await postToWorkerAndWait(worker, { op: "release", lock_id });
},
shouldBeCached: true,
}, "A released lock on a worker should not prevent bfcache");
runWebLocksBfcacheTest({
funcBeforeNavigation: async () => {
window.worker = new Worker("/web-locks/resources/parentworker.js");
const { lock_id } = await postToWorkerAndWait(worker, { op: "request", name: uniqueNameByQuery() });
await postToWorkerAndWait(worker, { op: "release", lock_id });
},
shouldBeCached: true,
}, "A released lock on a nested worker should not prevent bfcache");
runWebLocksBfcacheTest({
funcBeforeNavigation: async () => {
window.worker = new SharedWorker("/web-locks/resources/worker.js");
worker.port.start();
const { lock_id } = await postToWorkerAndWait(worker.port, { op: "request", name: uniqueNameByQuery() });
await postToWorkerAndWait(worker.port, { op: "release", lock_id });
},
shouldBeCached: true,
}, "A released lock on a shared worker should not prevent bfcache");
</script>

View file

@ -0,0 +1,69 @@
<!DOCTYPE html>
<meta charset=utf-8>
<meta name="timeout" content="long">
<title>Web Locks API: bfcache</title>
<link rel=help href="https://w3c.github.io/web-locks/">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/common/utils.js"></script>
<script src="/common/dispatcher/dispatcher.js"></script>
<script src="/html/browsers/browsing-the-web/back-forward-cache/resources/helper.sub.js"></script>
<script>
const connectToSharedWorker = async () => {
await window.pageShowPromise;
window.worker = new SharedWorker("/web-locks/resources/worker.js");
worker.port.start();
}
function double_docs_test(func, description) {
promise_test(async t => {
const pageA1 = new RemoteContext(token());
const pageA2 = new RemoteContext(token());
const pageB1 = new RemoteContext(token());
const pageB2 = new RemoteContext(token());
const urlA1 = executorPath + pageA1.context_id;
const urlA2 = executorPath + pageA2.context_id;
const urlB1 = originCrossSite + executorPath + pageB1.context_id;
const urlB2 = originCrossSite + executorPath + pageB2.context_id;
window.open(urlA1, "_blank", "noopener");
window.open(urlA2, "_blank", "noopener");
await func(pageA1, pageA2);
await Promise.all([
navigateAndThenBack(pageA1, pageB1, urlB1),
navigateAndThenBack(pageA2, pageB2, urlB2),
]);
await assert_not_bfcached(pageA1);
await assert_not_bfcached(pageA2);
}, description);
}
double_docs_test(async (pageA1, pageA2) => {
await Promise.all([
pageA1.execute_script(connectToSharedWorker),
pageA2.execute_script(connectToSharedWorker),
]);
await pageA1.execute_script(async () => {
const script = document.createElement("script");
script.src = "/web-locks/resources/helpers.js";
document.head.append(script);
await new Promise(resolve => script.onload = resolve);
await postToWorkerAndWait(worker.port, { op: "request", name: uniqueNameByQuery() });
});
}, "A new held lock must prevent bfcache on all connected documents");
double_docs_test(async (pageA1, pageA2) => {
await pageA1.execute_script(connectToSharedWorker);
await pageA1.execute_script(async () => {
const script = document.createElement("script");
script.src = "/web-locks/resources/helpers.js";
document.head.append(script);
await new Promise(resolve => script.onload = resolve);
await postToWorkerAndWait(worker.port, { op: "request", name: uniqueNameByQuery() });
});
await pageA2.execute_script(connectToSharedWorker);
}, "An existing held lock must prevent bfcache on all connected documents");
</script>

View file

@ -0,0 +1,45 @@
<!DOCTYPE html>
<meta charset=utf-8>
<title>Web Locks API: Client IDs in query() vs. Service Worker</title>
<link rel=help href="https://w3c.github.io/web-locks/">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/service-workers/service-worker/resources/test-helpers.sub.js"></script>
<script>
// Returns a promise resolved by the next message event.
function nextMessage() {
return new Promise(resolve => {
window.addEventListener('message', event => {
resolve(event.data);
}, {once: true});
});
}
promise_test(async t => {
assert_implements(navigator.locks);
const iframe_url = 'resources/sw-controlled-iframe.html';
// Register a service worker that will control an iframe.
const registration = await service_worker_unregister_and_register(
t, 'resources/service-worker.js', iframe_url);
await wait_for_state(t, registration.installing, 'activated');
const iframe = await with_iframe(iframe_url);
iframe.contentWindow.postMessage('get_sw_client_id', '*');
const sw_client_id = await nextMessage();
iframe.contentWindow.postMessage('get_lock_client_id', '*');
const lock_client_id = await nextMessage();
// NOTE: Not assert_equals(), as we don't want log the randomly generated
// clientIds, since they would not match any failure expectation files.
assert_equals(lock_client_id, sw_client_id,
'clientIds should match, but are different');
await registration.unregister();
}, 'Client IDs match between Locks API and Service Workers');
</script>

View file

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html class="test-wait">
<meta charset="utf-8">
<script>
const script = `
postMessage("hi");
// This line runs until worker.terminate() happens, which terminates this function too.
self.reportError(new Int16Array(2147483648))
// And thus this line runs after the termination.
navigator.locks.request("weblock_0", () => {});
`;
const worker = new Worker(URL.createObjectURL(new Blob([script])));
worker.onmessage = () => {
worker.terminate();
// We want to wait for the full termination but there is no API for that
// So, just wait for a random time
setTimeout(() => document.documentElement.classList.remove("test-wait"), 100);
}
</script>

View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<meta charset="utf-8">
<iframe id="id_0"></iframe>
<script>
window.addEventListener("load", () => {
const iframe = document.getElementById("id_0")
// Discards the previous document
document.documentElement.appendChild(iframe)
const xhr = new XMLHttpRequest()
// LockManager is created after discarding
// At this point the new document is not there yet
iframe.contentWindow.navigator.locks.request("weblock_0", () => {
xhr.open("GET", "FOOBAR", false)
xhr.send()
// Now there is a new document
})
})
</script>

View file

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html class="test-wait">
<meta charset="utf-8">
<iframe id="id_0"></iframe>
<script>
/** @param {HTMLIFrameElement} iframe */
function waitForLoad(iframe) {
// iframe is initialized immediately on Chrome while it needs some time on Firefox
if (iframe.contentDocument.readyState === "complete") {
return;
}
return new Promise(r => iframe.onload = r);
}
const iframe = document.getElementById("id_0");
iframe.contentWindow.navigator.locks.request("weblock_0", async () => {
await waitForLoad(iframe);
document.body.append(iframe); // discards the document and destroys locks
document.documentElement.classList.remove("test-wait");
});
</script>

View file

@ -0,0 +1,10 @@
<!DOCTYPE html>
<meta charset="utf-8">
<html class="test-wait">
<script>
navigator.locks.request("foo", async () => {
await new Promise(queueMicrotask);
document.documentElement.classList.remove("test-wait");
});
navigator.locks.request("foo", { steal: true }, () => {});
</script>

View file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html class="test-wait">
<meta charset="utf-8">
<script>
var worker = new Worker(URL.createObjectURL(new Blob([`
postMessage("hi");
(async () => {
const abort = new AbortController()
await navigator.locks.request("weblock_0", { signal: abort.signal }, () => {})
})()
`])));
worker.onmessage = () => {
worker.terminate();
document.documentElement.classList.remove("test-wait");
};
</script>

View file

@ -0,0 +1,265 @@
<!DOCTYPE html>
<meta charset=utf-8>
<title>Web Locks API: Frames</title>
<link rel=help href="https://w3c.github.io/web-locks/">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="resources/helpers.js"></script>
<style>iframe { display: none; }</style>
<script>
'use strict';
promise_test(async t => {
assert_implements(navigator.locks);
const res = uniqueName(t);
const frame = await iframe('resources/iframe.html');
t.add_cleanup(() => { frame.remove(); });
const lock_id = (await postToFrameAndWait(
frame, {op: 'request', name: res, mode: 'shared'})).lock_id;
await navigator.locks.request(res, {mode: 'shared'}, async lock => {
await postToFrameAndWait(frame, {op: 'release', lock_id});
});
}, 'Window and Frame - shared mode');
promise_test(async t => {
assert_implements(navigator.locks);
const res = uniqueName(t);
const frame = await iframe('resources/iframe.html');
t.add_cleanup(() => { frame.remove(); });
// frame acquires the lock.
const lock_id = (await postToFrameAndWait(
frame, {op: 'request', name: res})).lock_id;
// This request should be blocked.
let lock_granted = false;
const blocked = navigator.locks.request(res, lock => { lock_granted = true; });
// Verify that we can't get it.
let available = undefined;
await navigator.locks.request(
res, {ifAvailable: true}, lock => { available = lock !== null; });
assert_false(available);
assert_false(lock_granted);
// Ask the frame to release it.
await postToFrameAndWait(frame, {op: 'release', lock_id});
await blocked;
// Now we've got it.
assert_true(lock_granted);
}, 'Window and Frame - exclusive mode');
promise_test(async t => {
assert_implements(navigator.locks);
const res = uniqueName(t);
const frame1 = await iframe('resources/iframe.html');
const frame2 = await iframe('resources/iframe.html');
// frame1 acquires the lock.
const lock_id = (await postToFrameAndWait(
frame1, {op: 'request', name: res})).lock_id;
// frame2's request should be blocked.
let lock_granted = false;
const blocked = postToFrameAndWait(
frame2, {op: 'request', name: res});
blocked.then(f => { lock_granted = true; });
// Verify that frame2 can't get it.
assert_true((await postToFrameAndWait(frame2, {
op: 'request', name: res, ifAvailable: true
})).failed, 'Lock request should have failed');
assert_false(lock_granted);
// Ask frame1 to release it.
await postToFrameAndWait(frame1, {op: 'release', lock_id});
await blocked;
// Now frame2 can get it.
assert_true(lock_granted);
frame1.parentElement.removeChild(frame1);
frame2.parentElement.removeChild(frame2);
}, 'Frame and Frame - exclusive mode');
promise_test(async t => {
assert_implements(navigator.locks);
const res = uniqueName(t);
const frame = await iframe('resources/iframe.html');
// Frame acquires the lock.
await postToFrameAndWait(frame, {op: 'request', name: res});
// This request should be blocked.
let lock_granted = false;
const blocked = navigator.locks.request(
res, lock => { lock_granted = true; });
// Verify that we can't get it.
let available = undefined;
await navigator.locks.request(
res, {ifAvailable: true}, lock => { available = lock !== null; });
assert_false(available);
assert_false(lock_granted);
// Implicitly release it by terminating the frame.
frame.remove();
await blocked;
// Now we've got it.
assert_true(lock_granted);
}, 'Terminated Frame with held lock');
promise_test(async t => {
assert_implements(navigator.locks);
const res = uniqueName(t);
const frame = await iframe('resources/iframe.html');
// Frame acquires the lock.
await postToFrameAndWait(frame, {op: 'request', name: res});
// This request should be blocked.
let lock_granted = false;
const blocked = navigator.locks.request(
res, lock => { lock_granted = true; });
// Verify that we can't get it.
let available = undefined;
await navigator.locks.request(
res, {ifAvailable: true}, lock => { available = lock !== null; });
assert_false(available);
assert_false(lock_granted);
// Implicitly release it by navigating the frame.
frame.src = 'about:blank';
await blocked;
// Now we've got it.
assert_true(lock_granted);
}, 'Navigated Frame with held lock');
promise_test(async t => {
assert_implements(navigator.locks);
const res = uniqueName(t);
// frame1 requests and holds res - should be granted immediately.
// frame2 requests res - should be blocked.
// frame3 requests res - should be blocked.
// frame2 is navigated.
// frame1 releases res.
// frame3's request should be granted.
const frame1 = await iframe('resources/iframe.html');
const frame2 = await iframe('resources/iframe.html');
const frame3 = await iframe('resources/iframe.html');
t.add_cleanup(() => { frame1.remove(); });
t.add_cleanup(() => { frame2.remove(); });
t.add_cleanup(() => { frame3.remove(); });
// frame1 requests and holds res - should be granted immediately.
const lock_id = (await postToFrameAndWait(
frame1, {op: 'request', name: res})).lock_id;
// frame2 requests res - should be blocked.
// (don't attach listeners as they will keep the frame alive)
frame2.contentWindow.postMessage({op: 'request', name: res}, '*');
// frame3 requests res - should be blocked.
let lock_granted = false;
const blocked = postToFrameAndWait(frame3, {op: 'request', name: res});
blocked.then(f => { lock_granted = true; });
// Verify that frame3 can't get it.
assert_true((await postToFrameAndWait(frame3, {
op: 'request', name: res, ifAvailable: true
})).failed, 'Lock request should have failed');
assert_false(lock_granted);
// Navigate frame2.
frame2.src = 'about:blank';
// frame1 releases lock
await postToFrameAndWait(frame1, {op: 'release', lock_id});
// frame3's request should be granted.
await blocked;
assert_true(lock_granted);
}, 'Navigated Frame with pending request');
promise_test(async t => {
assert_implements(navigator.locks);
const res = uniqueName(t);
// frame1 requests and holds res - should be granted immediately.
// frame2 requests res - should be blocked.
// frame3 requests res - should be blocked.
// frame2 is removed.
// frame1 drops lock.
// frame3's request should be granted.
const frame1 = await iframe('resources/iframe.html');
const frame2 = await iframe('resources/iframe.html');
const frame3 = await iframe('resources/iframe.html');
t.add_cleanup(() => { frame1.remove(); });
t.add_cleanup(() => { frame3.remove(); });
// frame1 requests and holds res - should be granted immediately.
const lock_id = (await postToFrameAndWait(
frame1, {op: 'request', name: res})).lock_id;
// frame2 requests res - should be blocked.
// (don't attach listeners as they will keep the frame alive)
frame2.contentWindow.postMessage({op: 'request', name: res}, '*');
// frame3 requests res - should be blocked.
let lock_granted = false;
const blocked = postToFrameAndWait(frame3, {op: 'request', name: res});
blocked.then(f => { lock_granted = true; });
// So frame3 can't get it
assert_true((await postToFrameAndWait(frame3, {
op: 'request', name: res, ifAvailable: true
})).failed, 'Lock request should have failed');
assert_false(lock_granted);
// Remove frame2.
frame2.remove();
// frame1 releases lock
await postToFrameAndWait(frame1, {op: 'release', lock_id});
// frame3's request should be granted.
await blocked;
assert_true(lock_granted);
}, 'Removed Frame with pending request');
promise_test(async t => {
assert_implements(navigator.locks);
const res = uniqueName(t);
const frame = await iframe('about:blank');
// Remove a frame while it is in the process of requesting a lock.
// The promise returned by `request` will never resolve since its frame no
// longer exists, but the lock should still be released.
await new Promise(resolve => {
frame.contentWindow.navigator.locks.request(res, () => {
frame.remove();
resolve();
});
});
assert_false((await navigator.locks.query()).held.includes(res));
}, 'Removed Frame as lock is granted');
</script>

View file

@ -0,0 +1,91 @@
// META: title=Web Locks API: Lock held until callback result resolves
// META: script=resources/helpers.js
// META: global=window,dedicatedworker,sharedworker,serviceworker
'use strict';
// For uncaught rejections.
setup({allow_uncaught_exception: true});
function snooze(t, ms) { return new Promise(r => t.step_timeout(r, ms)); }
promise_test(async t => {
const res = uniqueName(t);
const p = navigator.locks.request(res, lock => 123);
assert_equals(Promise.resolve(p), p, 'request() result is a Promise');
assert_equals(await p, 123, 'promise resolves to the returned value');
}, 'callback\'s result is promisified if not async');
promise_test(async t => {
const res = uniqueName(t);
// Resolved when the lock is granted.
let granted;
const lock_granted_promise = new Promise(r => { granted = r; });
// Lock is held until this is resolved.
let resolve;
const lock_release_promise = new Promise(r => { resolve = r; });
const order = [];
navigator.locks.request(res, lock => {
granted(lock);
return lock_release_promise;
});
await lock_granted_promise;
await Promise.all([
snooze(t, 50).then(() => {
order.push('1st lock released');
resolve();
}),
navigator.locks.request(res, () => {
order.push('2nd lock granted');
})
]);
assert_array_equals(order, ['1st lock released', '2nd lock granted']);
}, 'lock is held until callback\'s returned promise resolves');
promise_test(async t => {
const res = uniqueName(t);
// Resolved when the lock is granted.
let granted;
const lock_granted_promise = new Promise(r => { granted = r; });
// Lock is held until this is rejected.
let reject;
const lock_release_promise = new Promise((_, r) => { reject = r; });
const order = [];
navigator.locks.request(res, lock => {
granted(lock);
return lock_release_promise;
});
await lock_granted_promise;
await Promise.all([
snooze(t, 50).then(() => {
order.push('reject');
reject(new Error('this uncaught rejection is expected'));
}),
navigator.locks.request(res, () => {
order.push('2nd lock granted');
})
]);
assert_array_equals(order, ['reject', '2nd lock granted']);
}, 'lock is held until callback\'s returned promise rejects');
promise_test(async t => {
const res = uniqueName(t);
let callback_called = false;
await navigator.locks.request(res, async lock => {
await navigator.locks.request(res, {ifAvailable: true}, lock => {
callback_called = true;
assert_equals(lock, null, 'lock request should fail if held');
});
});
assert_true(callback_called, 'callback should have executed');
}, 'held lock prevents the same client from acquiring it');

View file

@ -0,0 +1,29 @@
// META: script=/resources/WebIDLParser.js
// META: script=/resources/idlharness.js
// META: global=window,dedicatedworker,sharedworker,serviceworker
// META: timeout=long
'use strict';
idl_test(
['web-locks'],
['html'],
async idl_array => {
idl_array.add_objects({
LockManager: ['navigator.locks'],
Lock: ['lock'],
});
if (self.Window) {
idl_array.add_objects({ Navigator: ['navigator'] });
} else {
idl_array.add_objects({ WorkerNavigator: ['navigator'] });
}
try {
await navigator.locks.request('name', l => { self.lock = l; });
} catch (e) {
// Surfaced in idlharness.js's test_object below.
}
}
);

View file

@ -0,0 +1,163 @@
// META: title=Web Locks API: ifAvailable option
// META: script=resources/helpers.js
// META: global=window,dedicatedworker,sharedworker,serviceworker
'use strict';
promise_test(async t => {
const res = self.uniqueName(t);
let callback_called = false;
await navigator.locks.request(res, {ifAvailable: true}, async lock => {
callback_called = true;
assert_not_equals(lock, null, 'lock should be granted');
});
assert_true(callback_called, 'callback should be called');
}, 'Lock request with ifAvailable - lock available');
promise_test(async t => {
const res = self.uniqueName(t);
let callback_called = false;
await navigator.locks.request(res, async lock => {
// Request would time out if |ifAvailable| was not specified.
const result = await navigator.locks.request(
res, {ifAvailable: true}, async lock => {
callback_called = true;
assert_equals(lock, null, 'lock should not be granted');
return 123;
});
assert_equals(result, 123, 'result should be value returned by callback');
});
assert_true(callback_called, 'callback should be called');
}, 'Lock request with ifAvailable - lock not available');
promise_test(async t => {
const res = self.uniqueName(t);
let callback_called = false;
await navigator.locks.request(res, async lock => {
try {
// Request would time out if |ifAvailable| was not specified.
await navigator.locks.request(res, {ifAvailable: true}, async lock => {
callback_called = true;
assert_equals(lock, null, 'lock should not be granted');
throw 123;
});
assert_unreached('call should throw');
} catch (ex) {
assert_equals(ex, 123, 'ex should be value thrown by callback');
}
});
assert_true(callback_called, 'callback should be called');
}, 'Lock request with ifAvailable - lock not available, callback throws');
promise_test(async t => {
const res = self.uniqueName(t);
let callback_called = false;
await navigator.locks.request(res, async lock => {
// Request with a different name - should be grantable.
await navigator.locks.request('different', {ifAvailable: true}, async lock => {
callback_called = true;
assert_not_equals(lock, null, 'lock should be granted');
});
});
assert_true(callback_called, 'callback should be called');
}, 'Lock request with ifAvailable - unrelated lock held');
promise_test(async t => {
const res = self.uniqueName(t);
let callback_called = false;
await navigator.locks.request(res, {mode: 'shared'}, async lock => {
await navigator.locks.request(
res, {mode: 'shared', ifAvailable: true}, async lock => {
callback_called = true;
assert_not_equals(lock, null, 'lock should be granted');
});
});
assert_true(callback_called, 'callback should be called');
}, 'Shared lock request with ifAvailable - shared lock held');
promise_test(async t => {
const res = self.uniqueName(t);
let callback_called = false;
await navigator.locks.request(res, {mode: 'shared'}, async lock => {
// Request would time out if |ifAvailable| was not specified.
await navigator.locks.request(res, {ifAvailable: true}, async lock => {
callback_called = true;
assert_equals(lock, null, 'lock should not be granted');
});
});
assert_true(callback_called, 'callback should be called');
}, 'Exclusive lock request with ifAvailable - shared lock held');
promise_test(async t => {
const res = self.uniqueName(t);
let callback_called = false;
await navigator.locks.request(res, async lock => {
// Request would time out if |ifAvailable| was not specified.
await navigator.locks.request(
res, {mode: 'shared', ifAvailable: true}, async lock => {
callback_called = true;
assert_equals(lock, null, 'lock should not be granted');
});
});
assert_true(callback_called, 'callback should be called');
}, 'Shared lock request with ifAvailable - exclusive lock held');
promise_test(async t => {
const res = self.uniqueName(t);
let callback_called = false;
await navigator.locks.request(res, async lock => {
callback_called = true;
const test_error = {name: 'test'};
const p = navigator.locks.request(
res, {ifAvailable: true}, lock => {
assert_equals(lock, null, 'lock should not be available');
throw test_error;
});
assert_equals(Promise.resolve(p), p, 'request() result is a Promise');
await promise_rejects_exactly(t, test_error, p, 'result should reject');
});
assert_true(callback_called, 'callback should be called');
}, 'Returned Promise rejects if callback throws synchronously');
promise_test(async t => {
const res = self.uniqueName(t);
let callback_called = false;
await navigator.locks.request(res, async lock => {
callback_called = true;
const test_error = {name: 'test'};
const p = navigator.locks.request(
res, {ifAvailable: true}, async lock => {
assert_equals(lock, null, 'lock should not be available');
throw test_error;
});
assert_equals(Promise.resolve(p), p, 'request() result is a Promise');
await promise_rejects_exactly(t, test_error, p, 'result should reject');
});
assert_true(callback_called, 'callback should be called');
}, 'Returned Promise rejects if async callback yields rejected promise');
// Regression test for: https://crbug.com/840994
promise_test(async t => {
const res1 = self.uniqueName(t);
const res2 = self.uniqueName(t);
let callback1_called = false;
await navigator.locks.request(res1, async lock => {
callback1_called = true;
let callback2_called = false;
await navigator.locks.request(res2, async lock => {
callback2_called = true;
});
assert_true(callback2_called, 'callback2 should be called');
let callback3_called = false;
await navigator.locks.request(res2, {ifAvailable: true}, async lock => {
callback3_called = true;
// This request would fail if the "is this grantable?" test
// failed, e.g. due to the release without a pending request
// skipping steps.
assert_not_equals(lock, null, 'Lock should be available');
});
assert_true(callback3_called, 'callback2 should be called');
});
assert_true(callback1_called, 'callback1 should be called');
}, 'Locks are available once previous release is processed');

View file

@ -0,0 +1,18 @@
// META: title=Web Locks API: Lock Attributes
// META: global=window,dedicatedworker,sharedworker,serviceworker
'use strict';
promise_test(async t => {
await navigator.locks.request('resource', lock => {
assert_equals(lock.name, 'resource');
assert_equals(lock.mode, 'exclusive');
});
}, 'Lock attributes reflect requested properties (exclusive)');
promise_test(async t => {
await navigator.locks.request('resource', {mode: 'shared'}, lock => {
assert_equals(lock.name, 'resource');
assert_equals(lock.mode, 'shared');
});
}, 'Lock attributes reflect requested properties (shared)');

View file

@ -0,0 +1,34 @@
// META: title=Web Locks API: Exclusive Mode
// META: global=window,dedicatedworker,sharedworker,serviceworker
'use strict';
promise_test(async t => {
const granted = [];
function log_grant(n) { return () => { granted.push(n); }; }
await Promise.all([
navigator.locks.request('a', log_grant(1)),
navigator.locks.request('a', log_grant(2)),
navigator.locks.request('a', log_grant(3))
]);
assert_array_equals(granted, [1, 2, 3]);
}, 'Lock requests are granted in order');
promise_test(async t => {
const granted = [];
function log_grant(n) { return () => { granted.push(n); }; }
let inner_promise;
await navigator.locks.request('a', async lock => {
inner_promise = Promise.all([
// This will be blocked.
navigator.locks.request('a', log_grant(1)),
// But this should be grantable immediately.
navigator.locks.request('b', log_grant(2))
]);
});
await inner_promise;
assert_array_equals(granted, [2, 1]);
}, 'Requests for distinct resources can be granted');

View file

@ -0,0 +1,98 @@
// META: title=Web Locks API: Mixed Modes
// META: script=resources/helpers.js
// META: global=window,dedicatedworker,sharedworker,serviceworker
'use strict';
promise_test(async t => {
let unblock;
const blocked = new Promise(r => { unblock = r; });
const granted = [];
// These should be granted immediately, and held until unblocked.
navigator.locks.request('a', {mode: 'shared'}, async lock => {
granted.push('a-shared-1'); await blocked; });
navigator.locks.request('a', {mode: 'shared'}, async lock => {
granted.push('a-shared-2'); await blocked; });
navigator.locks.request('a', {mode: 'shared'}, async lock => {
granted.push('a-shared-3'); await blocked; });
// This should be blocked.
let exclusive_lock;
const exclusive_request = navigator.locks.request('a', async lock => {
granted.push('a-exclusive');
exclusive_lock = lock;
});
// This should be granted immediately (different name).
await navigator.locks.request('b', {mode: 'exclusive'}, lock => {
granted.push('b-exclusive'); });
assert_array_equals(
granted, ['a-shared-1', 'a-shared-2', 'a-shared-3', 'b-exclusive']);
// Release the shared locks granted above.
unblock();
// Now the blocked request can be granted.
await exclusive_request;
assert_equals(exclusive_lock.mode, 'exclusive');
assert_array_equals(
granted,
['a-shared-1', 'a-shared-2', 'a-shared-3', 'b-exclusive', 'a-exclusive']);
}, 'Lock requests are granted in order');
promise_test(async t => {
const res = uniqueName(t);
let [promise, resolve] = makePromiseAndResolveFunc();
const exclusive = navigator.locks.request(res, () => promise);
for (let i = 0; i < 5; i++) {
requestLockAndHold(t, res, { mode: "shared" });
}
let answer = await navigator.locks.query();
assert_equals(answer.held.length, 1, "An exclusive lock is held");
assert_equals(answer.pending.length, 5, "Requests for shared locks are pending");
resolve();
await exclusive;
answer = await navigator.locks.query();
assert_equals(answer.held.length, 5, "Shared locks are held");
assert_true(answer.held.every(l => l.mode === "shared"), "All held locks are shared ones");
}, 'Releasing exclusive lock grants multiple shared locks');
promise_test(async t => {
const res = uniqueName(t);
let [sharedPromise, sharedResolve] = makePromiseAndResolveFunc();
let [exclusivePromise, exclusiveResolve] = makePromiseAndResolveFunc();
const sharedReleasedPromise = Promise.all(new Array(5).fill(0).map(
() => navigator.locks.request(res, { mode: "shared" }, () => sharedPromise))
);
const exclusiveReleasedPromise = navigator.locks.request(res, () => exclusivePromise);
for (let i = 0; i < 5; i++) {
requestLockAndHold(t, res, { mode: "shared" });
}
let answer = await navigator.locks.query();
assert_equals(answer.held.length, 5, "Shared locks are held");
assert_true(answer.held.every(l => l.mode === "shared"), "All held locks are shared ones");
sharedResolve();
await sharedReleasedPromise;
answer = await navigator.locks.query();
assert_equals(answer.held.length, 1, "An exclusive lock is held");
assert_equals(answer.held[0].mode, "exclusive");
exclusiveResolve();
await exclusiveReleasedPromise;
answer = await navigator.locks.query();
assert_equals(answer.held.length, 5, "The next shared locks are held");
assert_true(answer.held.every(l => l.mode === "shared"), "All held locks are shared ones");
}, 'An exclusive lock between shared locks');

View file

@ -0,0 +1,38 @@
// META: title=Web Locks API: Shared Mode
// META: global=window,dedicatedworker,sharedworker,serviceworker
'use strict';
promise_test(async t => {
const granted = [];
function log_grant(n) { return () => { granted.push(n); }; }
await Promise.all([
navigator.locks.request('a', {mode: 'shared'}, log_grant(1)),
navigator.locks.request('b', {mode: 'shared'}, log_grant(2)),
navigator.locks.request('c', {mode: 'shared'}, log_grant(3)),
navigator.locks.request('a', {mode: 'shared'}, log_grant(4)),
navigator.locks.request('b', {mode: 'shared'}, log_grant(5)),
navigator.locks.request('c', {mode: 'shared'}, log_grant(6)),
]);
assert_array_equals(granted, [1, 2, 3, 4, 5, 6]);
}, 'Lock requests are granted in order');
promise_test(async t => {
let a_acquired = false, a_acquired_again = false;
await navigator.locks.request('a', {mode: 'shared'}, async lock => {
a_acquired = true;
// Since lock is held, this request would be blocked if the
// lock was not 'shared', causing this test to time out.
await navigator.locks.request('a', {mode: 'shared'}, lock => {
a_acquired_again = true;
});
});
assert_true(a_acquired, 'first lock acquired');
assert_true(a_acquired_again, 'second lock acquired');
}, 'Shared locks are not exclusive');

View file

@ -0,0 +1,73 @@
<!DOCTYPE html>
<meta charset=utf-8>
<title>Web Locks API: Non-fully-active documents</title>
<link rel=help href="https://w3c.github.io/web-locks/">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="resources/helpers.js"></script>
<div></div>
<script>
function createNonFullyActiveIframe(src) {
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
const { navigator, DOMException, postMessage } = iframe.contentWindow;
iframe.remove();
return { iframe, navigator, DOMException, postMessage };
}
promise_test(async t => {
const { navigator, DOMException } = createNonFullyActiveIframe();
const p = navigator.locks.request("foo", t.unreached_func());
await promise_rejects_dom(t, "InvalidStateError", DOMException, p, "Request should explicitly fail");
}, "request() on non-fully-active document must fail");
promise_test(async t => {
const { navigator, DOMException } = createNonFullyActiveIframe();
const p = navigator.locks.query();
await promise_rejects_dom(t, "InvalidStateError", DOMException, p, "Query should explicitly fail");
}, "query() on a non-fully-active document must fail");
promise_test(async t => {
const { navigator, DOMException, postMessage } = createNonFullyActiveIframe();
const p = navigator.locks.request("-", t.unreached_func());
await promise_rejects_dom(t, "InvalidStateError", DOMException, p, "Request should explicitly fail");
}, "request()'s fully-active check happens earlier than name validation");
promise_test(async t => {
const { iframe, navigator, DOMException } = createNonFullyActiveIframe();
document.body.append(iframe);
t.add_cleanup(() => iframe.remove());
// Appending should create a new browsing context with a new Navigator object
// https://html.spec.whatwg.org/multipage/iframe-embed-object.html#the-iframe-element:insert-an-element-into-a-document
// https://html.spec.whatwg.org/multipage/system-state.html#the-navigator-object:associated-navigator
assert_not_equals(navigator, iframe.contentWindow.navigator, "Navigator object changes");
assert_not_equals(navigator.locks, iframe.contentWindow.navigator.locks, "LockManager object changes");
const p = navigator.locks.request("foo", t.unreached_func());
await promise_rejects_dom(t, "InvalidStateError", DOMException, p, "Request on the previous LockManager still must fail");
}, "Reactivated iframe must not reuse the previous LockManager");
promise_test(async t => {
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
const worker = new iframe.contentWindow.Worker("resources/worker.js");
const name = uniqueName(t);
await postToWorkerAndWait(worker, { op: 'request', name });
let query = await navigator.locks.query();
assert_equals(query.held.length, 1, "One lock is present");
iframe.remove();
const lock = await navigator.locks.request(name, lock => lock);
assert_equals(lock.name, name, "The following lock should be processed");
query = await navigator.locks.query();
assert_equals(query.held.length, 0, "No lock is present");
}, "Workers owned by an unloaded iframe must release their locks");
</script>

View file

@ -0,0 +1,14 @@
// META: title=Web Locks API: API not available in non-secure context
// META: global=window,dedicatedworker,sharedworker
'use strict';
test(t => {
assert_false(self.isSecureContext);
assert_false('locks' in navigator,
'navigator.locks is only present in secure contexts');
assert_false('LockManager' in self,
'LockManager is only present in secure contexts');
assert_false('Lock' in self,
'Lock interface is only present in secure contexts');
}, 'API presence in non-secure contexts');

View file

@ -0,0 +1,87 @@
<!DOCTYPE html>
<meta charset=utf-8>
<title>Web Locks API: Opaque origins</title>
<link rel=help href="https://w3c.github.io/web-locks/">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script>
function load_iframe(src, sandbox) {
return new Promise(resolve => {
const iframe = document.createElement('iframe');
iframe.onload = () => { resolve(iframe); };
if (sandbox)
iframe.sandbox = sandbox;
iframe.srcdoc = src;
iframe.style.display = 'none';
document.documentElement.appendChild(iframe);
});
}
function wait_for_message(iframe) {
return new Promise(resolve => {
self.addEventListener('message', function listener(e) {
if (e.source === iframe.contentWindow) {
resolve(e.data);
self.removeEventListener('message', listener);
}
});
});
}
const script = `
<script>
"use strict";
window.onmessage = async (ev) => {
try {
switch (ev.data) {
case "request":
await navigator.locks.request('name', lock => {});
break;
case "query":
await navigator.locks.query();
break;
default:
window.parent.postMessage({result: "unexpected message"}, "*");
return;
}
window.parent.postMessage({result: "no exception"}, "*");
} catch (ex) {
window.parent.postMessage({result: ex.name}, "*");
};
};
<\/script>
`;
promise_test(async t => {
const iframe = await load_iframe(script);
iframe.contentWindow.postMessage("request", '*');
const message = await wait_for_message(iframe);
assert_equals(message.result, 'no exception',
'navigator.locks.request() should not throw');
}, 'navigator.locks.request() in non-sandboxed iframe should not throw');
promise_test(async t => {
const iframe = await load_iframe(script, 'allow-scripts');
iframe.contentWindow.postMessage("request", '*');
const message = await wait_for_message(iframe);
assert_equals(message.result, 'SecurityError',
'Exception should be SecurityError');
}, 'navigator.locks.request() in sandboxed iframe should throw SecurityError');
promise_test(async t => {
const iframe = await load_iframe(script);
iframe.contentWindow.postMessage("query", '*');
const message = await wait_for_message(iframe);
assert_equals(message.result, 'no exception',
'navigator.locks.request() should not throw');
}, 'navigator.locks.query() in non-sandboxed iframe should not throw');
promise_test(async t => {
const iframe = await load_iframe(script, 'allow-scripts');
iframe.contentWindow.postMessage("query", '*');
const message = await wait_for_message(iframe);
assert_equals(message.result, 'SecurityError',
'Exception should be SecurityError');
}, 'navigator.locks.query() in sandboxed iframe should throw SecurityError');
</script>

View file

@ -0,0 +1,172 @@
<!DOCTYPE html>
<meta charset="utf-8"/>
<title>Web Locks API: Partitioned WebLocks</title>
<!-- Pull in get_host_info() -->
<script src="/common/get-host-info.sub.js"></script>
<script src="/common/utils.js"></script>
<script src="/common/dispatcher/dispatcher.js"></script>
<script src="resources/helpers.js"></script>
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<body>
<script>
const { HTTPS_ORIGIN, HTTPS_NOTSAMESITE_ORIGIN } = get_host_info();
// Map of lock_id => function that releases a lock.
const held = new Map();
let next_lock_id = 1;
// How this test works:
// Step 1 (top-frame): request an exclusive web-lock and store its id
// and release for clean-up.
// Step 2 (top-frame): open a pop-up window and load a not-same-site
// ./web-locks/resources/partitioned-parent.html
// Step 3 (pop-up): load a same-site iframe inside the pop-up.
// Step 4 (pop-up): send a web-lock request to the same-site iframe.
// Step 5 (iframe): process the web-lock request and message the result
// back to the pop-up.
// Step 6 (pop-up): intercept the result message from the iframe and
// send it to the top-frame.
// Step 7 (top-frame): add cleanup hook.
// Step 8 (top-frame): ensure that the same-site iframe's web-lock
// request succeeds since it and the top-level site are successfully
// partitioned and each can hold an exclusive lock.
async function third_party_test(t) {
let target_url = HTTPS_ORIGIN + '/web-locks/resources/iframe.html';
target_url = new URL(
`/web-locks/resources/partitioned-parent.html?target=${encodeURIComponent(target_url)}`,
HTTPS_NOTSAMESITE_ORIGIN + self.location.pathname);
// Step 1.
let lock_id = next_lock_id++;
let [ promise, release ] = makePromiseAndResolveFunc();
let released = navigator.locks.request('testLock', {mode: 'exclusive', ifAvailable: true},
lock => {
if (lock === null) {
assert_true(false)
return;
}
return promise;
});
held.set(lock_id, { release, released });
// Step 2.
const w = window.open(target_url);
const result = await new Promise(resolve => window.onmessage = resolve);
// Step 7.
t.add_cleanup(() => {
w.close();
let released = [];
for(let i = 1; i < next_lock_id; i++){
let h = held.get(i);
h.release();
released.push(h.released);
}
return Promise.allSettled(released);
});
// Step 8.
// When 3rd party storage partitioning is enabled, the iframe should be able
// to acquire a lock with the same name as one exclusively held by the opener
// of its top window, even when that opener has the same origin.
assert_equals(result.data.failed, undefined,
'The 1p iframe failed to acquire the lock');
}
promise_test(t => {
return third_party_test(t);
}, 'WebLocks of an iframe under a 3rd-party site are partitioned');
// Optional Test: Checking for partitioned web locks in an A->B->A
// (nested-iframe with cross-site ancestor chain) scenario.
//
// How this test works:
// Nested Step 1 (top frame): request an exclusive web-lock and
// store its id and release for clean-up.
// Nested Step 2 (top frame): open a pop-up window and load a
// same-site /web-locks/resources/partitioned-parent.html.
// Nested Step 3 (pop-up): load a not-same-site "parent" iframe (A->B)
// (/web-locks/resources/iframe-parent.html) inside the pop-up.
// Nested Step 4 (pop-up): send a web-lock request to the parent iframe.
// Nested Step 5 (parent iframe): load a "child" iframe (A->B->A)
// (/web-locks/resources/iframe.html) that is same-site with the
// pop-up inside the "parent" iframe.
// Nested Step 6 (parent iframe): pass on the web-lock request message to
// the "child" iframe.
// Nested Step 7 (child iframe): process the web-lock request and message
// the result to the parent iframe.
// Nested Step 8 (parent iframe): intercept the result message from the
// child iframe and send it to the pop-up.
// Nested Step 9 (pop-up): intercept the result message from the parent
// iframe and send it to the top frame.
// Nested Step 10 (top frame): add cleanup hook
// Nested Step 11 (top frame): ensure that the same-site iframe's web-lock
// request succeeds since it and the top-level are successfully
// partitioned and each can hold an exclusive lock.
// Map of lock_id => function that releases a lock.
const held_2 = new Map();
let next_lock_id_2 = 1;
async function nested_iframe_test(t) {
// Create innermost child iframe (leaf).
let leaf_url = HTTPS_ORIGIN + '/web-locks/resources/iframe.html';
// Wrap the child iframe in its cross-origin parent (middle).
let middle_url = new URL(
`/web-locks/resources/iframe-parent.html?target=${encodeURIComponent(leaf_url)}`,
HTTPS_NOTSAMESITE_ORIGIN + self.location.pathname);
// Embed the parent iframe in the top-level site (top).
let top_url = new URL(
`/web-locks/resources/partitioned-parent.html?target=${encodeURIComponent(middle_url)}`,
HTTPS_ORIGIN + self.location.pathname);
// Nested Step 1.
// Request the weblock for the top-level site.
let lock_id = next_lock_id_2++;
let [ promise, release ] = makePromiseAndResolveFunc();
let released = navigator.locks.request('testLock', {mode: 'exclusive', ifAvailable: true},
lock => {
if (lock === null) {
assert_true(false)
return;
}
return promise;
}).catch(error => alert(error.message));
held_2.set(lock_id, { release, released });
// Nested Step 2.
// Open the nested iframes. The script in the innermost child iframe
// will attempt to obtain the same weblock as above.
const w = window.open(top_url);
const result = await new Promise(resolve => window.onmessage = resolve);
// Nested Step 10.
t.add_cleanup(() => {
w.close();
let released = [];
for(let i = 1; i < next_lock_id; i++){
let h = held_2.get(i);
h.release();
released.push(h.released);
}
return Promise.allSettled(released);
});
// Nested Step 11.
// With third-party storage partitioning enabled, the same-site iframe
// should be able to acquire the lock as it has a cross-site ancestor
// and is partitioned separately from the top-level site.
assert_equals(result.data.failed, undefined,
'The 1p iframe failed to acquire the lock');
}
promise_test(t => {
return nested_iframe_test(t);
}, 'WebLocks of a nested iframe with a cross-site ancestor are partitioned');
</script>
</body>

View file

@ -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');

View file

@ -0,0 +1,131 @@
<!DOCTYPE html>
<meta charset=utf-8>
<title>Web Locks API: navigator.locks.query ordering</title>
<link rel=help href="https://w3c.github.io/web-locks/">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="resources/helpers.js"></script>
<style>iframe { display: none; }</style>
<script>
'use strict';
// Grab a lock and hold until a release function is called. Resolves
// to a release function.
function getLockAndHoldUntilReleased(name, options) {
let release;
const promise = new Promise(resolve => { release = resolve; });
return new Promise(resolve => {
navigator.locks.request(name, options || {}, lock => {
resolve(release);
return promise;
}).catch(_ => {});
});
}
// Returns a promise resolved by the next message event.
function nextMessage() {
return new Promise(resolve => {
window.addEventListener('message', event => {
resolve(event.data);
}, {once: true});
});
}
// Tests the ordering constraints on the requested lock state returned by
// navigator.locks.query(). Three separate iframes are instantiated to make
// lock requests on the same resource, first in one order and then in another,
// different order. For each set of requests, it is verified that the requests
// appear in the result of navigator.locks.query() in the same order in which
// they were made.
//
// It is necessary to use separate iframes here so that the lock requests have
// distinguishable client_ids (otherwise it would not be possible to
// distinguish the requests and thus impossible to verify ordering).
promise_test(async testCase => {
assert_implements(navigator.locks);
const resourceName = uniqueName(testCase);
// Set up clients.
const frame1 = await iframe('resources/iframe.html');
const frame2 = await iframe('resources/iframe.html');
const frame3 = await iframe('resources/iframe.html');
testCase.add_cleanup(() => { frame1.remove(); });
testCase.add_cleanup(() => { frame2.remove(); });
testCase.add_cleanup(() => { frame3.remove(); });
// Collect the client ids.
const clientId1 =
(await postToFrameAndWait(frame1, {op: 'client_id',
name: resourceName})).client_id;
const clientId2 =
(await postToFrameAndWait(frame2, {op: 'client_id',
name: resourceName})).client_id;
const clientId3 =
(await postToFrameAndWait(frame3, {op: 'client_id',
name: resourceName})).client_id;
// Preemptively take the lock.
const firstRequestGroupReleaseFunction =
await getLockAndHoldUntilReleased(resourceName);
// Queue the first group of lock requests from the different clients. These
// will be blocked until firstRequestGroupReleaseFunction() is called.
let lockId1;
let lockId2;
const lockPromise1 =
postToFrameAndWait(frame1, {op: 'request', name: resourceName})
.then(val => {lockId1 = val.lock_id;});
const lockPromise2 =
postToFrameAndWait(frame2, {op: 'request', name: resourceName})
.then(val => {lockId2 = val.lock_id;});
// This third request will later be granted and held in order to block a
// second group of requests to test a different client ordering. It is not
// meant to be released.
postToFrameAndWait(frame3, {op: 'request', name: resourceName});
// Request and wait for the release of a separate lock to ensure all previous
// requests are processed.
const checkpointName = uniqueName(testCase, 'checkpoint');
const checkpointId = (await postToFrameAndWait(
frame3,
{op: 'request', name: checkpointName})).lock_id;
await postToFrameAndWait(frame3, {op: 'release', lock_id: checkpointId});
// Query the state and test the ordering of requested locks.
const state = await navigator.locks.query();
const relevant_pending_ids = state.pending
.filter(lock => [clientId1, clientId2, clientId3].includes(lock.clientId))
.map(lock => lock.clientId);
assert_array_equals(
[clientId1, clientId2, clientId3],
relevant_pending_ids,
'Querying the state should return requested locks in the order they were '
+ 'requested.');
// Add the second group of requests from the clients in a new order.
postToFrameAndWait(frame3, {op: 'request', name: resourceName});
postToFrameAndWait(frame1, {op: 'request', name: resourceName});
postToFrameAndWait(frame2, {op: 'request', name: resourceName});
// Release locks such that only the newly added locks are requested. This
// acts like a checkpoint for the newly queued requests.
firstRequestGroupReleaseFunction();
await lockPromise1;
await postToFrameAndWait(frame1, {op: 'release', lock_id: lockId1});
await lockPromise2;
await postToFrameAndWait(frame2, {op: 'release', lock_id: lockId2});
// Query the state and test the new ordering.
const state2 = await navigator.locks.query();
const relevant_pending_ids2 = state2.pending
.filter(lock => [clientId1, clientId2, clientId3].includes(lock.clientId))
.map(lock => lock.clientId);
assert_array_equals(
[clientId3, clientId1, clientId2],
relevant_pending_ids2,
'Querying the state should return requested locks in the order they were '
+ 'requested.');
}, 'Requests appear in state in order made.');
</script>

View file

@ -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');

View file

@ -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 "-"');

View file

@ -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];
};
})();

View file

@ -0,0 +1,37 @@
<!DOCTYPE html>
<title>Helper IFrame</title>
<script>
'use strict';
async function onLoad() {
// Nested Step 5: wpt/web-locks/partitioned-web-locks.tentative.https.html
// Load the innermost child iframe and its content.
const params = new URLSearchParams(self.location.search);
const frame = document.createElement('iframe');
frame.src = params.get('target');
document.body.appendChild(frame);
self.addEventListener('message', evt => {
// Nested Step 6: wpt/web-locks/partitioned-web-locks.tentative.https.html
// Pass any operations request messages to the
// innermost child iframe.
if (evt.data.op){
// Ensure that the iframe has loaded before passing
// on the message.
frame.addEventListener('load', function(){
frame.contentWindow.postMessage(evt.data, '*');
});
}
// Nested Step 8: wpt/web-locks/partitioned-web-locks.tentative.https.html
else {
// All other messages, should be sent back to the
// top-level site.
if (self.opener)
self.opener.postMessage(evt.data, '*');
else
self.top.postMessage(evt.data, '*');
}
});
}
self.addEventListener('load', onLoad);
</script>

View file

@ -0,0 +1,52 @@
<!DOCTYPE html>
<title>Helper IFrame</title>
<script>
'use strict';
// Map of lock_id => function that releases a lock.
const held = new Map();
let next_lock_id = 1;
self.addEventListener('message', e => {
function respond(data) {
parent.postMessage(Object.assign(data, {rqid: e.data.rqid}), '*');
}
switch (e.data.op) {
case 'request':
navigator.locks.request(
e.data.name, {
mode: e.data.mode || 'exclusive',
ifAvailable: e.data.ifAvailable || false
}, 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
});
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;
case 'client_id':
navigator.locks.request(e.data.name, async lock => {
const lock_state = await navigator.locks.query();
const held_lock =
lock_state.held.filter(l => l.name === lock.name)[0];
respond({ack: 'client_id', client_id: held_lock.clientId});
});
break;
}
});
</script>

View file

@ -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);
});

View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<meta charset="utf-8"/>
<meta name="referrer" content="origin">
<script>
async function onLoad() {
// Step 6 and Nested Step 9:
// wpt/web-locks/partitioned-web-locks.tentative.https.html
self.addEventListener('message', evt => {
if (self.opener)
self.opener.postMessage(evt.data, '*');
else
self.top.postMessage(evt.data, '*');
}, { once: true });
// Step 3 and Nested Step 3:
// wpt/web-locks/partitioned-web-locks.tentative.https.html
const params = new URLSearchParams(self.location.search);
const frame = document.createElement('iframe');
frame.src = params.get('target');
document.body.appendChild(frame);
// Step 4 and Nested Step 4:
// wpt/web-locks/partitioned-web-locks.tentative.https.html
frame.addEventListener('load', function(){
frame.contentWindow.postMessage({op: 'request',
name: 'testLock', ifAvailable: true}, '*');
});
}
self.addEventListener('load', onLoad);
</script>

View file

@ -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;
}
});

View file

@ -0,0 +1,35 @@
<!DOCTYPE html>
<meta charset=utf-8>
<title>iframe used in clientId test</title>
<script>
self.onmessage = async event => {
try {
if (event.data === 'get_sw_client_id') {
// Use the controlling service worker to determine
// this client's id according to the Service Worker.
const response = await fetch('/clientId');
const data = await response.json();
window.parent.postMessage(data.clientId, '*');
return;
}
if (event.data === 'get_lock_client_id') {
// Grab a lock, then query the lock manager for state to
// determine this client's id according to the lock manager.
await navigator.locks.request('lock-name', async lock => {
const lock_state = await navigator.locks.query();
const held_lock = lock_state.held.filter(l => l.name === lock.name)[0];
window.parent.postMessage(held_lock.clientId, '*');
});
return;
}
window.parent.postMessage(`unknown request: ${event.data}`, '*');
} catch (ex) {
// In case of test failure, don't leave parent window hanging.
window.parent.postMessage(`${ex.name}: ${ex.message}`, '*');
}
};
</script>

View file

@ -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;
});

View file

@ -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');

View file

@ -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");

View file

@ -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');

View file

@ -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');

View file

@ -0,0 +1,122 @@
<!DOCTYPE html>
<meta charset=utf-8>
<title>Web Locks API: Workers</title>
<link rel=help href="https://w3c.github.io/web-locks/">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="resources/helpers.js"></script>
<script>
'use strict';
promise_test(async t => {
assert_implements(navigator.locks);
const worker = new Worker('resources/worker.js');
t.add_cleanup(() => { worker.terminate(); });
const res = 'shared resource 1';
const lock_id = (await postToWorkerAndWait(
worker, {op: 'request', name: res, mode: 'shared'})).lock_id;
await navigator.locks.request(res, {mode: 'shared'}, async lock => {
await postToWorkerAndWait(worker, {op: 'release', lock_id});
});
}, 'Window and Worker - shared mode');
promise_test(async t => {
assert_implements(navigator.locks);
const worker = new Worker('resources/worker.js');
t.add_cleanup(() => { worker.terminate(); });
const res = 'exclusive resource 1';
// worker acquires the lock.
const lock_id = (await postToWorkerAndWait(
worker, {op: 'request', name: res})).lock_id;
// This request should be blocked.
let lock_granted = false;
const blocked = navigator.locks.request(
res, lock => { lock_granted = true; });
// Verify we can't get it.
let available = undefined;
await navigator.locks.request(
res, {ifAvailable: true}, lock => { available = lock !== null; });
assert_false(available);
assert_false(lock_granted);
// Ask the worker to release it.
await postToWorkerAndWait(worker, {op: 'release', lock_id});
// Now we've got it.
const lock2 = await blocked;
assert_true(lock_granted);
}, 'Window and Worker - exclusive mode');
promise_test(async t => {
assert_implements(navigator.locks);
const worker1 = new Worker('resources/worker.js');
const worker2 = new Worker('resources/worker.js');
t.add_cleanup(() => { worker1.terminate(); worker2.terminate(); });
const res = 'exclusive resource 2';
// worker1 acquires the lock.
const lock_id = (await postToWorkerAndWait(
worker1, {op: 'request', name: res})).lock_id;
// This request should be blocked.
let lock_granted = false;
const blocked = postToWorkerAndWait(
worker2, {op: 'request', name: res});
blocked.then(f => { lock_granted = true; });
// Verify worker2 can't get it.
assert_true((await postToWorkerAndWait(worker2, {
op: 'request', name: res, ifAvailable: true
})).failed, 'Lock request should have failed');
assert_false(lock_granted);
// Ask worker1 to release it.
await postToWorkerAndWait(worker1, {op: 'release', lock_id});
// Now worker2 can get it.
const lock = await blocked;
assert_true(lock_granted);
}, 'Worker and Worker - exclusive mode');
promise_test(async t => {
assert_implements(navigator.locks);
const worker = new Worker('resources/worker.js');
const res = 'exclusive resource 3';
// Worker acquires the lock.
await postToWorkerAndWait(worker, {op: 'request', name: res});
// This request should be blocked.
let lock_granted = false;
const blocked = navigator.locks.request(
res, lock => { lock_granted = true; });
// Verify we can't get it.
let available = undefined;
await navigator.locks.request(
res, {ifAvailable: true}, lock => { available = lock !== null; });
assert_false(available);
assert_false(lock_granted);
// Implicitly release it by terminating the worker.
worker.terminate();
// Now we've got it.
const lock = await blocked;
assert_true(lock_granted);
}, 'Terminated Worker - exclusive mode');
</script>

View file

@ -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',

View file

@ -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();
});
});

View file

@ -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');
});
});
});

View file

@ -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)

View file

@ -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"
}
}

View file

@ -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();

View file

@ -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 = /(?:\[])+$/;

View file

@ -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;

31
typings/internalBinding/locks.d.ts vendored Normal file
View file

@ -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> | 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<any>;
query(): Promise<InternalLocksBinding.LockManagerSnapshot>;
}