Skip to content

Commit f89fc50

Browse files
committed
lib: improve diagnostic message support
Support serializable context.diagnostic messages and optionally listen to diagnostic_channel messages Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode
1 parent 0ba36c5 commit f89fc50

12 files changed

Lines changed: 253 additions & 32 deletions

File tree

doc/api/bench.md

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,9 @@ added: REPLACEME
316316
* `name` {string} The benchmark name. **Default:** The `name` property of `fn`,
317317
or `'<anonymous>'` when `fn` has no name.
318318
* `options` {Object}
319+
* `diagnosticChannels` {Array} String diagnostics channel names, deduplicated
320+
and inherited from containing suites by union. Symbol values in the array
321+
are silently ignored. **Default:** `[]`.
319322
* `only` {boolean} When any benchmark or containing suite has `only` set,
320323
benchmarks without `only` in their hierarchy are skipped. **Default:**
321324
`false`.
@@ -346,6 +349,12 @@ samples, but their samples are discarded. An exception, rejection, timeout,
346349
abort, missing timing call, or duplicate timing call stops the current
347350
benchmark. Later benchmarks continue to run.
348351

352+
For each warmup and measured callback, the runner subscribes to the configured
353+
diagnostics channels. Each publication queues a context diagnostic whose
354+
`message` is `{ name, message }`, containing the string channel name and the
355+
published message. Subscriptions are removed when the callback settles or is
356+
aborted.
357+
349358
A timeout or abort cannot interrupt synchronous JavaScript and does not forcibly
350359
cancel asynchronous work that ignores `context.signal`.
351360

@@ -388,6 +397,9 @@ added: REPLACEME
388397
* `name` {string} The suite name. **Default:** The `name` property of `fn`, or
389398
`'<anonymous>'` when `fn` has no name.
390399
* `options` {Object}
400+
* `diagnosticChannels` {Array} String diagnostics channel names inherited by
401+
nested suites and benchmarks. Symbol values in the array are silently
402+
ignored. **Default:** `[]`.
391403
* `only` {boolean} Selects all benchmarks nested in this suite. **Default:**
392404
`false`.
393405
* `skip` {boolean|string} Skips all benchmarks nested in this suite.
@@ -663,7 +675,8 @@ message transport from the duration. `record()` is mutually exclusive with
663675
added: REPLACEME
664676
-->
665677

666-
* `message` {string} The diagnostic message.
678+
* `message` {any} A structured-cloneable diagnostic value. With CLI process
679+
isolation, it must also be supported by advanced child process serialization.
667680
* `options` {Object}
668681
* `level` {string} Either `'info'` or `'warning'`. **Default:** `'info'`.
669682
* `detail` {any} Additional structured-cloneable diagnostic data. With CLI
@@ -679,10 +692,10 @@ before a callback failure are emitted before the failed `'bench:complete'`
679692
event and do not themselves cause the benchmark to fail. If a timeout or abort
680693
wins before the callback settles, queued diagnostics might not be emitted.
681694

682-
The message and options are validated, and detail is cloned, synchronously.
683-
Calling `diagnostic()` between `context.start()` and `context.end()` therefore
684-
includes that work in the measured duration. Invalid arguments or an
685-
uncloneable detail violate the sample contract.
695+
The message and detail are cloned synchronously. Options are also validated
696+
synchronously. Calling `diagnostic()` between `context.start()` and
697+
`context.end()` therefore includes that work in the measured duration. Invalid
698+
arguments or an uncloneable message or detail violate the sample contract.
686699

687700
### `context.done()`
688701

@@ -751,6 +764,8 @@ isolation, all files share one runner and their plans are emitted before any
751764
benchmark executes. Plan data contains the benchmark-scoped identity, location,
752765
tags, and parameters described in [benchmark result][], together with:
753766

767+
* `diagnosticChannels` {string\[]} The inherited string channel names
768+
subscribed to during each callback.
754769
* `samples` {number} The effective maximum number of measured callback
755770
invocations after run-level overrides.
756771
* `warmup` {number} The effective number of unreported warmup callback

doc/api/cli.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -492,9 +492,7 @@ benchmark runner process. This reduces startup overhead but allows module,
492492
heap, and process state to carry between files. User writes to stdout or stderr
493493
also share destinations with benchmark reporters in this mode.
494494

495-
The supported modes are `'process'` and `'none'`. Worker-thread isolation is not
496-
a CLI mode. Higher-level tools can implement it using externally measured
497-
samples as described in the [benchmark runner][] documentation.
495+
The supported modes are `'process'` and `'none'`.
498496

