Skip to content

src: add web locks api #58666

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open

src: add web locks api #58666

wants to merge 14 commits into from

Conversation

IlyasShabi
Copy link
Contributor

@IlyasShabi IlyasShabi commented Jun 10, 2025

This PR implements the Web Locks API, Locks are used to coordinate access to shared resources across multiple threads.

This implementation is based on previous work in #22719 and #36502, but takes a C++ native approach for better performance and reliability, this solution uses a singleton LockManager with thread-safe data structures to coordinate locks across all workers.

  • Support exclusive and shared modes
  • Support steal option
  • Support ifAvailable option
  • Support signal option
  • Documentation
  • WPT tests
  • Add missing query.https.any.js tests as unit tests
  • Add basic tests

Closes: #22702
Refs: https://w3c.github.io/web-locks

@nodejs-github-bot
Copy link
Collaborator

Review requested:

  • @nodejs/gyp
  • @nodejs/startup
  • @nodejs/web-standards

@nodejs-github-bot nodejs-github-bot added lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. labels Jun 10, 2025
@IlyasShabi IlyasShabi changed the title Add web locks api src: add web locks api Jun 10, 2025
@IlyasShabi IlyasShabi marked this pull request as ready for review June 10, 2025 19:47
Copy link

codecov bot commented Jun 10, 2025

Codecov Report

Attention: Patch coverage is 79.88636% with 177 lines in your changes missing coverage. Please review.

Project coverage is 90.01%. Comparing base (09b4c57) to head (66089c5).

Files with missing lines Patch % Lines
src/node_locks.cc 70.20% 95 Missing and 68 partials ⚠️
lib/internal/locks.js 95.22% 14 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #58666      +/-   ##
==========================================
- Coverage   90.07%   90.01%   -0.07%     
==========================================
  Files         641      644       +3     
  Lines      188998   189878     +880     
  Branches    37069    37245     +176     
==========================================
+ Hits       170246   170917     +671     
- Misses      11462    11599     +137     
- Partials     7290     7362      +72     
Files with missing lines Coverage Δ
lib/internal/navigator.js 99.37% <100.00%> (+0.04%) ⬆️
lib/worker_threads.js 100.00% <100.00%> (ø)
src/node_binding.cc 82.67% <ø> (ø)
src/node_external_reference.h 100.00% <ø> (ø)
src/node_locks.h 100.00% <100.00%> (ø)
lib/internal/locks.js 95.22% <95.22%> (ø)
src/node_locks.cc 70.20% <70.20%> (ø)

... and 35 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jasnell jasnell requested a review from addaleax June 10, 2025 21:00
@jasnell jasnell added the semver-minor PRs that contain new features and should be released in the next minor version. label Jun 10, 2025
Copy link
Member

@mcollina mcollina left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

Copy link
Contributor

@Ethan-Arrowood Ethan-Arrowood left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this lgtm. I looked through the WPT and other tests and they look all good. I reviewed the JS api to make sure it aligns with expectations and analyzed its source. All is good for a first implementation. I did a cursory review of the C++ part as I'm less experienced there, but in general it looks okay too. Nice work!

@panva panva added semver-major PRs that contain breaking changes and should be released in the next major version. and removed semver-minor PRs that contain new features and should be released in the next minor version. labels Jun 16, 2025
@panva
Copy link
Member

panva commented Jun 16, 2025

Adding semver-major PRs that contain breaking changes and should be released in the next major version. as this adds new globals (Lock, LockManager)

@panva panva added the request-ci Add this label to start a Jenkins CI on a PR. label Jun 16, 2025
@panva panva added web-standards Issues and PRs related to Web APIs and removed request-ci Add this label to start a Jenkins CI on a PR. labels Jun 16, 2025
@IlyasShabi IlyasShabi requested a review from panva June 17, 2025 09:53
@panva panva added the request-ci Add this label to start a Jenkins CI on a PR. label Jun 17, 2025
@github-actions github-actions bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Jun 17, 2025
@anonrig anonrig added the request-ci Add this label to start a Jenkins CI on a PR. label Jun 26, 2025
Copy link
Contributor

