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 9 commits into
base: main
Choose a base branch
from
Open

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 85.87786% with 111 lines in your changes missing coverage. Please review.

Project coverage is 90.08%. Comparing base (462c741) to head (9f2f57a).
Report is 30 commits behind head on main.

Files with missing lines Patch % Lines
src/node_locks.cc 78.58% 52 Missing and 45 partials ⚠️
lib/internal/locks.js 95.22% 14 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #58666      +/-   ##
==========================================
- Coverage   90.15%   90.08%   -0.08%     
==========================================
  Files         639      643       +4     
  Lines      188201   189048     +847     
  Branches    36915    37062     +147     
==========================================
+ Hits       169675   170302     +627     
- Misses      11274    11419     +145     
- Partials     7252     7327      +75     
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 78.58% <78.58%> (ø)

... and 65 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
@BridgeAR BridgeAR added the request-ci Add this label to start a Jenkins CI on a PR. label Jun 23, 2025
@github-actions github-actions bot added request-ci-failed An error occurred while starting CI via request-ci label, and manual interventon is needed. and removed request-ci Add this label to start a Jenkins CI on a PR. labels Jun 23, 2025
Copy link
Contributor

Failed to start CI
   ⚠  Commits were pushed since the last approving review:
   ⚠  - worker: add web locks api
   ⚠  - use WebIDL convertors
   ⚠  - add lock and lockmanager to globals doc
   ⚠  - remove lock and lockmanager from globals
   ⚠  - attemp to atach catch on c++ by defering to next microtask
   ⚠  - separate promise fulfillment and rejection handlers
   ⚠  - add cjs/esm code in locks doc
   ⚠  - add more tests and doc
   ✘  Refusing to run CI on potentially unsafe PR
https://github.com/nodejs/node/actions/runs/15825543437

@IlyasShabi IlyasShabi requested a review from jasnell June 23, 2025 13:29
@marco-ippolito marco-ippolito added request-ci Add this label to start a Jenkins CI on a PR. and removed request-ci-failed An error occurred while starting CI via request-ci label, and manual interventon is needed. labels Jun 23, 2025
@github-actions github-actions bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Jun 23, 2025
@nodejs-github-bot
Copy link
Collaborator

@jasnell
Copy link
Member

jasnell commented Jun 24, 2025

There are relevant test failures in CI (web locks web platform tests)