499497
### `--bench-name-pattern=pattern`
500498

doc/node.1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ When \fBmode\fR is \fB'none'\fR, all matching files and benchmarks run serially
304304
benchmark runner process. This reduces startup overhead but allows module,
305305
heap, and process state to carry between files. User writes to stdout or stderr
306306
also share destinations with benchmark reporters in this mode.
307+
The supported modes are \fB'process'\fR and \fB'none'\fR.
307308
.
308309
.It Fl -bench-name-pattern Ns = Ns Ar pattern
309310
Only runs benchmarks whose full hierarchical name matches the JavaScript

lib/internal/bench_runner/benchmark.js

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const { structuredClone } = require('internal/worker/js_transferable');
4848
const { bigint: hrtime } = process.hrtime;
4949
const kDefaultSamples = 30;
5050
const kDefaultWarmup = 0;
51+
const kEmptyDiagnosticChannels = ObjectFreeze([]);
5152
const kEmptyNamePath = ObjectFreeze([]);
5253
const kEmptyParams = ObjectFreeze({ __proto__: null });
5354
const kEmptyTags = ObjectFreeze([]);
@@ -82,6 +83,33 @@ function canonicalizeTags(tags, parentTags = kEmptyTags) {
8283
return ObjectFreeze(result);
8384
}
8485