The notable-change PRs with changes that should be highlighted in changelogs. label has been added by @anonrig.

Please suggest a text for the release notes if you'd like to include a more detailed summary, then proceed to update the PR description with the text or a link to the notable change suggested text comment. Otherwise, the commit will be placed in the Other Notable Changes section.

@github-actions github-actions bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Jun 26, 2025
@nodejs-github-bot
Copy link
Collaborator

@marco-ippolito marco-ippolito added the request-ci Add this label to start a Jenkins CI on a PR. label Jun 26, 2025
@github-actions github-actions bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Jun 26, 2025
@nodejs-github-bot
Copy link
Collaborator

Copy link
Member

@jasnell jasnell left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just putting the red x on this since CI passed and it has sufficient sign off from others to land but there are still a few outstanding issues to resolve. I want to make sure it doesn't end up getting landed by the commit-queue while some comments are still pending.

@IlyasShabi IlyasShabi requested a review from jasnell June 30, 2025 08:00
@nodejs-github-bot
Copy link
Collaborator

// Called when the promise returned from the user's callback rejects
static void OnIfAvailableReject(const FunctionCallbackInfo<Value>& info) {
HandleScope handle_scope(info.GetIsolate());
auto* holder = static_cast<Global<Promise::Resolver>*>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These's aren't correct here. You really should not be holding a v8::Global<T> inside a v8::External, then deleting it like this. It's fine to use indirection through another type... e.g. having the External hold an instance of a struct that is holding the Global<T>.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did experiment with a wrapper that let the callback own a
std::unique_ptr, so the Global<…> would be reset automatically, but that changed the timing of the reset and introduced a race that broke the WPT and hanging tests sometimes.

I think that the current pattern is also valid since the Global is reset and freed immediately in the callback and the raw pointer’s ownership is transferred with release(). Wdyt ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ought to be able to take another look this afternoon. Just catching up after two week of being out of the office ;-) ... just mentioning because I didn't want you to feel I was ignoring your pings on this ;-)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Take your time, thanks for your help

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jasnell Did you manage to take another look on this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this pattern is indeed very odd. Here's a diff that simplifies things a lot without appearing to break anything:

