@nestia/benchmark 14.0.1 → 14.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,13 @@
1
1
  import { IConnection } from "@nestia/fetcher";
2
2
  import fs from "fs";
3
+ import os from "os";
4
+ import NodePath from "path";
3
5
  import { Driver, WorkerConnector, WorkerServer } from "tgrid";
4
- import { HashMap, hash, sleep_for } from "tstl";
6
+ import { HashMap, hash } from "tstl";
5
7
 
6
8
  import { IBenchmarkEvent } from "./IBenchmarkEvent";
7
9
  import { DynamicBenchmarkReporter } from "./internal/DynamicBenchmarkReporter";
10
+ import { DynamicBenchmarkStatistics } from "./internal/DynamicBenchmarkStatistics";
8
11
  import { IBenchmarkMaster } from "./internal/IBenchmarkMaster";
9
12
  import { IBenchmarkServant } from "./internal/IBenchmarkServant";
10
13
 
@@ -33,17 +36,65 @@ import { IBenchmarkServant } from "./internal/IBenchmarkServant";
33
36
  * Additionally, if you hope to see some utilization cases, see the below
34
37
  * example tagged links.
35
38
  *
39
+ * Processing cost: Totals are split across T servants once; E events are
40
+ * aggregated with hash-based endpoint grouping. Progress notifications update
41
+ * the aggregate from one servant counter delta in constant time. Event
42
+ * statistics and rendering traverse their input populations; discovery visits
43
+ * each real directory once.
44
+ *
45
+ * Computation reuse: A master distributes distinct request budgets rather than
46
+ * replaying one effectful request. Endpoint grouping shares one event
47
+ * population across whole-run and endpoint statistics. Results are not cached
48
+ * across benchmark runs because server state and timing change.
49
+ *
50
+ * Resource ownership: Each master owns at most threads connectors and
51
+ * simultaneous function workers; connector closure is attempted on
52
+ * connection/execution success or failure; a rejected close is suppressed and
53
+ * may leave an unreleased child process. Events retained by servants/master
54
+ * scale with logged events, and memory samples with run duration. The
55
+ * independent sampler detaches its report target on stop or rejection and
56
+ * clears/wakes its owned timer. A pending caller memory getter has no
57
+ * cancellation hook, but its task retains only the sampler state/getter and
58
+ * cannot keep the master report arrays, connectors or event collections through
59
+ * that state. Its eventual result is discarded after deactivation. Returned
60
+ * reports transfer their arrays to callers.
61
+ *
62
+ * Platform boundary: WorkerConnector process mode owns Node child-process
63
+ * launch/IPC and its explicit stdio modes. Feature discovery resolves the
64
+ * caller location with native path and fs realpath/stat, follows directory
65
+ * links with realpath cycle detection and imports discovered native absolute
66
+ * paths. It does not infer filesystem case policy from OS names.
67
+ *
36
68
  * @author Jeongho Nam - https://github.com/samchon
37
69
  * @example
38
70
  * https://github.com/samchon/nestia-start/blob/master/test/benchmaark/index.ts
39
71
  *
40
72
  * @example
41
73
  * https://github.com/samchon/backend/blob/master/test/benchmark/index.ts
74
+ *
75
+ * @evidence contracts/common.md#principled-implementation The master spawns one child process per thread through tgrid's process-mode `WorkerConnector`, drives each servant over RPC, and merges the returned events; totals are split by floor plus remainder so the shares sum to the requested count and simultaneous budget.
76
+ * @evidence contracts/common.md#clear-and-simple-design One namespace is the public surface (master, servant, markdown, props, report); execution, discovery, and cleanup helpers stay private, and statistics and rendering live in their own internal namespaces.
77
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts Nothing names a server, feature, or file: the feature extension, prefix, and filter are caller inputs, and the servant path is a property rather than an assumed location.
78
+ * @evidence contracts/common.md#meaningful-documentation The namespace prose explains the two-program model, what each program does, and how to obtain markdown, with links to the member documentation and to example repositories.
42
79
  */