External::New(isolate, reject_holder))
.ToLocal(&on_rejected_callback)) {
delete fulfill_holder;
delete reject_holder;
Copy link
Member

@jasnell jasnell Jun 24, 2025

Choose a reason for hiding this comment

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

These might need to be handled separately. If the first call succeeds, then the first created function will take ownership over the fulfill_holder. If the second call then fails for whatever reason, we are deleting the fulfill_holder while it's external is still holding the reference to it that it assumes it owns. It would be best to separate this into two separate calls rather than aggregating them together like this. Create one, create it's external and it's function, then create the second...

Or, can we at least be certain that we won't end up with a free-after-free type error when deleting these while the External is still holding them?

Local<Value> rejection_value = promise->Result();
grantable_request->waiting_promise()
->Reject(context, rejection_value)
.Check();
Copy link
Member

Choose a reason for hiding this comment

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

Not something to do here, but using Check() here has the same issue as using ToLocalChecked() in that it will just crash the process rather than propagate the error. This is a common issue throughout the code, however, so not something I would block this PR on. We need to handle these better in general.

If you did want to handle this here, then changing these to check if the return value is empty then doing some proper error propagation similar to the way the ToLocal(...) results are handled would be ideal.

grantable_request->waiting_promise()
->Resolve(context, callback_result)
.Check();
USE(promise->Then(
Copy link
Member

Choose a reason for hiding this comment

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

We should avoid using USE for the same error propagation reasons. Calling Then(...) can cause a JavaScript error to be scheduled. USE would cause that to be ignored when we ought to propagate it.

->Resolve(context, callback_result)
.Check();
Local<Value> promise_args[] = {callback_result};
USE(on_fulfilled_callback->Call(
Copy link
Member

Choose a reason for hiding this comment

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

Likewise here. Calling the callback could throw. The error should be appropriately propagated.

Comment on lines +430 to +435
CHECK(args[0]->IsString());
CHECK(args[1]->IsString());
CHECK(args[2]->IsString());
CHECK(args[3]->IsBoolean());
CHECK(args[4]->IsBoolean());
CHECK(args[5]->IsFunction());
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
CHECK(args[0]->IsString());
CHECK(args[1]->IsString());
CHECK(args[2]->IsString());
CHECK(args[3]->IsBoolean());
CHECK(args[4]->IsBoolean());
CHECK(args[5]->IsFunction());
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

Just to help connect these back to the documentation comment at the top

Comment on lines +453 to +460
Local<Promise::Resolver> waiting_promise;
if (!Promise::Resolver::New(context).ToLocal(&waiting_promise)) {
return;
}
Local<Promise::Resolver> released_promise;
if (!Promise::Resolver::New(context).ToLocal(&released_promise)) {
return;
}
Copy link
Member

@jasnell jasnell Jun 24, 2025

Choose a reason for hiding this comment

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

Suggested change
Local<Promise::Resolver> waiting_promise;
if (!Promise::Resolver::New(context).ToLocal(&waiting_promise)) {
return;
}
Local<Promise::Resolver> released_promise;
if (!Promise::Resolver::New(context).ToLocal(&released_promise)) {
return;
}
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;
}

return;
}

args.GetReturnValue().Set(released_promise->GetPromise());
Copy link
Member

Choose a reason for hiding this comment

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

It's a bit unusual (by convention) to set the return value and then perform additional actions. It makes it rather easy to miss that the return value is set at all. Could this be moved to the end of the function?

if (!Promise::Resolver::New(context).ToLocal(&resolver)) {
return;
}
args.GetReturnValue().Set(resolver->GetPromise());
Copy link
Member

Choose a reason for hiding this comment

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

Likewise here... setting the return value in the middle here is a bit unusual.

for (const auto& resource_entry : manager->held_locks_) {
for (const auto& held_lock : resource_entry.second) {
if (held_lock->env() == env) {
Local<Object> lock_info =
Copy link
Member

Choose a reason for hiding this comment

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

You might consider moving the declaration for the Local<Object> lock_info to outside of the for loop and just reset it here so that we're reusing the same declaration on each iteration rather than creating a new one.

index = 0;
for (const auto& pending_request : manager->pending_queue_) {
if (pending_request->env() == env) {
Local<Object> lock_info =
Copy link
Member

Choose a reason for hiding this comment

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

Same here... if the Local<Object> lock_info; is moved outside of the for loops then it can just be reused here.

result->Set(context, FIXED_ONE_BYTE_STRING(isolate, "held"), held_list)
.Check();
result->Set(context, FIXED_ONE_BYTE_STRING(isolate, "pending"), pending_list)
.Check();
Copy link
Member

Choose a reason for hiding this comment

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

Let's avoid using Check() here and propagate the errors appropriately.

Local<Object> obj = Object::New(isolate);

Local<String> name_string;
if (!String::NewFromTwoByte(isolate,
Copy link
Member

Choose a reason for hiding this comment

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

TODO for later (no need to do it in this PR)... we should add a ToV8Value variant that accepts the std::u16string

return Local<Object>();
}
obj->Set(context, FIXED_ONE_BYTE_STRING(isolate, "name"), name_string)
.Check();
Copy link
Member

@jasnell jasnell Jun 24, 2025

Choose a reason for hiding this comment

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

Avoid Check here... and elsewhere in this function. Specifically, if you can modify this function to return a MaybeLocal<Object> instead, then if these return IsNothing() == true, you should just return an empty MaybeLocal<Object> to propagate the error.

LockManager::GetCurrent()->CleanupEnvironment(env);
}

static Local<Object> CreateLockInfoObject(Isolate* isolate,
Copy link
Member

Choose a reason for hiding this comment

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

Consider having this return a MaybeLocal<Object> to better facilitate error propagation

if (String::NewFromUtf8(isolate, kSharedMode).ToLocal(&shared_mode)) {
target->Set(FIXED_ONE_BYTE_STRING(isolate, "LOCK_MODE_SHARED"),
shared_mode);
}
Copy link
Member

Choose a reason for hiding this comment

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

Would NODE_DEFINE_STRING_CONSTANT work for these here instead?

@jasnell
Copy link
Member

jasnell commented Jun 24, 2025

I think this is very close! Added a few comments and notice that there are WPT test failures.

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. 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.