@@ -132,24 +130,15 @@ static void OnLockCallbackRejected(const FunctionCallbackInfo<Value>& info) {
 // Called when the promise returned from the user's callback resolves
 static void OnIfAvailableFulfill(const FunctionCallbackInfo<Value>& info) {
   HandleScope handle_scope(info.GetIsolate());
-  auto* holder = static_cast<Global<Promise::Resolver>*>(
-      info.Data().As<External>()->Value());
-  USE(holder->Get(info.GetIsolate())
+  USE(info.Data().As<Promise::Resolver>()
           ->Resolve(info.GetIsolate()->GetCurrentContext(), info[0]));
-  holder->Reset();
-  delete holder;
 }
 
 // Called when the promise returned from the user's callback rejects
 static void OnIfAvailableReject(const FunctionCallbackInfo<Value>& info) {
   HandleScope handle_scope(info.GetIsolate());
-  auto* holder = static_cast<Global<Promise::Resolver>*>(
-      info.Data().As<External>()->Value());
-  USE(holder->Get(info.GetIsolate())
+  USE(info.Data().As<Promise::Resolver>()
           ->Reject(info.GetIsolate()->GetCurrentContext(), info[0]));
-  holder->Reset();
-
-  delete holder;
 }
 
 void LockManager::CleanupStolenLocks(Environment* env) {
@@ -326,28 +315,17 @@ void LockManager::ProcessQueue(Environment* env) {
       if (callback_result->IsPromise()) {
         Local<Promise> p = callback_result.As<Promise>();
 
-        // The resolver survives until the promise settles and is freed
-        // automatically.
-        auto resolve_holder = std::make_unique<Global<Promise::Resolver>>(
-            isolate, if_available_request->released_promise());
-        auto reject_holder = std::make_unique<Global<Promise::Resolver>>(
-            isolate, if_available_request->released_promise());
-
         Local<Function> on_fulfilled;
         Local<Function> on_rejected;
         CHECK(Function::New(context,
                             OnIfAvailableFulfill,
-                            External::New(isolate, resolve_holder.get()))
+                            if_available_request->released_promise())
                   .ToLocal(&on_fulfilled));
         CHECK(Function::New(context,
                             OnIfAvailableReject,
-                            External::New(isolate, reject_holder.get()))
+                            if_available_request->released_promise())
                   .ToLocal(&on_rejected));
 
-        // Transfer ownership to the callbacks.
-        resolve_holder.release();
-        reject_holder.release();
-
         {
           TryCatchScope try_catch_scope(env);
           if (p->Then(context, on_fulfilled, on_rejected).IsEmpty()) {

Generally speaking, we:

  • Strongly want to avoid using External, because it is an "unsafe" feature of the V8 API in the sense that it associates a C++ object with a JS object without leaving V8's Garbage Collector, C++ memory management or diagnostic tooling any insight into what's happening (and so an External can easily end up accidentally containing a dangling pointer, or alternatively the C++ object not freed properly when the External is garbage-collected).
  • Avoid using Globals, because those do not properly represent references-relationships between objects (disclaimer: I don't know how the current work on C++ garbage collector integration changes this). If a Global pointing at a JS object B is contained in a C++ object that is attached to some JS object A, the garbage collector cannot see this reference, and it treats the Global as a separate GC root (or a weak reference), neither of which correctly represents the typical intended reference mechanism.

In this case, we should be able to just associate JS data directly with the JS function object in question. If this isn't possible, it's generally advisable to create a BaseObject with internal fields (in the V8 sense of the word) instead, which solves these issues.

I think the latter (inherit from BaseObject) is what we'd also want to do for LockHolder, instead of creating Externals containing pointers to LockHolder instances.

@jasnell
Copy link
Member

jasnell commented Jun 30, 2025

I still think there may be at least a few issues here relating to how you're using v8::Global and v8::External. I'd really like to see if we could get @addaleax to take a look.

@IlyasShabi IlyasShabi requested a review from jasnell July 7, 2025 07:33
Copy link
Member

@addaleax addaleax left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank for the pings, in particular @BridgeAR for making me aware of this PR over Slack, where messages generally reach me a bit better 🙂

This PR looks great and I am excited to see this feature finally come to Node.js, I think @jasnell had some good comments on the memory management aspects here that I think should be addressed before this PR is merged

Comment on lines +108 to +111
auto* lock_holder =
static_cast<LockHolder*>(info.Data().As<External>()->Value());
std::shared_ptr<Lock> lock = lock_holder->lock();
delete lock_holder;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fwiw, this should be the change that @jasnell suggested in the resolved comment, still feels a bit cleaner here to me but obviously not a big deal either way

Suggested change
auto* lock_holder =
static_cast<LockHolder*>(info.Data().As<External>()->Value());
std::shared_ptr<Lock> lock = lock_holder->lock();
delete lock_holder;
std::unique_ptr<LockHolder> lock_holder {
static_cast<LockHolder*>(info.Data().As<External>()->Value()) };
std::shared_ptr<Lock> lock = lock_holder->lock();

(similar pattern would apply below)

// Called when the promise returned from the user's callback rejects
static void OnIfAvailableReject(const FunctionCallbackInfo<Value>& info) {
HandleScope handle_scope(info.GetIsolate());
auto* holder = static_cast<Global<Promise::Resolver>*>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this pattern is indeed very odd. Here's a diff that simplifies things a lot without appearing to break anything:

@@ -132,24 +130,15 @@ static void OnLockCallbackRejected(const FunctionCallbackInfo<Value>& info) {
 // Called when the promise returned from the user's callback resolves
 static void OnIfAvailableFulfill(const FunctionCallbackInfo<Value>& info) {
   HandleScope handle_scope(info.GetIsolate());
-  auto* holder = static_cast<Global<Promise::Resolver>*>(
-      info.Data().As<External>()->Value());
-  USE(holder->Get(info.GetIsolate())
+  USE(info.Data().As<Promise::Resolver>()
           ->Resolve(info.GetIsolate()->GetCurrentContext(), info[0]));
-  holder->Reset();
-  delete holder;
 }
 
 // Called when the promise returned from the user's callback rejects
 static void OnIfAvailableReject(const FunctionCallbackInfo<Value>& info) {
   HandleScope handle_scope(info.GetIsolate());
-  auto* holder = static_cast<Global<Promise::Resolver>*>(
-      info.Data().As<External>()->Value());
-  USE(holder->Get(info.GetIsolate())
+  USE(info.Data().As<Promise::Resolver>()
           ->Reject(info.GetIsolate()->GetCurrentContext(), info[0]));
-  holder->Reset();
-
-  delete holder;
 }
 
 void LockManager::CleanupStolenLocks(Environment* env) {
@@ -326,28 +315,17 @@ void LockManager::ProcessQueue(Environment* env) {
       if (callback_result->IsPromise()) {
         Local<Promise> p = callback_result.As<Promise>();
 
-        // The resolver survives until the promise settles and is freed
-        // automatically.
-        auto resolve_holder = std::make_unique<Global<Promise::Resolver>>(
-            isolate, if_available_request->released_promise());
-        auto reject_holder = std::make_unique<Global<Promise::Resolver>>(
-            isolate, if_available_request->released_promise());
-
         Local<Function> on_fulfilled;
         Local<Function> on_rejected;
         CHECK(Function::New(context,
                             OnIfAvailableFulfill,
-                            External::New(isolate, resolve_holder.get()))
+                            if_available_request->released_promise())
                   .ToLocal(&on_fulfilled));
         CHECK(Function::New(context,
                             OnIfAvailableReject,
-                            External::New(isolate, reject_holder.get()))
+                            if_available_request->released_promise())
                   .ToLocal(&on_rejected));
 
-        // Transfer ownership to the callbacks.
-        resolve_holder.release();
-        reject_holder.release();
-
         {
           TryCatchScope try_catch_scope(env);
           if (p->Then(context, on_fulfilled, on_rejected).IsEmpty()) {

Generally speaking, we:

  • Strongly want to avoid using External, because it is an "unsafe" feature of the V8 API in the sense that it associates a C++ object with a JS object without leaving V8's Garbage Collector, C++ memory management or diagnostic tooling any insight into what's happening (and so an External can easily end up accidentally containing a dangling pointer, or alternatively the C++ object not freed properly when the External is garbage-collected).
  • Avoid using Globals, because those do not properly represent references-relationships between objects (disclaimer: I don't know how the current work on C++ garbage collector integration changes this). If a Global pointing at a JS object B is contained in a C++ object that is attached to some JS object A, the garbage collector cannot see this reference, and it treats the Global as a separate GC root (or a weak reference), neither of which correctly represents the typical intended reference mechanism.

In this case, we should be able to just associate JS data directly with the JS function object in question. If this isn't possible, it's generally advisable to create a BaseObject with internal fields (in the V8 sense of the word) instead, which solves these issues.

I think the latter (inherit from BaseObject) is what we'd also want to do for LockHolder, instead of creating Externals containing pointers to LockHolder instances.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
commit-queue-squash Add this label to instruct the Commit Queue to squash all the PR commits into the first one. lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. notable-change PRs with changes that should be highlighted in changelogs. semver-minor PRs that contain new features and should be released in the next minor version. web-standards Issues and PRs related to Web APIs
Projects
None yet
Development

Successfully merging this pull request may close these issues.