43
80
  export namespace DynamicBenchmarker {
44
- /** Properties of the master program. */
81
+ /**
82
+ * Properties of the master program.
83
+ *
84
+ * Platform boundary: The servant path uses native filesystem spelling, and
85
+ * stdio exposes Node child-process modes without imposing separator or case
86
+ * policy.
87
+ *
88
+ * @evidence contracts/common.md#principled-implementation The fields are exactly what the master needs to plan a run: totals, parallelism, how to reach the servant program, and optional hooks; the count, threads, and simultaneous constraints are enforced by `master`, which the field prose states.
89
+ * @evidence contracts/common.md#clear-and-simple-design A flat property record; optional members carry the only extension points (filter, progress, memory, stdio).
90
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts Every value is caller-supplied; no default names a target application or fixture.
91
+ * @evidence contracts/common.md#meaningful-documentation Each property documents its meaning and constraints, including how simultaneous is distributed among threads and that the filter receives a file basename.
92
+ */
45
93
  export interface IMasterProps {
46
- /** Total count of the requests. */
94
+ /**
95
+ * Total number of benchmark function invocations, each of which may log
96
+ * several HTTP requests.
97
+ */
47
98
  count: number;
48
99
 
49
100
  /**
@@ -82,13 +133,24 @@ export namespace DynamicBenchmarker {
82
133
  *
83
134
  * @param file File name (basename) of the benchmark functions
84
135
  * @returns Whether to execute the function or not.
136
+ * @evidence contracts/common.md#principled-implementation The predicate is asked for each candidate file name through the master driver before the servant imports it, so a rejected file's functions can never execute.
137
+ * @evidence contracts/common.md#clear-and-simple-design A single predicate on the file basename, with a default that accepts everything.
138
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts Selection is by the caller's predicate, not by known file names.
139
+ * @evidence contracts/common.md#meaningful-documentation The comment states the argument (basename, not function name) and that a `false` result prevents the import.
85
140
  */
86
141
  filter?: (file: string) => boolean;
87
142
 
88
143
  /**
89
144
  * Progress callback function.
90
145
  *
91
- * @param complete The number of completed requests.
146
+ * Progress counts completed benchmark function invocations, regardless of
147
+ * how many HTTP events each invocation logs.
148
+ *
149
+ * @param complete The number of completed benchmark function invocations.
150
+ * @evidence contracts/common.md#principled-implementation The callback receives the sum of completed invocation counts from every servant and is called once more with the requested count at the end, including when no matching functions were discovered.
151
+ * @evidence contracts/common.md#clear-and-simple-design One optional notification with a single number argument.
152
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts The callback does not configure request counts or concurrency; a throw from the final callback propagates after servants have been closed rather than being hidden.
153
+ * @evidence contracts/common.md#meaningful-documentation The comment distinguishes completed benchmark function invocations from logged HTTP events.
92
154
  */
93
155
  progress?: (complete: number) => void;
94
156
 
@@ -100,6 +162,16 @@ export namespace DynamicBenchmarker {
100
162
  * Specify this property only when your backend server is running on a
101
163
  * different process, so that need to measure the memory usage of the
102
164
  * backend server from other process.
165
+ *
166
+ * The function is called once a second while the benchmark runs. If it
167
+ * rejects, the sampling stops and the report keeps the samples taken so
168
+ * far. A result still pending when execution ends is discarded when it
169
+ * settles.
170
+ *
171
+ * @evidence contracts/common.md#principled-implementation When the server under test runs in another process, the master's own `process.memoryUsage()` measures the wrong process, so the caller supplies a getter for the right one; it is sampled once a second while the run lasts.
172
+ * @evidence contracts/common.md#clear-and-simple-design One optional async getter that replaces the default sampler and nothing else.
173
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts The default measures the master process itself; a caller getter is used as given, and a getter that rejects ends sampling rather than being retried or replaced.
174
+ * @evidence contracts/common.md#meaningful-documentation The comment states when to set it, the sampling cadence, and that a rejection stops the sampling and keeps earlier samples.
103
175
  */
104
176
  memory?: () => Promise<NodeJS.MemoryUsage>;
105
177
 
@@ -111,7 +183,18 @@ export namespace DynamicBenchmarker {
111
183
  stdio?: undefined | "overlapped" | "pipe" | "ignore" | "inherit";
112
184
  }
113
185
 
114
- /** Properties of the servant program. */
186
+ /**
187
+ * Properties of the servant program.
188
+ *
189
+ * Platform boundary: The feature location and extension describe native files
190
+ * separately from the connection's protocol address; relative paths use the
191
+ * servant's working directory.
192
+ *
193
+ * @evidence contracts/common.md#principled-implementation The fields define how a servant finds benchmark functions (location, extension, prefix) and how it builds their arguments (connection and parameters), which is everything discovery and invocation need.
194
+ * @evidence contracts/common.md#clear-and-simple-design A generic property record parameterized by the tuple of function arguments, with the optional extension defaulting to the built JavaScript.
195
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts The extension is declared rather than inferred from the servant's own module, which once loaded no feature at all when the library was built and the features ran from TypeScript source.
196
+ * @evidence contracts/common.md#meaningful-documentation Each property documents its role, the default extension, the file-name and function-name prefix rules, and what happens when nothing matches.
197
+ */
115
198
  export interface IServantProps<Parameters extends any[]> {
116
199
  /**
117
200
  * Default connection.
@@ -120,7 +203,12 @@ export namespace DynamicBenchmarker {
120
203
  */
121
204
  connection: IConnection;
122
205
 
123
- /** Location of the benchmark functions. */
206
+ /**
207
+ * Location of the benchmark functions.
208
+ *
209
+ * A relative path is resolved against the current working directory of the
210
+ * servant, the same base that lists the directory.
211
+ */
124
212
  location: string;
125
213
 
126
214
  /**
@@ -145,6 +233,9 @@ export namespace DynamicBenchmarker {
145
233
  * would never be executed. Also, when a file name (basename) does not start
146
234
  * with the prefix, the file would never be imported either, so that every
147
235
  * function defined in the file would never be executed.
236
+ *
237
+ * When no function matches, no request is executed and the report carries
238
+ * no events.
148
239
  */
149
240
  prefix: string;
150
241
 
@@ -157,36 +248,117 @@ export namespace DynamicBenchmarker {
157
248
  *
158
249
  * @param connection Default connection instance
159
250
  * @param name Function name
251
+ * @evidence contracts/common.md#principled-implementation A function from the per-request connection and the function name to the argument tuple lets each execution receive a connection whose logger records its events.
252
+ * @evidence contracts/common.md#clear-and-simple-design One callback, called per execution, with the two inputs a caller needs to build arguments.
253
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts Arguments come only from the caller's function; the connection it receives is the servant's logging copy.
254
+ * @evidence contracts/common.md#meaningful-documentation The comment warns that the logger of the received connection must be carried into the returned parameters, the fact that decides whether events are recorded.
160
255
  */
161
256
  parameters: (connection: IConnection, name: string) => Parameters;
162
257
  }
163
258
 
164
- /** Benchmark report. */
259
+ /**
260
+ * Benchmark report.
261
+ *
262
+ * Times are ISO 8601 strings, durations are milliseconds, and the
263
+ * {@link statistics} and every entry of {@link endpoints} describe the same
264
+ * collected events, grouped by method and path template.
265
+ *
266
+ * @evidence contracts/common.md#principled-implementation The report is the master's merged observation: the run parameters, whole-run statistics, per-endpoint statistics, and memory samples, all derived from one event collection.
267
+ * @evidence contracts/common.md#clear-and-simple-design A flat record whose nested types (`IEndpoint`, `IStatistics`, `IMemory`) name the parts, with no behavior.
268
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts Every field is measured or copied from the request; none is a fixture value.
269
+ * @evidence contracts/common.md#meaningful-documentation The prose states the time format, the duration unit, and how statistics and endpoints relate.
270
+ */
165
271
  export interface IReport {
272
+ /** Requested number of benchmark function invocations. */
166
273
  count: number;
274
+
275
+ /** Number of servant processes requested. */
167
276
  threads: number;
277
+
278
+ /** Aggregate limit on concurrent benchmark function invocations. */
168
279
  simultaneous: number;
280
+
281
+ /** ISO 8601 time when connected servants started executing. */
169
282
  started_at: string;
283
+
284
+ /** ISO 8601 time when every servant finished executing. */
170
285
  completed_at: string;
286
+
287
+ /** Statistics over all HTTP events logged by the benchmark functions. */
171
288
  statistics: IReport.IStatistics;
289
+
290
+ /** The same events grouped by HTTP method and route path template. */
172
291
  endpoints: Array<IReport.IEndpoint & IReport.IStatistics>;
292
+
293
+ /**
294
+ * Memory samples obtained during execution; failed or pending readings are
295
+ * omitted.
296
+ */
173
297
  memories: IReport.IMemory[];
174
298
  }
175
299
  export namespace IReport {
300
+ /**
301
+ * Identity of an endpoint in the report: the HTTP method and the route
302
+ * path, using the path template when the route declares one.
303
+ *
304
+ * @evidence contracts/common.md#principled-implementation An endpoint is identified by method and path, the pair the master groups events by, with the template preferred so parameterized paths aggregate.
305
+ * @evidence contracts/common.md#clear-and-simple-design Two string fields with no behavior.
306
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts Grouping uses the route metadata carried by each event, without a list of known endpoints.
307
+ * @evidence contracts/common.md#meaningful-documentation The comment states which path form is used for grouping.
308
+ */
176
309
  export interface IEndpoint {
310
+ /** HTTP method of the grouped requests. */
177
311
  method: string;
312
+
313
+ /** Route template, falling back to the concrete route path. */
178
314
  path: string;
179
315
  }
316
+ /**
317
+ * Statistics of a set of benchmarked requests.
318
+ *
319
+ * Durations are milliseconds from the request start to its completion.
320
+ * `mean`, `stdev`, `minimum`, and `maximum` are `null` when there are no
321
+ * events, and `stdev` is the population standard deviation. `success`
322
+ * counts the events whose executed function did not throw.
323
+ *
324
+ * @evidence contracts/common.md#principled-implementation Counts are exact, the mean and population standard deviation are computed over the same set, and `null` marks the empty set where no value is defined.
325
+ * @evidence contracts/common.md#clear-and-simple-design A flat record of six measures reused for the whole run and for each endpoint.
326
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts The values are computed from the events; no measure is defaulted to a plausible number.
327
+ * @evidence contracts/common.md#meaningful-documentation The comment states the unit, the empty-set convention, the deviation kind, and what success counts.
328
+ */
180
329
  export interface IStatistics {
330
+ /** Number of logged HTTP events. */
181
331
  count: number;
332
+
333
+ /** Events whose enclosing benchmark function completed without throwing. */
182
334
  success: number;
335
+
336
+ /** Mean duration in milliseconds, or null for no events. */
183
337
  mean: number | null;
338
+
339
+ /** Population standard deviation in milliseconds, or null for no events. */
184
340
  stdev: number | null;
341
+
342
+ /** Shortest duration in milliseconds, or null for no events. */
185
343
  minimum: number | null;
344
+
345
+ /** Longest duration in milliseconds, or null for no events. */
186
346
  maximum: number | null;
187
347
  }
348
+ /**
349
+ * One memory sample taken while the benchmark ran: an ISO 8601 time and the
350
+ * process memory usage read by the sampler.
351
+ *
352
+ * @evidence contracts/common.md#principled-implementation A sample pairs the instant with the usage object exactly as the getter returned it, so the report keeps Node's own fields.
353
+ * @evidence contracts/common.md#clear-and-simple-design Two fields with no behavior.
354
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts The usage is the getter's return value, not a synthesized reading.
355
+ * @evidence contracts/common.md#meaningful-documentation The comment states the time format and the source of the usage.
356
+ */
188
357
  export interface IMemory {
358
+ /** ISO 8601 time when the memory getter completed. */
189
359
  time: string;
360
+
361
+ /** Node memory usage fields in bytes, supplied by the selected getter. */
190
362
  usage: NodeJS.MemoryUsage;
191
363
  }
192
364
  }
@@ -199,8 +371,45 @@ export namespace DynamicBenchmarker {
199
371
  * Note that, {@link IMasterProps.servant} property must be the path of the
200
372
  * servant program executing the {@link servant} function.
201
373
  *
374
+ * The servants are child processes. When a servant fails to connect or to
375
+ * execute, every servant receives a close attempt before the returned promise
376
+ * rejects.
377
+ *
378
+ * Processing cost: Totals are split across T servants once; E events are
379
+ * aggregated with hash-based endpoint grouping. Progress notifications update
380
+ * the aggregate from one servant counter delta in constant time. Event
381
+ * statistics and rendering traverse their input populations; discovery visits
382
+ * each real directory once.
383
+ *
384
+ * Computation reuse: A master distributes distinct request budgets rather
385
+ * than replaying one effectful request. Endpoint grouping shares one event
386
+ * population across whole-run and endpoint statistics. Results are not cached
387
+ * across benchmark runs because server state and timing change.
388
+ *
389
+ * Resource ownership: Each master owns at most threads connectors and
390
+ * simultaneous function workers; connector closure is attempted on
391
+ * connection/execution success or failure; a rejected close is suppressed and
392
+ * may leave an unreleased child process. Events retained by servants/master
393
+ * scale with logged events, and memory samples with run duration. The
394
+ * independent sampler detaches its report target on stop or rejection and
395
+ * clears/wakes its owned timer. A pending caller memory getter has no
396
+ * cancellation hook, but its task retains only the sampler state/getter and
397
+ * cannot keep the master report arrays, connectors or event collections
398
+ * through that state. Its eventual result is discarded after deactivation.
399
+ * Returned reports transfer their arrays to callers.
400
+ *
401
+ * Platform boundary: WorkerConnector process mode owns Node child-process
402
+ * launch/IPC and its explicit stdio modes. Feature discovery resolves the
403
+ * caller location with native path and fs realpath/stat, follows directory
404
+ * links with realpath cycle detection and imports discovered native absolute
405
+ * paths. It does not infer filesystem case policy from OS names.
406
+ *
202
407
  * @param props Properties of the master program
203
408
  * @returns Benchmark report
409
+ * @evidence contracts/common.md#principled-implementation Inputs are validated as safe integers before any process starts; totals are distributed by floor plus remainder; servants execute concurrently and every event is grouped by method and path template in a hash map; closing is attempted for every spawned servant on success and on failure, and the memory sampler is deactivated when the run ends, detaching its report array and clearing/waking its owned timer immediately; a pending caller getter may settle later but cannot append.
410
+ * @evidence contracts/common.md#clear-and-simple-design One function owns the run and keeps the sequence readable: validate, spawn, sample, execute, close, aggregate; statistics come from `DynamicBenchmarkStatistics` and the closing helper is shared by the failure and success paths.
411
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts A servant that fails to close cannot alter a finished report and is not allowed to hide the run's own error, so closing failures are dropped on purpose; no other error is swallowed except a rejecting memory getter, which ends sampling as documented.
412
+ * @evidence contracts/common.md#meaningful-documentation The comment states what the master does, that the servant property must name the servant program, that servants are child processes, and that every servant receives a close attempt before a failure rejects.
204
413
  */
205
414
  export const master = async (props: IMasterProps): Promise<IReport> => {
206
415
  if (!Number.isSafeInteger(props.count) || props.count < 0)
@@ -220,6 +429,7 @@ export namespace DynamicBenchmarker {
220
429
  `DynamicBenchmarker.master(): simultaneous (${props.simultaneous}) must not be less than threads (${props.threads}), as each servant runs at least one request at a time.`,
221
430
  );
222
431
  const completes: number[] = new Array(props.threads).fill(0);
432
+ let completed: number = 0;
223
433
  // the first `total % threads` servants take one more than the rest, so the
224
434
  // shares sum to the total
225
435
  const distribute = (total: number): number[] =>
@@ -232,61 +442,63 @@ export namespace DynamicBenchmarker {
232
442
  );
233
443
  const counts: number[] = distribute(props.count);
234
444
  const budgets: number[] = distribute(props.simultaneous);
235
- const servants: WorkerConnector<
236
- null,
237
- IBenchmarkMaster,
238
- IBenchmarkServant
239
- >[] = await Promise.all(
445
+ // every servant is a child process, so each connector created must be
446
+ // closed on every path out of this function, including a failed connection
447
+ // of a sibling
448
+ const spawned: Servant[] = [];
449
+ const settled: PromiseSettledResult<void>[] = await Promise.allSettled(
240
450
  new Array(props.threads).fill(null).map(async (_, i) => {
241
- const connector: WorkerConnector<
242
- null,
243
- IBenchmarkMaster,
244
- IBenchmarkServant
245
- > = new WorkerConnector(
451
+ const connector: Servant = new WorkerConnector(
246
452
  null,
247
453
  {
248
454
  filter: props.filter ?? (() => true),
249
455
  progress: (current) => {
456
+ completed += current - completes[i]!;
250
457
  completes[i] = current;
251
- if (props.progress)
252
- props.progress(completes.reduce((a, b) => a + b, 0));
458
+ if (props.progress) props.progress(completed);
253
459
  },
254
460
  },
255
461
  "process",
256
462
  );
463
+ spawned.push(connector);
257
464
  await connector.connect(props.servant, { stdio: props.stdio });
258
- return connector;
259
465
  }),
260
466
  );
467
+ const refused: PromiseRejectedResult | undefined = settled.find(
468
+ (result): result is PromiseRejectedResult => result.status === "rejected",
469
+ );
470
+ if (refused !== undefined) {
471
+ await closeAll(spawned);
472
+ throw refused.reason;
473
+ }
474
+ const servants: Servant[] = spawned;
261
475
 
262
476
  const started_at: Date = new Date();
263
477
  const memories: IReport.IMemory[] = [];
264
- let completed_at: Date | null = null;
265
-
266
- (async () => {
267
- const getter = props.memory ?? (async () => process.memoryUsage());
268
- while (completed_at === null) {
269
- await sleep_for(1_000);
270
- memories.push({
271
- usage: await getter(),
272
- time: new Date().toISOString(),
273
- });
274
- }
275
- })().catch(() => {});
478
+ let completed_at: Date;
479
+ let events: IBenchmarkEvent[];
276
480
 
277
- const events: IBenchmarkEvent[] = (
278
- await Promise.all(
279
- servants.map((connector, index) =>
280
- connector.getDriver().execute({
281
- count: counts[index]!,
282
- simultaneous: budgets[index]!,
283
- }),
284
- ),
285
- )
286
- ).flat();
481
+ const stopSampling = startMemorySampler(
482
+ props.memory ?? (async () => process.memoryUsage()),
483
+ memories,
484
+ );
287
485
 
288
- completed_at = new Date();
289
- await Promise.all(servants.map((connector) => connector.close()));
486
+ try {
487
+ events = (
488
+ await Promise.all(
489
+ servants.map((connector, index) =>
490
+ connector.getDriver().execute({
491
+ count: counts[index]!,
492
+ simultaneous: budgets[index]!,
493
+ }),
494
+ ),
495
+ )
496
+ ).flat();
497
+ completed_at = new Date();
498
+ } finally {
499
+ stopSampling();
500
+ await closeAll(servants);
501
+ }
290
502
  if (props.progress) props.progress(props.count);
291
503
 
292
504
  const endpoints: HashMap<IReport.IEndpoint, IBenchmarkEvent[]> =
@@ -308,9 +520,9 @@ export namespace DynamicBenchmarker {
308
520
  count: props.count,
309
521
  threads: props.threads,
310
522
  simultaneous: props.simultaneous,
311
- statistics: statistics(events),
523
+ statistics: DynamicBenchmarkStatistics.of(events),
312
524
  endpoints: [...endpoints].map((it) => ({
313
- ...statistics(it.second),
525
+ ...DynamicBenchmarkStatistics.of(it.second),
314
526
  ...it.first,
315
527
  })),
316
528
  started_at: started_at.toISOString(),
@@ -324,8 +536,33 @@ export namespace DynamicBenchmarker {
324
536
  *
325
537
  * Creates a servant program executing the prefixed functions in parallel.
326
538
  *
539
+ * Processing cost: Each execute request discovers matching modules once, then
540
+ * runs at most simultaneous loop workers and count invocations. Event storage
541
+ * grows with actual logger calls rather than an assumed one event per
542
+ * invocation.
543
+ *
544
+ * Computation reuse: Discovery is refreshed for each execute request because
545
+ * the supplied filter and filesystem can change; requests themselves are
546
+ * effectful and cannot reuse earlier observations.
547
+ *
548
+ * Resource ownership: The returned WorkerServer transfers connection
549
+ * lifecycle to its caller. Each execute owns its discovery collections,
550
+ * workers and event arrays until it completes; the master closes its servant
551
+ * process. No discovered-module cache is retained by this namespace beyond
552
+ * Node own loader.
553
+ *
554
+ * Platform boundary: WorkerConnector process mode owns Node child-process
555
+ * launch/IPC and its explicit stdio modes. Feature discovery resolves the
556
+ * caller location with native path and fs realpath/stat, follows directory
557
+ * links with realpath cycle detection and imports discovered native absolute
558
+ * paths. It does not infer filesystem case policy from OS names.
559
+ *
327
560
  * @param props Properties of the servant program
328
561
  * @returns Servant program as a worker server
562
+ * @evidence contracts/common.md#principled-implementation A servant opens a `WorkerServer` that exposes one `execute` operation; the master decides counts and budgets, so the servant only needs the location and naming rules to find functions.
563
+ * @evidence contracts/common.md#clear-and-simple-design A thin adapter over the worker server whose behavior lives in the private `execute` and `iterate` helpers.
564
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts Discovery is driven by the caller's location, extension, and prefix, and by the master's filter; no file or function name is assumed.
565
+ * @evidence contracts/common.md#meaningful-documentation The comment states that it creates the servant program and returns it as a worker server.
329
566
  */
330
567
  export const servant = async <Parameters extends any[]>(
331
568
  props: IServantProps<Parameters>,
@@ -344,11 +581,27 @@ export namespace DynamicBenchmarker {
344
581
  /**
345
582
  * Convert the benchmark report to markdown content.
346
583
  *
584
+ * Processing cost: Rendering delegates to the reporter and scales with report
585
+ * endpoints, samples and output bytes; host metadata is obtained through Node
586
+ * os APIs.
587
+ *
588
+ * Platform boundary: The wrapper obtains host information through Node os
589
+ * APIs and passes those facts to the pure renderer without constructing
590
+ * native file paths or shell commands.
591
+ *
347
592
  * @param report Benchmark report
348
593
  * @returns Markdown content
594
+ * @evidence contracts/common.md#principled-implementation Rendering is a pure function of the report and a few host facts, delegated to the reporter so the report type and its presentation stay separate.
595
+ * @evidence contracts/common.md#clear-and-simple-design The wrapper captures three native host facts and delegates rendering, giving users one entry point without exposing the internal reporter.
596
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts The function observes native host facts and formats the report without altering its measurements.
597
+ * @evidence contracts/common.md#meaningful-documentation The comment states input and output: a report in, markdown content out.
349
598
  */
350
599
  export const markdown = (report: DynamicBenchmarker.IReport): string =>
351
- DynamicBenchmarkReporter.markdown(report);
600
+ DynamicBenchmarkReporter.markdown(report, {
601
+ cpu: os.cpus()[0]?.model,
602
+ memory: os.totalmem(),
603
+ node: process.version,
604
+ });
352
605
 
353
606
  const execute =
354
607
  <Parameters extends any[]>(ctx: {
@@ -364,10 +617,11 @@ export namespace DynamicBenchmarker {
364
617
  collection: functions,
365
618
  driver: ctx.driver,
366
619
  props: ctx.props,
367
- })(ctx.props.location);
620
+ })(NodePath.resolve(ctx.props.location));
368
621
 
369
622
  const entireEvents: IBenchmarkEvent[] = [];
370
623
  let scheduled: number = 0;
624
+ let completed: number = 0;
371
625
  await Promise.all(
372
626
  new Array(mass.simultaneous)
373
627
  .fill(null)
@@ -398,16 +652,81 @@ export namespace DynamicBenchmarker {
398
652
  } catch (exp) {
399
653
  for (const e of localEvents) e.success = false;
400
654
  }
401
- if (localEvents.length !== 0)
402
- ctx.driver.progress(entireEvents.length).catch(() => {});
655
+ ctx.driver.progress(++completed).catch(() => {});
403
656
  }
404
657
  }),
405
658
  );
406
- await ctx.driver.progress(entireEvents.length);
659
+ await ctx.driver.progress(completed);
407
660
  return entireEvents;
408
661
  };
409
662
  }
410
663
 
664
+ type Servant = WorkerConnector<null, IBenchmarkMaster, IBenchmarkServant>;
665
+
666
+ /**
667
+ * Keeps the sampler's pending caller task separate from the master's report,
668
+ * connectors and events. Stopping detaches the report array immediately;
669
+ * cancellation of a caller-owned getter remains its caller's responsibility.
670
+ */
671
+ const startMemorySampler = (
672
+ getter: () => Promise<NodeJS.MemoryUsage>,
673
+ memories: DynamicBenchmarker.IReport.IMemory[],
674
+ ): (() => void) => {
675
+ const state: MemorySamplerState = {
676
+ active: true,
677
+ target: memories,
678
+ };
679
+ sampleMemory(getter, state).catch(() => {});
680
+ return () => stopMemorySampler(state);
681
+ };
682
+
683
+ interface MemorySamplerState {
684
+ active: boolean;
685
+ target: DynamicBenchmarker.IReport.IMemory[] | null;
686
+ timer?: ReturnType<typeof setTimeout>;
687
+ wake?: () => void;
688
+ }
689
+
690
+ const stopMemorySampler = (state: MemorySamplerState): void => {
691
+ state.active = false;
692
+ state.target = null;
693
+ if (state.timer !== undefined) clearTimeout(state.timer);
694
+ state.timer = undefined;
695
+ const wake = state.wake;
696
+ state.wake = undefined;
697
+ wake?.();
698
+ };
699
+
700
+ const sampleMemory = async (
701
+ getter: () => Promise<NodeJS.MemoryUsage>,
702
+ state: MemorySamplerState,
703
+ ): Promise<void> => {
704
+ try {
705
+ while (state.active) {
706
+ await new Promise<void>((resolve) => {
707
+ state.wake = resolve;
708
+ state.timer = setTimeout(() => {
709
+ state.timer = undefined;
710
+ state.wake = undefined;
711
+ resolve();
712
+ }, 1_000);
713
+ });
714
+ if (!state.active) return;
715
+ const usage = await getter();
716
+ if (!state.active) return;
717
+ state.target!.push({ usage, time: new Date().toISOString() });
718
+ }
719
+ } finally {
720
+ stopMemorySampler(state);
721
+ }
722
+ };
723
+
724
+ // A servant that fails to close cannot change a finished report, and must not
725
+ // hide the error that ended the run, so a failed close is dropped.
726
+ const closeAll = async (servants: Servant[]): Promise<void> => {
727
+ await Promise.allSettled(servants.map((connector) => connector.close()));
728
+ };
729
+
411
730
  interface IFunction<Parameters extends any[]> {
412
731
  key: string;
413
732
  value: (...args: Parameters) => Promise<void>;
@@ -419,12 +738,15 @@ const iterate =
419
738
  driver: Driver<IBenchmarkMaster>;
420
739
  props: DynamicBenchmarker.IServantProps<Parameters>;
421
740
  }) =>
422
- async (path: string): Promise<void> => {
741
+ async (path: string, visited: Set<string> = new Set()): Promise<void> => {
742
+ const identity: string = await fs.promises.realpath(path);
743
+ if (visited.has(identity)) return;
744
+ visited.add(identity);
423
745
  const directory: string[] = await fs.promises.readdir(path);
424
746
  for (const file of directory) {
425
747
  const location: string = `${path}/${file}`;
426
748
  const stat: fs.Stats = await fs.promises.stat(location);
427
- if (stat.isDirectory() === true) await iterate(ctx)(location);
749
+ if (stat.isDirectory() === true) await iterate(ctx)(location, visited);
428
750
  // Load feature files whose extension matches `props.extension`. This is
429
751
  // configurable instead of inferred from the servant's own module
430
752
  // extension, because the benchmark library may be built (`.js`) while the
@@ -432,6 +754,7 @@ const iterate =
432
754
  // inferring would look for `.js` features that do not exist, load
433
755
  // nothing, and spin forever.
434
756
  else if (
757
+ stat.isFile() &&
435
758
  file.endsWith(`.${ctx.props.extension ?? "js"}`) &&
436
759
  file.endsWith(".d.ts") === false
437
760
  ) {
@@ -451,45 +774,3 @@ const iterate =
451
774
  }
452
775
  }
453
776
  };
454
-
455
- const statistics = (
456
- events: IBenchmarkEvent[],
457
- ): DynamicBenchmarker.IReport.IStatistics => {
458
- const successes: IBenchmarkEvent[] = events.filter((event) => event.success);
459
- return {
460
- count: events.length,
461
- success: successes.length,
462
- ...average(events),
463
- };
464
- };
465
-
466
- const average = (
467
- events: IBenchmarkEvent[],
468
- ): Pick<
469
- DynamicBenchmarker.IReport.IStatistics,
470
- "mean" | "stdev" | "minimum" | "maximum"
471
- > => {
472
- if (events.length === 0)
473
- return {
474
- mean: null,
475
- stdev: null,
476
- minimum: null,
477
- maximum: null,
478
- };
479
- let mean: number = 0;
480
- let stdev: number = 0;
481
- let minimum: number = Number.MAX_SAFE_INTEGER;
482
- let maximum: number = Number.MIN_SAFE_INTEGER;
483
- for (const event of events) {
484
- const elapsed: number =
485
- new Date(event.completed_at).getTime() -
486
- new Date(event.started_at).getTime();
487
- mean += elapsed;
488
- stdev += elapsed * elapsed;
489
- minimum = Math.min(minimum, elapsed);
490
- maximum = Math.max(maximum, elapsed);
491
- }
492
- mean /= events.length;
493
- stdev = Math.sqrt(stdev / events.length - mean * mean);
494
- return { mean, stdev, minimum, maximum };
495
- };