Repository: nodejs/node
Pull request: diagnostics_channel: add diagnostics channels for web locks

This PR adds diagnostics channels support for `locks.request()` giving visibility into the lock request lifecycle. 

Four channels are emitted:

  - `locks.request.start` when a request begins
  - `locks.request.grant` when the lock is granted
  - `locks.request.miss` when the lock is not immediately available
  - `locks.request.end` when the request finishes

Changed files:
filename: doc/api/diagnostics_channel.md
patch: @@ -1447,6 +1447,50 @@ Emitted when [`child_process.spawn()`][] encounters an error.
 
 Emitted when [`process.execve()`][] is invoked.
 
+#### Web Locks
+
+> Stability: 1 - Experimental
+
+<!-- YAML
+added: REPLACEME
+-->
+
+These channels are emitted for each [`locks.request()`][] call. See
+[`worker_threads.locks`][] for details on Web Locks.
+
+##### Event: `'locks.request.start'`
+
+* `name` {string} The name of the requested lock resource.
+* `mode` {string} The lock mode: `'exclusive'` or `'shared'`.
+
+Emitted when a lock request is initiated, before the lock is granted.
+
+##### Event: `'locks.request.grant'`
+
+* `name` {string} The name of the requested lock resource.
+* `mode` {string} The lock mode: `'exclusive'` or `'shared'`.
+
+Emitted when a lock is successfully granted and the callback is about to run.
+
+##### Event: `'locks.request.miss'`
+
+* `name` {string} The name of the requested lock resource.
+* `mode` {string} The lock mode: `'exclusive'` or `'shared'`.
+
+Emitted when `ifAvailable` is `true` and the lock is not immediately available,
+and the request callback is invoked with `null` instead of a `Lock` object.
+
+##### Event: `'locks.request.end'`
+
+* `name` {string} The name of the requested lock resource.
+* `mode` {string} The lock mode: `'exclusive'` or `'shared'`.
+* `steal` {boolean} Whether the request uses steal semantics.
+* `ifAvailable` {boolean} Whether the request uses ifAvailable semantics.
+* `error` {Error|undefined} The error thrown by the callback, if any.
+
+Emitted when a lock request has finished, whether the callback succeeded,
+threw an error, or the lock was stolen.
+
 #### Worker Thread
 
 > Stability: 1 - Experimental
@@ -1476,7 +1520,9 @@ Emitted when a new thread is created.
 [`diagnostics_channel.tracingChannel()`]: #diagnostics_channeltracingchannelnameorchannels
 [`end` event]: #endevent
 [`error` event]: #errorevent
+[`locks.request()`]: worker_threads.md#locksrequestname-options-callback
 [`net.Server.listen()`]: net.md#serverlisten
 [`process.execve()`]: process.md#processexecvefile-args-env
 [`start` event]: #startevent
+[`worker_threads.locks`]: worker_threads.md#worker_threadslocks
 [context loss]: async_context.md#troubleshooting-context-loss

filename: lib/internal/locks.js
patch: @@ -29,8 +29,13 @@ const {
   createEnumConverter,
   createDictionaryConverter,
 } = require('internal/webidl');
+const dc = require('diagnostics_channel');
 
 const locks = internalBinding('locks');
+const lockRequestStartChannel = dc.channel('locks.request.start');
+const lockRequestGrantChannel = dc.channel('locks.request.grant');
+const lockRequestMissChannel = dc.channel('locks.request.miss');
+const lockRequestEndChannel = dc.channel('locks.request.end');
 
 const kName = Symbol('kName');
 const kMode = Symbol('kMode');
@@ -113,6 +118,30 @@ function convertLockError(error) {
   return error;
 }
 
+function publishLockRequestStart(name, mode) {
+  if (lockRequestStartChannel.hasSubscribers) {
+    lockRequestStartChannel.publish({ name, mode });
+  }
+}
+
+function publishLockRequestGrant(name, mode) {
+  if (lockRequestGrantChannel.hasSubscribers) {
+    lockRequestGrantChannel.publish({ name, mode });
+  }
+}
+
+function publishLockRequestMiss(name, mode, ifAvailable) {
+  if (ifAvailable && lockRequestMissChannel.hasSubscribers) {
+    lockRequestMissChannel.publish({ name, mode });
+  }
+}
+
+function publishLockRequestEnd(name, mode, ifAvailable, steal, error) {
+  if (lockRequestEndChannel.hasSubscribers) {
+    lockRequestEndChannel.publish({ name, mode, ifAvailable, steal, error });
+  }
+}
+
 // https://w3c.github.io/web-locks/#api-lock-manager
 class LockManager {
   constructor(symbol = undefined) {
@@ -192,6 +221,7 @@ class LockManager {
     }
 
     const clientId = `node-${process.pid}-${threadId}`;
+    publishLockRequestStart(name, mode);
 
     // Handle requests with AbortSignal
     if (signal) {
@@ -212,6 +242,8 @@ class LockManager {
               return undefined;
             }
             lockGranted = true;
+            publishLockRequestGrant(name, mode);
+
             return callback(createLock(lock));
           });
         };
