@cfour-hi/dsh-plugin-devicex 0.0.0-stage → 1.5.0

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.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Device X action engine — platform dispatcher.
3
+ *
4
+ * Both implementations expose the same surface (`GROUPS`, `actionCatalog`,
5
+ * `actionHistory`, `runAction`) and return the same result union:
6
+ * `{ ok: true, message, data? }` or `{ ok: false, message, code? }`.
7
+ * `device_control` and the browser API call through this module, so a caller
8
+ * never needs to know which platform produced the answer.
9
+ *
10
+ * @module @cfour-hi/dsh-plugin-devicex/host/actions
11
+ */
12
+ import * as darwin from './actions-darwin.js';
13
+ import * as win32 from './actions-win32.js';
14
+
15
+ /** Action groups, in UI order. */
16
+ export const GROUPS = ['process', 'app', 'system', 'network', 'software', 'power'];
17
+
18
+ /** @type {import('../../types/contracts.js').PlatformActions} */
19
+ const impl = process.platform === 'win32' ? win32 : darwin;
20
+
21
+ /**
22
+ * The platform's whitelisted action catalog.
23
+ * @returns {object[]}
24
+ */
25
+ export function actionCatalog() {
26
+ return impl.actionCatalog();
27
+ }
28
+
29
+ /**
30
+ * Recent action attempts, newest first.
31
+ * @returns {object[]}
32
+ */
33
+ export function actionHistory() {
34
+ return impl.actionHistory();
35
+ }
36
+
37
+ /**
38
+ * Validate and run one whitelisted action.
39
+ * @param {string} id
40
+ * @param {Record<string, unknown>} params
41
+ * @returns {Promise<object>}
42
+ */
43
+ export function runAction(id, params) {
44
+ return impl.runAction(id, params);
45
+ }
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Device X — repeatable benchmark engine.
3
+ *
4
+ * Three fixed-work tests, deliberately *not* a "score for the internet": the
5
+ * same amount of work is executed on every run and every machine, so the
6
+ * numbers are stable enough to compare a machine against itself (before/after a
7
+ * driver update, a thermal throttle, a plug/unplug). There is no composite
8
+ * weight, no fake normalization and no network call.
9
+ *
10
+ * - cpu fixed integer-mix work, once on one thread and once across
11
+ * `min(4, cores)` threads (worker_threads, with a single-thread
12
+ * fallback); score = operations / second.
13
+ * - memory fixed copy passes over two 32 MiB Float64Arrays;
14
+ * score = GB/s.
15
+ * - disk fixed 64 MiB write + fsync + read under the OS temp dir;
16
+ * score = MB/s (write/read reported separately, score = mean).
17
+ *
18
+ * Every test is bounded, kills its workers, removes its temp file and never
19
+ * throws: a failure becomes a result entry with an `error` string.
20
+ *
21
+ * @module @cfour-hi/dsh-plugin-devicex/host/benchmark
22
+ */
23
+ import { cpus, hostname, platform, release, tmpdir, totalmem } from 'node:os';
24
+ import { mkdtemp, open, rm, stat } from 'node:fs/promises';
25
+ import { join } from 'node:path';
26
+ import { Worker } from 'node:worker_threads';
27
+
28
+ /** Benchmark ids, in execution order. */
29
+ export const TESTS = ['cpu', 'memory', 'disk'];
30
+
31
+ /** Fixed CPU iterations per leg (tuned for roughly 600-900 ms on Apple silicon). */
32
+ export const CPU_ITERATIONS = 70_000_000;
33
+
34
+ /** Fixed memory copy passes over the two 32 MiB arrays. */
35
+ export const MEMORY_PASSES = 48;
36
+
37
+ /** Fixed disk payload: write + fsync + read. */
38
+ export const DISK_BYTES = 64 * 1024 * 1024;
39
+
40
+ /** Disk IO block size. */
41
+ const DISK_BLOCK = 1024 * 1024;
42
+
43
+ /** Hard ceiling for one multi-thread CPU leg. */
44
+ const WORKER_TIMEOUT_MS = 20_000;
45
+
46
+ /** Runs kept in memory for the "previous run" comparison. */
47
+ const HISTORY_LIMIT = 5;
48
+
49
+ /** Newest-first history of completed runs (module lifetime only). */
50
+ const history = [];
51
+
52
+ /** Milliseconds with sub-ms precision. */
53
+ function now() {
54
+ return performance.now();
55
+ }
56
+
57
+ /**
58
+ * Integer-mix workload. The exact checksum is irrelevant; the fixed iteration
59
+ * count is what makes the timing repeatable.
60
+ * @param {number} iterations
61
+ * @returns {number} last accumulator (for a cheap sanity check)
62
+ */
63
+ function cpuWork(iterations) {
64
+ let a = 0x9e3779b9 | 0;
65
+ let b = 0x85ebca6b | 0;
66
+ let c = iterations | 0;
67
+ for (let i = 0; i < iterations; i += 1) {
68
+ a = (a ^ Math.imul(b, 0x5bd1e995)) | 0;
69
+ b = (b + Math.imul(a ^ (a >>> 15), 0x27d4eb2f)) | 0;
70
+ c = (c ^ Math.imul(a + b, 0xc2b2ae35)) | 0;
71
+ }
72
+ return (a ^ b ^ c) >>> 0;
73
+ }
74
+
75
+ /**
76
+ * Source of the worker-side CPU leg (kept as a string: no worker file needed).
77
+ * The dynamic import keeps the eval worker valid whether the host treats eval
78
+ * workers as CommonJS or as ESM.
79
+ */
80
+ const CPU_WORKER_SOURCE = `
81
+ (async () => {
82
+ const { parentPort, workerData } = await import('node:worker_threads');
83
+ let a = 0x9e3779b9 | 0;
84
+ let b = 0x85ebca6b | 0;
85
+ let c = workerData.iterations | 0;
86
+ for (let i = 0; i < workerData.iterations; i += 1) {
87
+ a = (a ^ Math.imul(b, 0x5bd1e995)) | 0;
88
+ b = (b + Math.imul(a ^ (a >>> 15), 0x27d4eb2f)) | 0;
89
+ c = (c ^ Math.imul(a + b, 0xc2b2ae35)) | 0;
90
+ }
91
+ parentPort.postMessage((a ^ b ^ c) >>> 0);
92
+ })();
93
+ `;
94
+
95
+ /** Default worker factory (injected by the *_test_* hooks for failure tests). */
96
+ function defaultWorkerFactory(iterations) {
97
+ return new Worker(CPU_WORKER_SOURCE, { eval: true, workerData: { iterations } });
98
+ }
99
+
100
+ /**
101
+ * Run `count` worker legs plus the caller's own leg, all in parallel.
102
+ * @param {{threads?:number, workerFactory?:Function}} [options]
103
+ * @returns {Promise<{mode:string, threads:number, iterations:number, durationMs:number, checksum:number}>}
104
+ */
105
+ async function runCpuMulti(options = {}) {
106
+ const threads = Math.max(2, Math.floor(Number(options.threads) || 2));
107
+ const factory = typeof options.workerFactory === 'function' ? options.workerFactory : defaultWorkerFactory;
108
+ const iterations = CPU_ITERATIONS;
109
+ const workers = [];
110
+ let timer;
111
+ const started = now();
112
+ try {
113
+ const legs = [];
114
+ for (let index = 1; index < threads; index += 1) {
115
+ const worker = factory(iterations);
116
+ workers.push(worker);
117
+ legs.push(
118
+ new Promise((resolve, reject) => {
119
+ worker.once('message', resolve);
120
+ worker.once('error', reject);
121
+ worker.once('exit', (code) => {
122
+ if (code !== 0) reject(new Error(`worker exited with code ${String(code)}`));
123
+ });
124
+ }),
125
+ );
126
+ }
127
+ // The main thread is one of the legs too.
128
+ const all = Promise.all(legs).then((values) => [cpuWork(iterations), ...values]);
129
+ const timeout = new Promise((_, reject) => {
130
+ timer = setTimeout(() => reject(new Error('worker timeout')), WORKER_TIMEOUT_MS);
131
+ timer.unref?.();
132
+ });
133
+ const values = await Promise.race([all, timeout]);
134
+ const checksum = values.reduce((acc, value) => (acc ^ Number(value ?? 0)) >>> 0, 0);
135
+ return { mode: 'multi', threads, iterations, durationMs: now() - started, checksum };
136
+ } finally {
137
+ if (timer !== undefined) clearTimeout(timer);
138
+ await Promise.all(workers.map((worker) => worker.terminate().catch(() => undefined)));
139
+ }
140
+ }
141
+
142
+ /**
143
+ * CPU test: one leg single-threaded, then the best multi-core leg.
144
+ * @returns {Promise<object>} result entry
145
+ */
146
+ async function cpuTest(options = {}) {
147
+ const startedAt = now();
148
+ const singleStart = now();
149
+ const singleChecksum = cpuWork(CPU_ITERATIONS);
150
+ const singleMs = now() - singleStart;
151
+ const single = {
152
+ mode: 'single',
153
+ threads: 1,
154
+ iterations: CPU_ITERATIONS,
155
+ durationMs: singleMs,
156
+ score: scoreOf(CPU_ITERATIONS, singleMs),
157
+ checksum: singleChecksum,
158
+ };
159
+ let multi;
160
+ try {
161
+ const threads = Math.max(2, Math.min(4, Math.max(2, cpus().length - 1)));
162
+ const result = await runCpuMulti({ threads, workerFactory: options.workerFactory });
163
+ multi = {
164
+ mode: 'multi',
165
+ threads: result.threads,
166
+ iterations: result.iterations,
167
+ durationMs: result.durationMs,
168
+ score: scoreOf(result.iterations * result.threads, result.durationMs),
169
+ checksum: result.checksum,
170
+ };
171
+ } catch (error) {
172
+ multi = {
173
+ mode: 'single-fallback',
174
+ threads: 1,
175
+ iterations: CPU_ITERATIONS,
176
+ durationMs: singleMs,
177
+ score: single.score,
178
+ error: error instanceof Error ? error.message : String(error),
179
+ };
180
+ }
181
+ // Aggregate: multi-core score is the headline number; keep both legs in details.
182
+ const details = { singleThread: single, multiThread: multi };
183
+ return {
184
+ id: 'cpu',
185
+ durationMs: Math.round(now() - startedAt),
186
+ score: multi?.score ?? single.score,
187
+ unit: 'ops/s',
188
+ details,
189
+ };
190
+ }
191
+
192
+ /** Ops-per-second score with a duration guard. */
193
+ function scoreOf(operations, durationMs) {
194
+ const seconds = Number(durationMs) / 1000;
195
+ if (!Number.isFinite(seconds) || seconds <= 0) return null;
196
+ return Math.round(Number(operations) / seconds);
197
+ }
198
+
199
+ /**
200
+ * Memory bandwidth test: copy between two 32 MiB arrays, fixed pass count.
201
+ * @returns {object} result entry
202
+ */
203
+ function memoryTest() {
204
+ const startedAt = now();
205
+ const elements = (32 * 1024 * 1024) / Float64Array.BYTES_PER_ELEMENT;
206
+ const a = new Float64Array(elements);
207
+ const b = new Float64Array(elements);
208
+ for (let index = 0; index < elements; index += 1) a[index] = index % 97;
209
+ const start = now();
210
+ for (let pass = 0; pass < MEMORY_PASSES; pass += 1) {
211
+ const source = pass % 2 === 0 ? a : b;
212
+ const target = pass % 2 === 0 ? b : a;
213
+ for (let index = 0; index < elements; index += 1) target[index] = source[index] + pass;
214
+ }
215
+ const durationMs = now() - start;
216
+ const bytes = elements * Float64Array.BYTES_PER_ELEMENT * MEMORY_PASSES;
217
+ return {
218
+ id: 'memory',
219
+ durationMs: Math.round(now() - startedAt),
220
+ score: round(Number(bytes) / 1e9 / (durationMs / 1000), 2),
221
+ unit: 'GB/s',
222
+ details: {
223
+ bytes,
224
+ passes: MEMORY_PASSES,
225
+ megabytes: Math.round(bytes / (1024 * 1024)),
226
+ checksum: round(a[0] + b[0], 6),
227
+ },
228
+ };
229
+ }
230
+
231
+ /** Round to `digits`, or null for a non-finite value. */
232
+ function round(value, digits) {
233
+ if (!Number.isFinite(value)) return null;
234
+ const factor = 10 ** digits;
235
+ return Math.round(value * factor) / factor;
236
+ }
237
+
238
+ /**
239
+ * Disk test: one 64 MiB file, serial write with fsync, then read back.
240
+ * @returns {Promise<object>} result entry
241
+ */
242
+ async function diskTest() {
243
+ const startedAt = now();
244
+ const dir = await mkdtemp(join(tmpdir(), 'devicex-bench-'));
245
+ const file = join(dir, 'bench.bin');
246
+ const block = Buffer.alloc(DISK_BLOCK, 0x5a);
247
+ const blocks = DISK_BYTES / DISK_BLOCK;
248
+ try {
249
+ const writeStart = now();
250
+ const handle = await open(file, 'w');
251
+ try {
252
+ for (let index = 0; index < blocks; index += 1) {
253
+ let offset = 0;
254
+ while (offset < block.length) {
255
+ const { bytesWritten } = await handle.write(block, offset, block.length - offset);
256
+ if (!bytesWritten) throw new Error('short write');
257
+ offset += bytesWritten;
258
+ }
259
+ }
260
+ await handle.sync();
261
+ } finally {
262
+ await handle.close();
263
+ }
264
+ const writeMs = now() - writeStart;
265
+
266
+ const readStart = now();
267
+ const readHandle = await open(file, 'r');
268
+ const buffer = Buffer.alloc(DISK_BLOCK);
269
+ let readBytes = 0;
270
+ try {
271
+ let position = 0;
272
+ for (;;) {
273
+ const { bytesRead } = await readHandle.read(buffer, 0, buffer.length, position);
274
+ if (bytesRead === 0) break;
275
+ position += bytesRead;
276
+ readBytes += bytesRead;
277
+ }
278
+ } finally {
279
+ await readHandle.close();
280
+ }
281
+ const readMs = now() - readStart;
282
+ const size = (await stat(file)).size;
283
+ if (size !== DISK_BYTES || readBytes !== DISK_BYTES) {
284
+ throw new Error(`size mismatch: ${String(size)} / ${String(readBytes)}`);
285
+ }
286
+ const writeMBps = round(DISK_BYTES / (1024 * 1024) / (writeMs / 1000), 1);
287
+ const readMBps = round(DISK_BYTES / (1024 * 1024) / (readMs / 1000), 1);
288
+ return {
289
+ id: 'disk',
290
+ durationMs: Math.round(now() - startedAt),
291
+ score: round(((writeMBps ?? 0) + (readMBps ?? 0)) / 2, 1),
292
+ unit: 'MB/s',
293
+ details: {
294
+ bytes: DISK_BYTES,
295
+ writeMBps,
296
+ readMBps,
297
+ writeMs: Math.round(writeMs),
298
+ readMs: Math.round(readMs),
299
+ tmpFile: file,
300
+ },
301
+ };
302
+ } finally {
303
+ await rm(dir, { recursive: true, force: true }).catch(() => undefined);
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Actually run one test by id; failures are contained per test.
309
+ * @param {string} id
310
+ * @returns {Promise<object>}
311
+ */
312
+ async function runTest(id) {
313
+ try {
314
+ if (id === 'cpu') return await cpuTest();
315
+ if (id === 'memory') return memoryTest();
316
+ if (id === 'disk') return await diskTest();
317
+ return { id, durationMs: 0, score: null, unit: '', details: {}, error: `unknown test: ${String(id)}` };
318
+ } catch (error) {
319
+ const message = error instanceof Error ? error.message : String(error);
320
+ return { id, durationMs: 0, score: null, unit: id === 'memory' ? 'GB/s' : id === 'disk' ? 'MB/s' : 'ops/s', details: {}, error: message };
321
+ }
322
+ }
323
+
324
+ /** Collect the machine facts every run records. */
325
+ function environment() {
326
+ const logicalCores = Math.max(1, cpus().length);
327
+ return {
328
+ hostname: hostname(),
329
+ cpuModel: cpus()[0]?.model ?? null,
330
+ logicalCores,
331
+ physicalCores: null,
332
+ memoryBytes: totalmem(),
333
+ osVersion: `${platform()} ${release()}`,
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Execute the benchmark suite.
339
+ *
340
+ * @param {{tests?: string[], repeat?: boolean}} [options] tests subset to run,
341
+ * repeat keeps the better of two runs per test.
342
+ * @returns {Promise<{ok:true, benchmark:object}>}
343
+ */
344
+ export async function runBenchmark(options = {}) {
345
+ const requestedTests = Array.isArray(options.tests) ? options.tests : [];
346
+ const requested = requestedTests.length > 0
347
+ ? TESTS.filter((id) => requestedTests.includes(id))
348
+ : TESTS;
349
+ const repeat = options.repeat === true;
350
+ const startedAt = Date.now();
351
+ const start = now();
352
+ const previousRun = history[0];
353
+ const previous = previousRun
354
+ ? { at: previousRun.startedAt, results: previousRun.results.map((entry) => ({ id: entry.id, score: entry.score, unit: entry.unit })) }
355
+ : null;
356
+ const results = [];
357
+ for (const id of requested.length > 0 ? requested : TESTS) {
358
+ if (!repeat) {
359
+ results.push(await runTest(id));
360
+ continue;
361
+ }
362
+ // Keep the better (higher score) of two runs; record that in details.
363
+ const first = await runTest(id);
364
+ const second = await runTest(id);
365
+ const better = pickBetter(first, second);
366
+ better.details = { ...(better.details ?? {}), bestOf: 2 };
367
+ results.push(better);
368
+ }
369
+ const run = {
370
+ platform: process.platform,
371
+ startedAt,
372
+ durationMs: Math.round(now() - start),
373
+ environment: environment(),
374
+ results,
375
+ };
376
+ history.unshift(run);
377
+ if (history.length > HISTORY_LIMIT) history.length = HISTORY_LIMIT;
378
+ return { ok: true, benchmark: { ...run, previous } };
379
+ }
380
+
381
+ /** Higher numeric score wins; errors lose. */
382
+ function pickBetter(first, second) {
383
+ const firstScore = typeof first?.score === 'number' ? first.score : -Infinity;
384
+ const secondScore = typeof second?.score === 'number' ? second.score : -Infinity;
385
+ return secondScore > firstScore ? second : first;
386
+ }
387
+
388
+ /**
389
+ * Recent runs, newest first (the same shape as `benchmark` minus `previous`).
390
+ * @returns {object[]}
391
+ */
392
+ export function benchmarkHistory() {
393
+ return history.map((run) => JSON.parse(JSON.stringify(run)));
394
+ }
395
+
396
+ /** Test hooks (pure helpers + injectable worker factory). */
397
+ export const __test = {
398
+ cpuWork,
399
+ cpuTest,
400
+ memoryTest,
401
+ diskTest,
402
+ runCpuMulti,
403
+ scoreOf,
404
+ pickBetter,
405
+ environment,
406
+ resetHistory() {
407
+ history.length = 0;
408
+ },
409
+ historyLimit: HISTORY_LIMIT,
410
+ CPU_ITERATIONS,
411
+ MEMORY_PASSES,
412
+ DISK_BYTES,
413
+ };