From 8ce0c9b00aad2b7abca6764da7ad7785e7e48c03 Mon Sep 17 00:00:00 2001 From: ishabi Date: Thu, 5 Mar 2026 22:42:29 +0100 Subject: [PATCH] diagnostics_channel: add diagnostics channels for web locks --- doc/api/diagnostics_channel.md | 46 +++++ lib/internal/locks.js | 60 ++++++- .../test-diagnostics-channel-web-locks.js | 167 ++++++++++++++++++ 3 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 test/parallel/test-diagnostics-channel-web-locks.js diff --git a/doc/api/diagnostics_channel.md b/doc/api/diagnostics_channel.md index e9ac279cc62917..0189ca8b5e77e3 100644 --- a/doc/api/diagnostics_channel.md +++ b/doc/api/diagnostics_channel.md @@ -1425,6 +1425,50 @@ Emitted when a new process is created. Emitted when [`process.execve()`][] is invoked. +#### Web Locks + +> Stability: 1 - Experimental + + + +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. +The 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 @@ -1453,7 +1497,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 diff --git a/lib/internal/locks.js b/lib/internal/locks.js index 054197bcaefcc6..05000e933f0b55 100644 --- a/lib/internal/locks.js +++ b/lib/internal/locks.js @@ -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; } } diff --git a/test/parallel/test-diagnostics-channel-web-locks.js b/test/parallel/test-diagnostics-channel-web-locks.js new file mode 100644 index 00000000000000..dec0daef9868a0 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-web-locks.js @@ -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(); + } + }); +});