Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions doc/api/diagnostics_channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -1425,6 +1425,50 @@ Emitted when a new process is created.

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.
The callback is invoked with `null` instead of a `Lock` object.
Copy link
Member

Choose a reason for hiding this comment

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

This channel callback has no parameter of Lock. What is this callback referring to?


##### 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
Expand Down Expand Up @@ -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
60 changes: 57 additions & 3 deletions lib/internal/locks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -192,6 +221,7 @@ class LockManager {
}

const clientId = `node-${process.pid}-${threadId}`;
publishLockRequestStart(name, mode);

// Handle requests with AbortSignal
if (signal) {
Expand All @@ -212,6 +242,8 @@ class LockManager {
return undefined;
}
lockGranted = true;
publishLockRequestGrant(name, mode);

return callback(createLock(lock));
});
};
Expand All @@ -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;
}
}
Expand Down
167 changes: 167 additions & 0 deletions test/parallel/test-diagnostics-channel-web-locks.js
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
Loading