@@ -228,27 +260,49 @@ class LockManager {
 
           // When released promise settles, clean up listener and resolve main promise
           SafePromisePrototypeFinally(
-            PromisePrototypeThen(released, resolve, (error) => reject(convertLockError(error))),
+            PromisePrototypeThen(
+              released,
+              (result) => {
+                publishLockRequestEnd(name, mode, ifAvailable, steal, undefined);
+                resolve(result);
+              },
+              (error) => {
+                const convertedError = convertLockError(error);
+                publishLockRequestEnd(name, mode, ifAvailable, steal, convertedError);
+                reject(convertedError);
+              },
+            ),
             () => signal.removeEventListener('abort', abortListener),
           );
         } catch (error) {
           signal.removeEventListener('abort', abortListener);
-          reject(convertLockError(error));
+          const convertedError = convertLockError(error);
+          publishLockRequestEnd(name, mode, ifAvailable, steal, convertedError);
+          reject(convertedError);
         }
       });
     }
 
     // When ifAvailable: true and lock is not available, C++ passes null to indicate no lock granted
     const wrapCallback = (internalLock) => {
+      if (internalLock === null) {
+        publishLockRequestMiss(name, mode, ifAvailable);
+      } else {
+        publishLockRequestGrant(name, mode);
+      }
       const lock = createLock(internalLock);
       return callback(lock);
     };
 
     // Standard request without signal
     try {
-      return await locks.request(name, clientId, mode, steal, ifAvailable, wrapCallback);
+      const result = await locks.request(name, clientId, mode, steal, ifAvailable, wrapCallback);
+      publishLockRequestEnd(name, mode, ifAvailable, steal, undefined);
+
+      return result;
     } catch (error) {
       const convertedError = convertLockError(error);
+      publishLockRequestEnd(name, mode, ifAvailable, steal, convertedError);
       throw convertedError;
     }
   }