86+
function canonicalizeDiagnosticChannels(
87+
diagnosticChannels,
88+
parentDiagnosticChannels = kEmptyDiagnosticChannels,
89+
) {
90+
if (diagnosticChannels === undefined) return parentDiagnosticChannels;
91+
if (!ArrayIsArray(diagnosticChannels)) {
92+
throw new ERR_INVALID_ARG_TYPE(
93+
'options.diagnosticChannels', 'Array', diagnosticChannels);
94+
}
95+
96+
const result = ArrayPrototypeSlice(parentDiagnosticChannels);
97+
const seen = new SafeSet(parentDiagnosticChannels);
98+
for (let i = 0; i < diagnosticChannels.length; i++) {
99+
const name = diagnosticChannels[i];
100+
if (typeof name === 'symbol') continue;
101+
if (typeof name !== 'string') {
102+
throw new ERR_INVALID_ARG_TYPE(
103+
`options.diagnosticChannels[${i}]`, ['string', 'symbol'], name);
104+
}
105+
if (!seen.has(name)) {
106+
seen.add(name);
107+
ArrayPrototypePush(result, name);
108+
}
109+
}
110+
return ObjectFreeze(result);
111+
}
112+
85113
function canonicalizeParams(params) {
86114
if (params === undefined) return kEmptyParams;
87115
validateObject(params, 'options.params');
@@ -106,15 +134,17 @@ function canonicalizeParams(params) {
106134
return ObjectFreeze(result);
107135
}
108136

109-
function validateNodeOptions(options, parentTags) {
137+
function validateNodeOptions(options, parentTags, parentDiagnosticChannels) {
110138
validateObject(options, 'options');
111-
const { only = false, skip, tags } = options;
139+
const { diagnosticChannels, only = false, skip, tags } = options;
112140
if (typeof only !== 'boolean') {
113141
throw new ERR_INVALID_ARG_TYPE('options.only', 'boolean', only);
114142
}
115143
validateSkip(skip);
116144
return {
117145
__proto__: null,
146+
diagnosticChannels: canonicalizeDiagnosticChannels(
147+
diagnosticChannels, parentDiagnosticChannels),
118148
only,
119149
skip,
120150
tags: canonicalizeTags(tags, parentTags),
@@ -157,7 +187,10 @@ class Suite extends AsyncResource {
157187
constructor(harness, parent, name, options, fn, loc, isRoot = false) {
158188
super('BenchSuite');
159189
const validated = validateNodeOptions(
160-
options, parent?.tags ?? kEmptyTags);
190+
options,
191+
parent?.tags ?? kEmptyTags,
192+
parent?.diagnosticChannels ?? kEmptyDiagnosticChannels,
193+
);
161194

162195
this.harness = harness;
163196
this.parent = parent;
@@ -174,6 +207,7 @@ class Suite extends AsyncResource {
174207
this.namePath,
175208
]);
176209
this.parentId = isRoot || parent.isRoot ? null : parent.suiteId;
210+
this.diagnosticChannels = validated.diagnosticChannels;
177211
this.only = validated.only;
178212
this.skip = validated.skip;
179213
this.tags = validated.tags;
@@ -195,7 +229,8 @@ class Suite extends AsyncResource {
195229
class Bench extends AsyncResource {
196230
constructor(harness, parent, name, options, fn, loc) {
197231
super('Benchmark');
198-
const validated = validateNodeOptions(options, parent.tags);
232+
const validated = validateNodeOptions(
233+
options, parent.tags, parent.diagnosticChannels);
199234
const {
200235
params,
201236
samples = kDefaultSamples,
@@ -216,6 +251,7 @@ class Bench extends AsyncResource {
216251
this.name = name;
217252
this.fn = fn;
218253
this.loc = createLocation(loc, harness.entryFile);
254+
this.diagnosticChannels = validated.diagnosticChannels;
219255
this.only = validated.only;
220256
this.skip = validated.skip;
221257
this.tags = validated.tags;
@@ -275,15 +311,15 @@ class BenchContext {
275311
throw new ERR_INVALID_STATE('benchmark sample is no longer active');
276312
}
277313
try {
278-
validateString(message, 'message');
314+
const clonedMessage = structuredClone(message);
279315
validateObject(options, 'options');
280316
const { detail, level = 'info' } = options;
281317
validateString(level, 'options.level');
282318
if (level !== 'info' && level !== 'warning') {
283319
throw new ERR_INVALID_ARG_VALUE(
284320
'options.level', level, "must be 'info' or 'warning'");
285321
}
286-
const diagnostic = { __proto__: null, level, message };
322+
const diagnostic = { __proto__: null, level, message: clonedMessage };
287323
if (detail !== undefined) diagnostic.detail = structuredClone(detail);
288324
this.#onDiagnostic(diagnostic);
289325
} catch (error) {

lib/internal/bench_runner/cli.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,8 @@ function validateRecord(record) {
440440
warmup,
441441
yieldBetweenSamples,
442442
} = record.data;
443-
if (typeof record.data.file !== 'string' ||
443+
if (!isStringArray(record.data.diagnosticChannels) ||
444+
typeof record.data.file !== 'string' ||
444445
!NumberIsSafeInteger(record.data.line) || record.data.line < 0 ||
445446
!NumberIsSafeInteger(record.data.column) || record.data.column < 0 ||
446447
!isStringArray(record.data.tags) ||
@@ -471,7 +472,7 @@ function validateRecord(record) {
471472
record.data.phase !== 'measurement') ||
472473
!NumberIsSafeInteger(record.data.index) || record.data.index < 0 ||
473474
record.data.index > 0xFFFFFFFF ||
474-
typeof record.data.message !== 'string' ||
475+
!ObjectPrototypeHasOwnProperty(record.data, 'message') ||
475476
(record.data.level !== 'info' && record.data.level !== 'warning') ||
476477
typeof record.data.file !== 'string' ||
477478
!NumberIsSafeInteger(record.data.line) || record.data.line < 0 ||

lib/internal/bench_runner/harness.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ const {
2424
const { getCallerLocation } = internalBinding('util');
2525
const { exitCodes: { kGenericUserError } } = internalBinding('errors');
2626
const { AsyncLocalStorage } = require('async_hooks');
27+
const {
28+
subscribe: subscribeToChannel,
29+
unsubscribe: unsubscribeFromChannel,
30+
} = require('diagnostics_channel');
2731
const { AbortController } = require('internal/abort_controller');
2832
const {
2933
AbortError,
@@ -406,6 +410,7 @@ class Harness {
406410
parentId: benchmark.parentId,
407411
name: benchmark.name,
408412
namePath: ArrayPrototypeSlice(benchmark.namePath),
413+
diagnosticChannels: ArrayPrototypeSlice(benchmark.diagnosticChannels),
409414
file: benchmark.loc.file,
410415
line: benchmark.loc.line,
411416
column: benchmark.loc.column,
@@ -720,9 +725,37 @@ class Harness {
720725
const context = new BenchContext(
721726
benchmark, signal, phase, index,
722727
(diagnostic) => ArrayPrototypePush(diagnostics, diagnostic));
728+
const channels = benchmark.diagnosticChannels;
729+
let abortSubscription;
730+
let diagnosticError;
731+
let diagnosticFailed = false;
732+
let subscribed = 0;
733+
const onMessage = (message, name) => {
734+
if (signal.aborted || diagnosticFailed) return;
735+
try {
736+
context.diagnostic({ __proto__: null, name, message });
737+
} catch (error) {
738+
diagnosticError = error;
739+
diagnosticFailed = true;
740+
}
741+
};
742+
const unsubscribe = () => {
743+
while (subscribed > 0) {
744+
subscribed--;
745+
unsubscribeFromChannel(channels[subscribed], onMessage);
746+
}
747+
};
723748
try {
749+
for (let i = 0; i < channels.length; i++) {
750+
subscribeToChannel(channels[i], onMessage);
751+
subscribed++;
752+
}
753+
if (subscribed > 0) {
754+
abortSubscription = addAbortListener(signal, unsubscribe);
755+
}
724756
await this.#invoke(
725757
benchmark, benchmark, benchmark.fn, [context]);
758+
if (diagnosticFailed) throw diagnosticError;
726759
const { done, sample } = context.finish();
727760
return {
728761
__proto__: null,
@@ -739,6 +772,9 @@ class Harness {
739772
error,
740773
failed: true,
741774
};
775+
} finally {
776+
abortSubscription?.[SymbolDispose]();
777+
unsubscribe();
742778
}
743779
}
744780

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
'use strict';
22

33
const { bench } = require('node:bench');
4+
const { channel } = require('diagnostics_channel');
45

5-
bench('diagnostic relay', { samples: 1 }, (b) => {
6-
b.diagnostic('relayed warning', {
7-
detail: { value: 42n },
8-
level: 'warning',
9-
});
6+
const channelName = 'node:bench:test:diagnostic';
7+
const diagnosticChannel = channel(channelName);
8+
9+
bench('diagnostic relay', {
10+
diagnosticChannels: [channelName],
11+
samples: 1,
12+
}, (b) => {
13+
const message = { value: 42n };
14+
diagnosticChannel.publish(message);
15+
message.value = 0n;
1016
b.record({ duration_ns: 1n, operations: 1 });
1117
});

test/parallel/test-bench-cli.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -399,11 +399,14 @@ for (const isolation of ['process', 'none']) {
399399
({ type }) => type === 'bench:diagnostic').data;
400400
const completion = records.find(
401401
({ type }) => type === 'bench:complete').data;
402-
assert.strictEqual(diagnostic.message, 'relayed warning');
403-
assert.strictEqual(diagnostic.level, 'warning');
402+
assert.deepStrictEqual(diagnostic.message, {
403+
name: 'node:bench:test:diagnostic',
404+
message: { value: '42' },
405+
});
406+
assert.strictEqual(diagnostic.level, 'info');
404407
assert.strictEqual(diagnostic.phase, 'measurement');
405408
assert.strictEqual(diagnostic.index, 0);
406-
assert.deepStrictEqual(diagnostic.detail, { value: '42' });
409+
assert.strictEqual(diagnostic.detail, undefined);
407410
assert.strictEqual(diagnostic.benchId, completion.benchId);
408411
assert.strictEqual(diagnostic.fileRunId, completion.fileRunId);
409412
assert.strictEqual(completion.error, undefined);

test/parallel/test-bench-context-errors.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ runner.bench('reentrant record', { samples: 1 }, (b) => {
5959
runner.bench('uncloneable detail', { samples: 1 }, (b) => {
6060
b.record({ duration_ns: 1n, operations: 1, detail: () => {} });
6161
});
62-
runner.bench('invalid diagnostic message', { samples: 1 }, (b) => {
63-
b.diagnostic(1);
62+
runner.bench('uncloneable diagnostic message', { samples: 1 }, (b) => {
63+
b.diagnostic(() => {});
6464
});
6565
runner.bench('invalid diagnostic level', { samples: 1 }, (b) => {
6666
b.diagnostic('invalid', { level: 'error' });
@@ -113,8 +113,8 @@ runner.bench('caught diagnostic violation', { samples: 1 },
113113
'ERR_INVALID_STATE');
114114
assert.strictEqual(byName.get('uncloneable detail').error.name,
115115
'DataCloneError');
116-
assert.strictEqual(byName.get('invalid diagnostic message').error.code,
117-
'ERR_INVALID_ARG_TYPE');
116+
assert.strictEqual(byName.get('uncloneable diagnostic message').error.name,
117+
'DataCloneError');
118118
assert.strictEqual(byName.get('invalid diagnostic level').error.code,
119119
'ERR_INVALID_ARG_VALUE');
120120
assert.strictEqual(byName.get('uncloneable diagnostic detail').error.name,

0 commit comments

Comments
 (0)