Skip to content

Commit 304edfb

Browse files
authored
Merge pull request #32 from jasnell/jasnell/node-bench-3
lib: have runFile honor permissions and accept URL/Buffer paths
2 parents d47a93f + 4358154 commit 304edfb

13 files changed

Lines changed: 373 additions & 51 deletions

File tree

doc/api/bench.md

Lines changed: 34 additions & 15 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.
@@ -504,7 +516,7 @@ for await (const { type, data } of run()) {
504516
added: REPLACEME
505517
-->
506518

507-
* `path` {string} The absolute path of one benchmark module.
519+
* `path` {string|Buffer|URL} The path of one benchmark module.
508520
* `options` {Object}
509521
* `env` {Object} The child process environment. Property values must be
510522
strings or `undefined`. This replaces, rather than extends, the parent
@@ -518,18 +530,22 @@ added: REPLACEME
518530
* Returns: {BenchmarksStream}
519531

520532
Runs exactly one benchmark module in a fresh child process and returns its
521-
object-mode event stream. `path` is not interpreted as a glob. Unless the signal
522-
is aborted or the stream is destroyed before startup, every call uses a new
523-
child. Input discovery, ordering, concurrency, retries, and multi-file
524-
scheduling remain the caller's responsibility.
533+
object-mode event stream. A relative `path` is resolved from the current working
534+
directory when `runFile()` is called. `path` is not interpreted as a glob.
535+
Unless the signal is aborted or the stream is destroyed before startup, every
536+
call uses a new child. Input discovery, ordering, concurrency, retries, and
537+
multi-file scheduling remain the caller's responsibility.
538+
539+
When the Permission Model is enabled, the caller must have file system read
540+
access to `path` and permission to create child processes.
525541

526542
Records use advanced child process serialization, preserving supported
527543
structured values such as `bigint` and errors. Child writes to stdout and stderr
528-
become `'bench:diagnostic'` records. A module loading error, abnormal child exit,
529-
or cancellation also emits an error diagnostic and produces a terminal
530-
`'bench:summary'` whose `success` property is `false`; these execution failures
531-
do not error the stream. If module evaluation fails after declaring benchmarks,
532-
those declarations still run before the unsuccessful summary.
544+
become `'bench:diagnostic'` records. A permission failure, module loading error,
545+
abnormal child exit, or cancellation also emits an error diagnostic and produces
546+
a terminal `'bench:summary'` whose `success` property is `false`; these execution
547+
failures do not error the stream. If module evaluation fails after declaring
548+
benchmarks, those declarations still run before the unsuccessful summary.
533549

534550
`env`, effective inherited options, and an explicitly provided `execArgv` are
535551
copied when `runFile()` is called. The runner removes `NODE_OPTIONS`, replaces
@@ -659,7 +675,8 @@ message transport from the duration. `record()` is mutually exclusive with
659675
added: REPLACEME
660676
-->
661677

662-
* `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.
663680
* `options` {Object}
664681
* `level` {string} Either `'info'` or `'warning'`. **Default:** `'info'`.
665682
* `detail` {any} Additional structured-cloneable diagnostic data. With CLI
@@ -675,10 +692,10 @@ before a callback failure are emitted before the failed `'bench:complete'`
675692
event and do not themselves cause the benchmark to fail. If a timeout or abort
676693
wins before the callback settles, queued diagnostics might not be emitted.
677694

678-
The message and options are validated, and detail is cloned, synchronously.
679-
Calling `diagnostic()` between `context.start()` and `context.end()` therefore
680-
includes that work in the measured duration. Invalid arguments or an
681-
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.
682699

683700
### `context.done()`
684701

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

767+
* `diagnosticChannels` {string\[]} The inherited string channel names
768+
subscribed to during each callback.
750769
* `samples` {number} The effective maximum number of measured callback
751770
invocations after run-level overrides.
752771
* `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: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,14 @@ const {
3636
StringPrototypeStartsWith,
3737
StringPrototypeToUpperCase,
3838
SymbolDispose,
39+
uncurryThis,
3940
} = primordials;
41+
const { Buffer } = require('buffer');
42+
const BufferToString = uncurryThis(Buffer.prototype.toString);
4043
const { spawn } = require('child_process');
4144
const { createWriteStream, statSync } = require('fs');
4245
const { Glob } = require('internal/fs/glob');
46+
const { getValidatedPath } = require('internal/fs/utils');
4347
const {
4448
BenchmarksStream,
4549
} = require('internal/bench_runner/benchmarks_stream');
@@ -53,6 +57,7 @@ const { deserializeError, serializeError } = require('internal/error_serdes');
5357
const {
5458
AbortError,
5559
codes: {
60+
ERR_ACCESS_DENIED,
5661
ERR_INVALID_ARG_TYPE,
5762
ERR_INVALID_ARG_VALUE,
5863
ERR_INVALID_STATE,
@@ -64,6 +69,7 @@ const {
6469
getOptionValue,
6570
getOptionsAsFlagsFromBinding,
6671
} = require('internal/options');
72+
const permission = require('internal/process/permission');
6773
const { TIMEOUT_MAX } = require('internal/timers');
6874
const { kEmptyObject } = require('internal/util');
6975
const {
@@ -75,7 +81,7 @@ const {
7581
} = require('internal/validators');
7682
const { pathToFileURL } = require('internal/url');
7783
const { pipeline } = require('stream/promises');
78-
const { isAbsolute, resolve, sep } = require('path');
84+
const { resolve, sep } = require('path');
7985
const { clearTimeout, setTimeout } = require('timers');
8086

8187
const console = require('internal/console/global');
@@ -434,7 +440,8 @@ function validateRecord(record) {
434440
warmup,
435441
yieldBetweenSamples,
436442
} = record.data;
437-
if (typeof record.data.file !== 'string' ||
443+
if (!isStringArray(record.data.diagnosticChannels) ||
444+
typeof record.data.file !== 'string' ||
438445
!NumberIsSafeInteger(record.data.line) || record.data.line < 0 ||
439446
!NumberIsSafeInteger(record.data.column) || record.data.column < 0 ||
440447
!isStringArray(record.data.tags) ||
@@ -465,7 +472,7 @@ function validateRecord(record) {
465472
record.data.phase !== 'measurement') ||
466473
!NumberIsSafeInteger(record.data.index) || record.data.index < 0 ||
467474
record.data.index > 0xFFFFFFFF ||
468-
typeof record.data.message !== 'string' ||
475+
!ObjectPrototypeHasOwnProperty(record.data, 'message') ||
469476
(record.data.level !== 'info' && record.data.level !== 'warning') ||
470477
typeof record.data.file !== 'string' ||
471478
!NumberIsSafeInteger(record.data.line) || record.data.line < 0 ||
@@ -758,6 +765,16 @@ async function runChild(path, options, scope, onRecord) {
758765
}),
759766
};
760767
}
768+
const resource = resolve(options.cwd, path);
769+
if (permission.isEnabled() &&
770+
!permission.has('fs.read', resource) &&
771+
!permission.isAuditMode()) {
772+
throw new ERR_ACCESS_DENIED(
773+
'Access to this API has been restricted. Use --allow-fs-read to manage permissions.',
774+
'FileSystemRead',
775+
resource,
776+
);
777+
}
761778
const child = spawn(
762779
options.execPath ?? process.execPath,
763780
getChildArgs(path, options),
@@ -1116,10 +1133,8 @@ async function runIsolated(files, options, output) {
11161133
}
11171134

11181135
function runFile(path, options = kEmptyObject) {
1119-
validateStringWithoutNullBytes(path, 'path');
1120-
if (!isAbsolute(path)) {
1121-
throw new ERR_INVALID_ARG_VALUE('path', path, 'must be an absolute path');
1122-
}
1136+
path = getValidatedPath(path);
1137+
if (typeof path !== 'string') path = BufferToString(path);
11231138
const file = resolve(path);
11241139
validateObject(options, 'options');
11251140
const {

0 commit comments

Comments
 (0)