filename: test/parallel/test-diagnostics-channel-web-locks.js
patch: @@ -0,0 +1,167 @@
+'use strict';
+
+const common = require('../common');
+const { describe, it } = require('node:test');
+const assert = require('node:assert');
+const dc = require('node:diagnostics_channel');
+
+function subscribe({ start, grant, miss, end }) {
+  if (start) dc.subscribe('locks.request.start', start);
+  if (grant) dc.subscribe('locks.request.grant', grant);
+  if (miss) dc.subscribe('locks.request.miss', miss);
+  if (end) dc.subscribe('locks.request.end', end);
+
+  return () => {
+    if (start) dc.unsubscribe('locks.request.start', start);
+    if (grant) dc.unsubscribe('locks.request.grant', grant);
+    if (miss) dc.unsubscribe('locks.request.miss', miss);
+    if (end) dc.unsubscribe('locks.request.end', end);
+  };
+}
+
+describe('Web Locks diagnostics channel', () => {
+  it('emits start, grant, and end on success', async () => {
+    let startEvent;
+    const unsubscribe = subscribe({
+      start: common.mustCall((e) => startEvent = e),
+      grant: common.mustCall(),
+      miss: common.mustNotCall(),
+      end: common.mustCall(),
+    });
+
+    try {
+      const result = await navigator.locks.request('normal-lock', async () => 'done');
+      assert.strictEqual(result, 'done');
+      assert.strictEqual(startEvent.name, 'normal-lock');
+      assert.strictEqual(startEvent.mode, 'exclusive');
+    } finally {
+      unsubscribe();
+    }
+  });
+
+  it('emits start, miss, and end when lock is unavailable', async () => {
+    await navigator.locks.request('ifavailable-true-lock', common.mustCall(async () => {
+      let startEvent;
+      const unsubscribe = subscribe({
+        start: common.mustCall((e) => startEvent = e),
+        grant: common.mustNotCall(),
+        miss: common.mustCall(),
+        end: common.mustCall(),
+      });
+
+      try {
+        const result = await navigator.locks.request(
+          'ifavailable-true-lock',
+          { ifAvailable: true },
+          (lock) => lock,
+        );
+
+        assert.strictEqual(result, null);
+        assert.strictEqual(startEvent.name, 'ifavailable-true-lock');
+        assert.strictEqual(startEvent.mode, 'exclusive');
+      } finally {
+        unsubscribe();
+      }
+    }));
+  });
+
+  it('queued lock request emits start, grant, and end without miss', async () => {
+    // Outer fires first, inner is queued — so events arrive in insertion order
+    let outerStartEvent, innerStartEvent;
+    const unsubscribe = subscribe({
+      start: common.mustCall((e) => (outerStartEvent ? innerStartEvent = e : outerStartEvent = e), 2),
+      grant: common.mustCall(2),
+      miss: common.mustNotCall(),
+      end: common.mustCall(2),
+    });
+
+    try {
+      let innerDone;
+
+      const outerResult = await navigator.locks.request('ifavailable-false-lock', common.mustCall(async () => {
+        innerDone = navigator.locks.request(
+          'ifavailable-false-lock',
+          { ifAvailable: false },
+          common.mustCall(async (lock) => {
+            assert.ok(lock);
+            return 'inner-done';
+          }),
+        );
+        await new Promise((resolve) => setTimeout(resolve, 20));
+        return 'outer-done';
+      }));
+
+      assert.strictEqual(outerResult, 'outer-done');
+      assert.strictEqual(await innerDone, 'inner-done');
+
+      assert.strictEqual(outerStartEvent.name, 'ifavailable-false-lock');
+      assert.strictEqual(outerStartEvent.mode, 'exclusive');
+      assert.strictEqual(innerStartEvent.name, 'ifavailable-false-lock');
+      assert.strictEqual(innerStartEvent.mode, 'exclusive');
+    } finally {
+      unsubscribe();
+    }
+  });
+
+  it('reports callback error in end event', async () => {
+    const expectedError = new Error('Callback error');
+    let endEvent;
+    const unsubscribe = subscribe({
+      start: common.mustCall(),
+      grant: common.mustCall(),
+      miss: common.mustNotCall(),
+      end: common.mustCall((e) => endEvent = e),
+    });
+
+    try {
+      await assert.rejects(
+        navigator.locks.request('error-lock', async () => { throw expectedError; }),
+        (error) => error === expectedError,
+      );
+
+      assert.strictEqual(endEvent.name, 'error-lock');
+      assert.strictEqual(endEvent.mode, 'exclusive');
+      assert.strictEqual(endEvent.error, expectedError);
+    } finally {
+      unsubscribe();
+    }
+  });
+
+  it('stolen lock ends original request with AbortError', async () => {
+    let stolenEndEvent, stealerEndEvent;
+    const unsubscribe = subscribe({
+      start: common.mustCall(2),
+      grant: common.mustCall(2),
+      miss: common.mustNotCall(),
+      end: common.mustCall((e) => (e.steal ? stealerEndEvent = e : stolenEndEvent = e), 2),
+    });
+
+    try {
+      let resolveGranted;
+      const granted = new Promise((r) => { resolveGranted = r; });
+
+      const originalRejected = assert.rejects(
+        navigator.locks.request('steal-lock', async () => {
+          resolveGranted();
+          await new Promise((r) => setTimeout(r, 200));
+        }),
+        { name: 'AbortError' },
+      );
+
+      await granted;
+      await navigator.locks.request('steal-lock', { steal: true }, async () => {});
+      await originalRejected;
+
+      assert.strictEqual(stolenEndEvent.name, 'steal-lock');
+      assert.strictEqual(stolenEndEvent.mode, 'exclusive');
+      assert.strictEqual(stolenEndEvent.steal, false);
+      assert.strictEqual(stealerEndEvent.name, 'steal-lock');
+      assert.strictEqual(stealerEndEvent.mode, 'exclusive');
+      assert.strictEqual(stealerEndEvent.steal, true);
+      assert.strictEqual(stolenEndEvent.error.name, 'AbortError');
+      assert.strictEqual(stealerEndEvent.error, undefined);
+    } finally {
+      unsubscribe();
+    }
+  });
+});