@camstack/types 1.2.115 → 1.2.117
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.
- package/dist/addon.js +4 -3
- package/dist/addon.mjs +4 -3
- package/dist/capabilities/data-store-provider.cap.d.ts +11 -0
- package/dist/capabilities/index.d.ts +6 -3
- package/dist/capabilities/load-contribution.cap.d.ts +146 -0
- package/dist/capabilities/metrics-provider.cap.d.ts +156 -38
- package/dist/capabilities/settings-store.cap.d.ts +48 -1
- package/dist/capabilities/system.cap.d.ts +67 -1
- package/dist/enums/event-category.d.ts +1 -1
- package/dist/enums.js +1 -1
- package/dist/enums.mjs +1 -1
- package/dist/{event-category-CIa_iT6b.mjs → event-category-BZL-fdNj.mjs} +1 -1
- package/dist/{event-category-EY0GNjV9.js → event-category-BaEgqJNv.js} +1 -1
- package/dist/generated/addon-api.d.ts +40 -3
- package/dist/generated/capability-router-map.d.ts +5 -2
- package/dist/generated/collection-array-methods.d.ts +1 -1
- package/dist/generated/method-access-map.d.ts +1 -1
- package/dist/generated/system-proxy.d.ts +3 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +585 -36
- package/dist/index.mjs +575 -37
- package/dist/interfaces/addon.d.ts +28 -0
- package/dist/interfaces/event-bus.d.ts +1 -1
- package/dist/interfaces/metrics-provider.d.ts +5 -3
- package/dist/metrics/load-series-fold.d.ts +142 -0
- package/dist/node.d.ts +2 -0
- package/dist/node.js +191 -0
- package/dist/node.mjs +186 -1
- package/dist/process/child-cost-registry.d.ts +105 -0
- package/dist/{sleep-CSodb2vQ.js → sleep-9d8tJRbO.js} +1 -1
- package/dist/{sleep-CdbM8ge4.mjs → sleep-Dolp38qx.mjs} +1 -1
- package/package.json +1 -1
|
@@ -1178,6 +1178,34 @@ export interface AddonExecution {
|
|
|
1178
1178
|
* `0` means "no ceiling for this addon" and, being the maximum, lifts the whole runner's cap.
|
|
1179
1179
|
*/
|
|
1180
1180
|
readonly maxOldSpaceMb?: number;
|
|
1181
|
+
/**
|
|
1182
|
+
* Whole-process RSS budget in MB — the number the memory watchdog alarms on.
|
|
1183
|
+
*
|
|
1184
|
+
* This is NOT a second heap ceiling and it enforces nothing: it is the
|
|
1185
|
+
* footprint this addon's role considers legitimate, and crossing it emits one
|
|
1186
|
+
* WARN carrying the breakdown (`heapUsed` / `external` / native residue) so the
|
|
1187
|
+
* operator learns WHICH KIND of growth it is.
|
|
1188
|
+
*
|
|
1189
|
+
* It exists because every other bound is blind to most of a process.
|
|
1190
|
+
* {@link AddonExecution.maxOldSpaceMb} bounds V8's old space and nothing else,
|
|
1191
|
+
* and the watchdog's `stranded` metric subtracts `external` by construction.
|
|
1192
|
+
* `hub/pipeline-analytics` was OOM-killed at 11.4GB on 2026-08-27 with a
|
|
1193
|
+
* 1024MB old-space ceiling in force the entire time, holding a 536MB heap and
|
|
1194
|
+
* 4264MB of `external` — 96% of the growth was never V8's, and nothing warned.
|
|
1195
|
+
* RSS is what the host's OOM killer reads, so RSS is what needs a declared
|
|
1196
|
+
* ceiling.
|
|
1197
|
+
*
|
|
1198
|
+
* OMITTED (the common case) → this addon declares no budget. If NO co-located
|
|
1199
|
+
* addon declares one, the runner reports itself UNWATCHED once at boot; there
|
|
1200
|
+
* is deliberately no class default, because the legitimate footprint differs by
|
|
1201
|
+
* an order of magnitude by role (a recorder holding a 1.5GB segment index is
|
|
1202
|
+
* behaving correctly; analytics holding 1.5GB is not). Declare it from a
|
|
1203
|
+
* MEASURED peak RSS, never from a heap number.
|
|
1204
|
+
*
|
|
1205
|
+
* A runner's budget is the MAXIMUM declared by any co-located addon —
|
|
1206
|
+
* co-location must never silence the hungriest member (D2/D29).
|
|
1207
|
+
*/
|
|
1208
|
+
readonly rssBudgetMb?: number;
|
|
1181
1209
|
/**
|
|
1182
1210
|
* `@camstack/system` builtin ONLY: run this builtin in its OWN forked runner
|
|
1183
1211
|
* instead of in-process on the hub root.
|
|
@@ -1172,7 +1172,7 @@ export interface EventCatalog {
|
|
|
1172
1172
|
};
|
|
1173
1173
|
/**
|
|
1174
1174
|
* Periodic per-node process-tree snapshot (`NodeProcess[]` —
|
|
1175
|
-
* camstack-related pids with
|
|
1175
|
+
* camstack-related pids with root / managed / system classification).
|
|
1176
1176
|
* One event per node per tick. Replaces polling on
|
|
1177
1177
|
* `metricsProvider.listNodeProcesses`.
|
|
1178
1178
|
*/
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { z } from 'zod';
|
|
2
|
-
import type { CpuBreakdownSchema, MemoryInfoSchema, DiskIoSnapshotSchema, NetworkIoSnapshotSchema, MetricsGpuInfoSchema, ProcessResourceInfoSchema, PressureInfoSchema, SystemResourceSnapshotSchema, DiskSpaceInfoSchema, PidResourceStatsSchema, AddonInstanceSchema, NodeProcessSchema,
|
|
2
|
+
import type { CpuBreakdownSchema, MemoryInfoSchema, DiskIoSnapshotSchema, NetworkIoSnapshotSchema, MetricsGpuInfoSchema, ProcessResourceInfoSchema, PressureInfoSchema, SystemResourceSnapshotSchema, DiskSpaceInfoSchema, PidResourceStatsSchema, AddonInstanceSchema, NodeProcessSchema, DumpHeapSnapshotInputSchema, DumpHeapSnapshotResultSchema, LoadPointSchema, LoadFunctionSeriesSchema, NodeLoadSeriesSchema, GetLoadSeriesInputSchema } from '../capabilities/metrics-provider.cap.js';
|
|
3
3
|
export type CpuBreakdown = z.infer<typeof CpuBreakdownSchema>;
|
|
4
4
|
export type MemoryInfo = z.infer<typeof MemoryInfoSchema>;
|
|
5
5
|
export type DiskIoSnapshot = z.infer<typeof DiskIoSnapshotSchema>;
|
|
@@ -12,7 +12,9 @@ export type DiskSpaceInfo = z.infer<typeof DiskSpaceInfoSchema>;
|
|
|
12
12
|
export type PidResourceStats = z.infer<typeof PidResourceStatsSchema>;
|
|
13
13
|
export type AddonInstance = z.infer<typeof AddonInstanceSchema>;
|
|
14
14
|
export type NodeProcess = z.infer<typeof NodeProcessSchema>;
|
|
15
|
-
export type KillProcessInput = z.infer<typeof KillProcessInputSchema>;
|
|
16
|
-
export type KillProcessResult = z.infer<typeof KillProcessResultSchema>;
|
|
17
15
|
export type DumpHeapSnapshotInput = z.infer<typeof DumpHeapSnapshotInputSchema>;
|
|
18
16
|
export type DumpHeapSnapshotResult = z.infer<typeof DumpHeapSnapshotResultSchema>;
|
|
17
|
+
export type LoadSeriesPoint = z.infer<typeof LoadPointSchema>;
|
|
18
|
+
export type LoadFunctionSeries = z.infer<typeof LoadFunctionSeriesSchema>;
|
|
19
|
+
export type NodeLoadSeries = z.infer<typeof NodeLoadSeriesSchema>;
|
|
20
|
+
export type GetLoadSeriesInput = z.infer<typeof GetLoadSeriesInputSchema>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folding a process snapshot into per-FUNCTION load points, and reducing a
|
|
3
|
+
* long series to a drawable number of them WITHOUT losing a peak.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this lives in `@camstack/types`
|
|
6
|
+
*
|
|
7
|
+
* Two processes need the identical arithmetic and neither may import the
|
|
8
|
+
* other: the hub's `native-metrics` folds the retained series before it leaves
|
|
9
|
+
* the node (server-side reduction — the browser must never be handed 2 160
|
|
10
|
+
* points per series to throw away), and the admin UI folds the LIVE
|
|
11
|
+
* `metrics.node-processes-snapshot` events it is already subscribed to. Two
|
|
12
|
+
* copies of a fold is two charts that disagree about the same node.
|
|
13
|
+
*
|
|
14
|
+
* Browser-safe by construction: no imports at all, so it stays on the root
|
|
15
|
+
* `@camstack/types` entry (the node-only surface is `@camstack/types/node`).
|
|
16
|
+
*
|
|
17
|
+
* ## The reduction, and why min/max rather than a mean
|
|
18
|
+
*
|
|
19
|
+
* Six hours at the 10 s cadence is 2 160 points per series. A chart a few
|
|
20
|
+
* hundred pixels wide has to reduce them, and the reduction is exactly where a
|
|
21
|
+
* chart lies:
|
|
22
|
+
*
|
|
23
|
+
* - a **mean per bucket** smears the peak across the bucket and it disappears;
|
|
24
|
+
* - **one sample every N** skips the peak outright.
|
|
25
|
+
*
|
|
26
|
+
* Either way the tool built to find peaks stops showing peaks. So a bucket
|
|
27
|
+
* carries `min`, `max` and the number of samples that landed in it, and the
|
|
28
|
+
* REPRESENTATIVE value of a bucket — the field the chart already reads — is
|
|
29
|
+
* the **max**. A mean is still derivable where it is wanted; a lost peak is
|
|
30
|
+
* not recoverable from anything.
|
|
31
|
+
*
|
|
32
|
+
* An unreduced series is a series of one-sample buckets, where `min === max`
|
|
33
|
+
* and every field equals the raw value. Reduced and unreduced are therefore
|
|
34
|
+
* the same shape, and the caller cannot tell which it is looking at — which is
|
|
35
|
+
* the whole reason there is one reader.
|
|
36
|
+
*
|
|
37
|
+
* ## An empty bucket stays empty
|
|
38
|
+
*
|
|
39
|
+
* A bucket with no samples is ABSENT from the output. It is never emitted with
|
|
40
|
+
* zeros, never interpolated, and never carried forward from the previous
|
|
41
|
+
* point. Emission is fixed-cadence, so a gap means one thing only: **nobody
|
|
42
|
+
* reported**. Filling it would destroy the single property that fixed cadence
|
|
43
|
+
* was adopted to buy.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* One process row as both tiers carry it.
|
|
47
|
+
*
|
|
48
|
+
* Deliberately NOT the full `NodeProcess`. `command` is absent because it is
|
|
49
|
+
* the fattest field in a snapshot and the same identical string on every
|
|
50
|
+
* runner — the runner id travels in the environment, not in argv — while
|
|
51
|
+
* `addonId` is the short identifier that already names the process. Nothing
|
|
52
|
+
* that draws or reduces this series has ever read `command`.
|
|
53
|
+
*/
|
|
54
|
+
export interface LoadProcessRow {
|
|
55
|
+
readonly pid: number;
|
|
56
|
+
readonly addonId: string | null;
|
|
57
|
+
readonly classification: string;
|
|
58
|
+
/** `ps pcpu` — a LIFETIME average, not a rate. Always present. */
|
|
59
|
+
readonly cpuPercent: number;
|
|
60
|
+
readonly memoryRssBytes: number;
|
|
61
|
+
/** Instantaneous main-thread CPU%, or `null` when unknown — never zero. */
|
|
62
|
+
readonly cpuMainPercent: number | null;
|
|
63
|
+
/** Instantaneous V8-helper-pool CPU%, or `null` under the same rule. */
|
|
64
|
+
readonly cpuGcPercent: number | null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The bucket a process is attributed to.
|
|
68
|
+
*
|
|
69
|
+
* `addon` is the only attributed kind. `root` is the node's own supervisor and
|
|
70
|
+
* `unattributed` is everything the cluster cannot name. They are separate
|
|
71
|
+
* buckets rather than folded into a nearby addon precisely because nothing
|
|
72
|
+
* licenses attributing them.
|
|
73
|
+
*/
|
|
74
|
+
export type LoadBucketKind = 'addon' | 'root' | 'unattributed';
|
|
75
|
+
/** Bucket key for rows no addon owns. Stable, so its series is continuous. */
|
|
76
|
+
export declare const UNATTRIBUTED_BUCKET_KEY = "__unattributed__";
|
|
77
|
+
export declare const ROOT_BUCKET_KEY = "__root__";
|
|
78
|
+
/**
|
|
79
|
+
* One point of one function's series.
|
|
80
|
+
*
|
|
81
|
+
* The unsuffixed fields are the bucket's **maximum** — the peak-preserving
|
|
82
|
+
* representative. `...Min` carries the other end so a caller can draw a band,
|
|
83
|
+
* and `samples` says how many raw snapshots the bucket folded. For an
|
|
84
|
+
* unreduced series `samples === 1` and each `...Min` equals its unsuffixed
|
|
85
|
+
* twin.
|
|
86
|
+
*/
|
|
87
|
+
export interface LoadPoint {
|
|
88
|
+
/** Bucket START, or the snapshot's own timestamp when unreduced. */
|
|
89
|
+
readonly atMs: number;
|
|
90
|
+
/** Raw snapshots folded into this point. Never 0 — an empty bucket is absent. */
|
|
91
|
+
readonly samples: number;
|
|
92
|
+
/**
|
|
93
|
+
* Instantaneous CPU% of the function's own threads, or `null`.
|
|
94
|
+
*
|
|
95
|
+
* `null` PROPAGATES: a bucket is `null` unless every process in every
|
|
96
|
+
* snapshot it covers reported a split. Summing only the processes that
|
|
97
|
+
* answered produces a smaller number that is indistinguishable, on a chart,
|
|
98
|
+
* from a complete one — the exact failure this surface exists to prevent.
|
|
99
|
+
*/
|
|
100
|
+
readonly cpuMainPercent: number | null;
|
|
101
|
+
readonly cpuMainPercentMin: number | null;
|
|
102
|
+
readonly cpuGcPercent: number | null;
|
|
103
|
+
readonly cpuGcPercentMin: number | null;
|
|
104
|
+
/** Lifetime-average CPU%, summed. Always known — and never a rate. */
|
|
105
|
+
readonly cpuLifetimePercent: number;
|
|
106
|
+
readonly cpuLifetimePercentMin: number;
|
|
107
|
+
readonly memoryRssBytes: number;
|
|
108
|
+
readonly memoryRssBytesMin: number;
|
|
109
|
+
readonly processCount: number;
|
|
110
|
+
readonly processCountMin: number;
|
|
111
|
+
}
|
|
112
|
+
export interface LoadBucket {
|
|
113
|
+
readonly key: string;
|
|
114
|
+
readonly kind: LoadBucketKind;
|
|
115
|
+
readonly point: LoadPoint;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Fold one snapshot into one point per function.
|
|
119
|
+
*
|
|
120
|
+
* Produces a ONE-SAMPLE point: `min === max` on every field, `samples === 1`.
|
|
121
|
+
* That is what lets a live event and a reduced server bucket sit in the same
|
|
122
|
+
* series without the consumer knowing which is which.
|
|
123
|
+
*/
|
|
124
|
+
export declare function foldSnapshotByFunction(rows: readonly LoadProcessRow[], atMs: number): readonly LoadBucket[];
|
|
125
|
+
/**
|
|
126
|
+
* The bucket width that brings `spanMs` down to at most `maxPoints` points,
|
|
127
|
+
* snapped up to a whole multiple of the sampling cadence.
|
|
128
|
+
*
|
|
129
|
+
* Returns `cadenceMs` (no reduction) when the span already fits. A caller that
|
|
130
|
+
* asks for a `maxPoints` of 0 or less gets no reduction rather than an
|
|
131
|
+
* infinite bucket — a nonsensical request must not produce a plausible chart.
|
|
132
|
+
*/
|
|
133
|
+
export declare function resolveBucketMs(spanMs: number, cadenceMs: number, maxPoints: number): number;
|
|
134
|
+
/**
|
|
135
|
+
* Reduce one function's points into buckets of `bucketMs`, preserving the
|
|
136
|
+
* extremes.
|
|
137
|
+
*
|
|
138
|
+
* Points are expected oldest-first and are returned oldest-first. A bucket
|
|
139
|
+
* with no samples is ABSENT — not zero, not interpolated, not the previous
|
|
140
|
+
* value held over.
|
|
141
|
+
*/
|
|
142
|
+
export declare function reducePoints(points: readonly LoadPoint[], bucketMs: number, origin: number): readonly LoadPoint[];
|
package/dist/node.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ export { ensureBinary, downloadBinary, findInPath, getPlatformInfo, buildBinaryP
|
|
|
2
2
|
export type { PlatformInfo } from './deps/binary-downloader.js';
|
|
3
3
|
export { ensureFfmpeg, getFfmpegDownloadUrl } from './deps/ffmpeg-downloader.js';
|
|
4
4
|
export { ensurePython, installPythonPackages, installPythonRequirements, getPythonDownloadUrl, PYTHON_VERSION, } from './deps/python-downloader.js';
|
|
5
|
+
export { ChildCostRegistry, NO_COST_CLAIM, nodeProcStatReader, parseProcCpuSeconds, parseProcRssBytes, readProcessCost, } from './process/child-cost-registry.js';
|
|
6
|
+
export type { ChildCostClaimHandle, ChildCostClaimInput, ProcStatReader, } from './process/child-cost-registry.js';
|
|
5
7
|
export { FilesystemStorageProvider } from './storage/filesystem-storage-provider.js';
|
|
6
8
|
export { canonicalHash } from './utils/canonical-hash.js';
|
|
7
9
|
export { signExpiringUrl, verifyExpiringUrl } from './utils/expiring-url-signature.js';
|
package/dist/node.js
CHANGED
|
@@ -31,6 +31,7 @@ node_path = __toESM(node_path);
|
|
|
31
31
|
let node_stream_promises = require("node:stream/promises");
|
|
32
32
|
let node_stream = require("node:stream");
|
|
33
33
|
let node_child_process = require("node:child_process");
|
|
34
|
+
let node_fs_promises = require("node:fs/promises");
|
|
34
35
|
//#region src/deps/binary-downloader.ts
|
|
35
36
|
/**
|
|
36
37
|
* Recursively find the first file named exactly `name` under `dir`. Used as the
|
|
@@ -457,6 +458,190 @@ async function installPythonRequirements(pythonPath, requirementsFile, logger) {
|
|
|
457
458
|
} });
|
|
458
459
|
}
|
|
459
460
|
//#endregion
|
|
461
|
+
//#region src/process/child-cost-registry.ts
|
|
462
|
+
/**
|
|
463
|
+
* The registry an addon claims its OWN child processes in — the spawn-site
|
|
464
|
+
* half of `load-contribution.cap.ts`.
|
|
465
|
+
*
|
|
466
|
+
* ## Where a claim is made, and where it is released
|
|
467
|
+
*
|
|
468
|
+
* At the spawn site, and nowhere else. It is the only place that knows both
|
|
469
|
+
* halves: the recorder's controller knows it is starting ffmpeg for camera 615
|
|
470
|
+
* profile `high`; the decode coordinator knows the session it is forking a
|
|
471
|
+
* worker for. Nothing has to deduce it afterwards, and no entity needs a
|
|
472
|
+
* global list of which pid belongs to which camera.
|
|
473
|
+
*
|
|
474
|
+
* {@link ChildCostRegistry.claim} returns a HANDLE, and the handle is the only
|
|
475
|
+
* way to release. That is not decoration: the alternative — `release(pid)` —
|
|
476
|
+
* lets a late release from a dead generation delete the live claim of a
|
|
477
|
+
* process that inherited the same pid, which is precisely how a per-camera
|
|
478
|
+
* chart charges one camera for another camera's work. A handle can only ever
|
|
479
|
+
* remove the entry it created.
|
|
480
|
+
*
|
|
481
|
+
* ## What the registry does NOT do
|
|
482
|
+
*
|
|
483
|
+
* It keeps no history, no counters and no timers. It holds live claims and
|
|
484
|
+
* answers questions about them; when the process holding it dies, every claim
|
|
485
|
+
* in it dies with it, which is correct — those children were its children.
|
|
486
|
+
*
|
|
487
|
+
* ## The numbers
|
|
488
|
+
*
|
|
489
|
+
* {@link ChildCostRegistry.contributions} reads each claimed child's
|
|
490
|
+
* CUMULATIVE CPU seconds and resident bytes out of `/proc/<pid>` at the moment
|
|
491
|
+
* it is asked. On demand, never on a timer: a new periodic per-node sampler is
|
|
492
|
+
* the defect half of `docs/architecture/load-ledger.md` documents. A counter
|
|
493
|
+
* can be differenced by whoever already keeps a history; a rate cannot be
|
|
494
|
+
* un-averaged.
|
|
495
|
+
*
|
|
496
|
+
* Where `/proc` does not exist (macOS, Windows) or the read fails, the numbers
|
|
497
|
+
* are ABSENT — never zero. Zero would say the camera cost nothing.
|
|
498
|
+
*/
|
|
499
|
+
/**
|
|
500
|
+
* Kernel jiffies per second (`USER_HZ`). Same constant, same reasoning, as
|
|
501
|
+
* `packages/system/src/builtins/native-metrics/thread-cpu-sampler.ts`:
|
|
502
|
+
* `sysconf(_SC_CLK_TCK)` is not exposed to Node and this has been 100 on every
|
|
503
|
+
* kernel configuration we ship to. A wrong value would scale every CPU number
|
|
504
|
+
* by a constant — visible immediately, not a silent skew.
|
|
505
|
+
*/
|
|
506
|
+
var CLOCK_TICKS_PER_SEC = 100;
|
|
507
|
+
/** Bytes per page, for `/proc/<pid>/statm`'s page counts. */
|
|
508
|
+
var PAGE_BYTES = 4096;
|
|
509
|
+
/**
|
|
510
|
+
* The handle a spawn site gets when nobody is collecting — a test harness, or
|
|
511
|
+
* a runner built before its addon registered a registry. Reporting nothing is
|
|
512
|
+
* the correct behaviour: the process still appears in the node's process
|
|
513
|
+
* snapshot, unclaimed, which is exactly what "nobody reported this" should
|
|
514
|
+
* look like.
|
|
515
|
+
*/
|
|
516
|
+
var NO_COST_CLAIM = { release: () => void 0 };
|
|
517
|
+
var nodeProcStatReader = {
|
|
518
|
+
readStat: (pid) => (0, node_fs_promises.readFile)(`/proc/${pid}/stat`, "utf8"),
|
|
519
|
+
readStatm: (pid) => (0, node_fs_promises.readFile)(`/proc/${pid}/statm`, "utf8")
|
|
520
|
+
};
|
|
521
|
+
/**
|
|
522
|
+
* Cumulative CPU seconds from a `/proc/<pid>/stat` line.
|
|
523
|
+
*
|
|
524
|
+
* The `comm` field is field 2, wrapped in parentheses, and can itself contain
|
|
525
|
+
* spaces and parentheses — so the only correct parse cuts at the LAST `)` and
|
|
526
|
+
* indexes from there. After the cut, field 3 (`state`) is index 0, so `utime`
|
|
527
|
+
* (field 14) is index 11 and `stime` (field 15) is index 12.
|
|
528
|
+
*
|
|
529
|
+
* `null` for a line that does not parse: a process that exited between the
|
|
530
|
+
* claim and the read leaves a truncated or empty file, and that is normal.
|
|
531
|
+
*/
|
|
532
|
+
function parseProcCpuSeconds(line) {
|
|
533
|
+
const close = line.lastIndexOf(")");
|
|
534
|
+
if (close < 0) return null;
|
|
535
|
+
const rest = line.slice(close + 1).trim().split(/\s+/);
|
|
536
|
+
const utime = Number(rest[11]);
|
|
537
|
+
const stime = Number(rest[12]);
|
|
538
|
+
if (!Number.isFinite(utime) || !Number.isFinite(stime)) return null;
|
|
539
|
+
return (utime + stime) / CLOCK_TICKS_PER_SEC;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Resident bytes from a `/proc/<pid>/statm` line — field 2 is the resident set
|
|
543
|
+
* in pages. `null` when the line does not parse.
|
|
544
|
+
*/
|
|
545
|
+
function parseProcRssBytes(line) {
|
|
546
|
+
const fields = line.trim().split(/\s+/);
|
|
547
|
+
const residentPages = Number(fields[1]);
|
|
548
|
+
if (!Number.isFinite(residentPages)) return null;
|
|
549
|
+
return residentPages * PAGE_BYTES;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* What the OS will say about one process right now: cumulative CPU seconds and
|
|
553
|
+
* resident bytes, each ABSENT when it cannot be read.
|
|
554
|
+
*
|
|
555
|
+
* Exported because not every cost has a spawn site inside a registry. The
|
|
556
|
+
* shared inference pool is the case that forced it: its process is created
|
|
557
|
+
* deep inside the engine factory and belongs to no camera, so it is reported
|
|
558
|
+
* as an `unattributable` contribution built from the live pool's pids rather
|
|
559
|
+
* than from a claim — but its numbers must be read the same way, by the same
|
|
560
|
+
* parsers, or two "CPU seconds" in one payload would mean two things.
|
|
561
|
+
*/
|
|
562
|
+
async function readProcessCost(pid, reader = nodeProcStatReader) {
|
|
563
|
+
let cpuSeconds = null;
|
|
564
|
+
let rssBytes = null;
|
|
565
|
+
try {
|
|
566
|
+
cpuSeconds = parseProcCpuSeconds(await reader.readStat(pid));
|
|
567
|
+
} catch {
|
|
568
|
+
cpuSeconds = null;
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
rssBytes = parseProcRssBytes(await reader.readStatm(pid));
|
|
572
|
+
} catch {
|
|
573
|
+
rssBytes = null;
|
|
574
|
+
}
|
|
575
|
+
return {
|
|
576
|
+
...cpuSeconds === null ? {} : { cpuSeconds },
|
|
577
|
+
...rssBytes === null ? {} : { rssBytes }
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
var ChildCostRegistry = class {
|
|
581
|
+
reader;
|
|
582
|
+
now;
|
|
583
|
+
claims = /* @__PURE__ */ new Map();
|
|
584
|
+
constructor(reader = nodeProcStatReader, now = Date.now) {
|
|
585
|
+
this.reader = reader;
|
|
586
|
+
this.now = now;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Record one child. The returned handle is the only way to remove it.
|
|
590
|
+
*
|
|
591
|
+
* A claim with no pid is dropped rather than stored: it could carry no
|
|
592
|
+
* measurement, and an entry with a camera and no numbers reads on a chart as
|
|
593
|
+
* a camera that cost nothing.
|
|
594
|
+
*/
|
|
595
|
+
claim(input) {
|
|
596
|
+
const pid = input.pid;
|
|
597
|
+
if (pid === void 0 || !Number.isInteger(pid) || pid <= 0) return NO_COST_CLAIM;
|
|
598
|
+
const key = Symbol("child-cost-claim");
|
|
599
|
+
this.claims.set(key, {
|
|
600
|
+
role: input.role,
|
|
601
|
+
deviceId: input.deviceId,
|
|
602
|
+
unit: input.unit,
|
|
603
|
+
attribution: input.attribution ?? "measured",
|
|
604
|
+
pid,
|
|
605
|
+
startedAtMs: this.now()
|
|
606
|
+
});
|
|
607
|
+
return { release: () => {
|
|
608
|
+
this.claims.delete(key);
|
|
609
|
+
} };
|
|
610
|
+
}
|
|
611
|
+
/** Live claims, in claim order. Diagnostics and tests; not a contribution. */
|
|
612
|
+
list() {
|
|
613
|
+
return [...this.claims.values()].map((c) => ({
|
|
614
|
+
role: c.role,
|
|
615
|
+
deviceId: c.deviceId,
|
|
616
|
+
unit: c.unit,
|
|
617
|
+
attribution: c.attribution,
|
|
618
|
+
pid: c.pid
|
|
619
|
+
}));
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* This addon's contribution: one entry per live claim, with whatever the OS
|
|
623
|
+
* will tell us about that child right now.
|
|
624
|
+
*
|
|
625
|
+
* A child that has exited between the claim and this read contributes an
|
|
626
|
+
* entry with no numbers rather than no entry — the unit exists, the addon
|
|
627
|
+
* believes it is running, and hiding it would make a dying writer look like
|
|
628
|
+
* a writer that was never started.
|
|
629
|
+
*/
|
|
630
|
+
async contributions() {
|
|
631
|
+
const out = [];
|
|
632
|
+
for (const claim of this.claims.values()) out.push({
|
|
633
|
+
role: claim.role,
|
|
634
|
+
deviceId: claim.deviceId,
|
|
635
|
+
attribution: claim.attribution,
|
|
636
|
+
unit: claim.unit,
|
|
637
|
+
pid: claim.pid,
|
|
638
|
+
startedAtMs: claim.startedAtMs,
|
|
639
|
+
...await readProcessCost(claim.pid, this.reader)
|
|
640
|
+
});
|
|
641
|
+
return out;
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
//#endregion
|
|
460
645
|
//#region src/storage/filesystem-storage-provider.ts
|
|
461
646
|
var STORAGE_LOCATION_TYPES = [
|
|
462
647
|
"data",
|
|
@@ -1481,10 +1666,12 @@ var Fmp4FragmentChild = class {
|
|
|
1481
1666
|
}
|
|
1482
1667
|
};
|
|
1483
1668
|
//#endregion
|
|
1669
|
+
exports.ChildCostRegistry = ChildCostRegistry;
|
|
1484
1670
|
exports.FfmpegProcess = FfmpegProcess;
|
|
1485
1671
|
exports.FilesystemStorageProvider = FilesystemStorageProvider;
|
|
1486
1672
|
exports.Fmp4FragmentChild = Fmp4FragmentChild;
|
|
1487
1673
|
exports.Fmp4FragmentPlane = Fmp4FragmentPlane;
|
|
1674
|
+
exports.NO_COST_CLAIM = NO_COST_CLAIM;
|
|
1488
1675
|
exports.PYTHON_VERSION = PYTHON_VERSION;
|
|
1489
1676
|
exports.buildBinaryPath = buildBinaryPath;
|
|
1490
1677
|
exports.canonicalDeviceFingerprint = canonicalDeviceFingerprint;
|
|
@@ -1500,6 +1687,10 @@ exports.getPlatformInfo = getPlatformInfo;
|
|
|
1500
1687
|
exports.getPythonDownloadUrl = getPythonDownloadUrl;
|
|
1501
1688
|
exports.installPythonPackages = installPythonPackages;
|
|
1502
1689
|
exports.installPythonRequirements = installPythonRequirements;
|
|
1690
|
+
exports.nodeProcStatReader = nodeProcStatReader;
|
|
1691
|
+
exports.parseProcCpuSeconds = parseProcCpuSeconds;
|
|
1692
|
+
exports.parseProcRssBytes = parseProcRssBytes;
|
|
1693
|
+
exports.readProcessCost = readProcessCost;
|
|
1503
1694
|
exports.resolveExportFingerprint = resolveExportFingerprint;
|
|
1504
1695
|
exports.signExpiringUrl = signExpiringUrl;
|
|
1505
1696
|
exports.verifyExpiringUrl = verifyExpiringUrl;
|
package/dist/node.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { basename, join } from "node:path";
|
|
|
8
8
|
import { pipeline } from "node:stream/promises";
|
|
9
9
|
import { Readable } from "node:stream";
|
|
10
10
|
import { execFileSync, spawn } from "node:child_process";
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
11
12
|
//#region src/deps/binary-downloader.ts
|
|
12
13
|
/**
|
|
13
14
|
* Recursively find the first file named exactly `name` under `dir`. Used as the
|
|
@@ -434,6 +435,190 @@ async function installPythonRequirements(pythonPath, requirementsFile, logger) {
|
|
|
434
435
|
} });
|
|
435
436
|
}
|
|
436
437
|
//#endregion
|
|
438
|
+
//#region src/process/child-cost-registry.ts
|
|
439
|
+
/**
|
|
440
|
+
* The registry an addon claims its OWN child processes in — the spawn-site
|
|
441
|
+
* half of `load-contribution.cap.ts`.
|
|
442
|
+
*
|
|
443
|
+
* ## Where a claim is made, and where it is released
|
|
444
|
+
*
|
|
445
|
+
* At the spawn site, and nowhere else. It is the only place that knows both
|
|
446
|
+
* halves: the recorder's controller knows it is starting ffmpeg for camera 615
|
|
447
|
+
* profile `high`; the decode coordinator knows the session it is forking a
|
|
448
|
+
* worker for. Nothing has to deduce it afterwards, and no entity needs a
|
|
449
|
+
* global list of which pid belongs to which camera.
|
|
450
|
+
*
|
|
451
|
+
* {@link ChildCostRegistry.claim} returns a HANDLE, and the handle is the only
|
|
452
|
+
* way to release. That is not decoration: the alternative — `release(pid)` —
|
|
453
|
+
* lets a late release from a dead generation delete the live claim of a
|
|
454
|
+
* process that inherited the same pid, which is precisely how a per-camera
|
|
455
|
+
* chart charges one camera for another camera's work. A handle can only ever
|
|
456
|
+
* remove the entry it created.
|
|
457
|
+
*
|
|
458
|
+
* ## What the registry does NOT do
|
|
459
|
+
*
|
|
460
|
+
* It keeps no history, no counters and no timers. It holds live claims and
|
|
461
|
+
* answers questions about them; when the process holding it dies, every claim
|
|
462
|
+
* in it dies with it, which is correct — those children were its children.
|
|
463
|
+
*
|
|
464
|
+
* ## The numbers
|
|
465
|
+
*
|
|
466
|
+
* {@link ChildCostRegistry.contributions} reads each claimed child's
|
|
467
|
+
* CUMULATIVE CPU seconds and resident bytes out of `/proc/<pid>` at the moment
|
|
468
|
+
* it is asked. On demand, never on a timer: a new periodic per-node sampler is
|
|
469
|
+
* the defect half of `docs/architecture/load-ledger.md` documents. A counter
|
|
470
|
+
* can be differenced by whoever already keeps a history; a rate cannot be
|
|
471
|
+
* un-averaged.
|
|
472
|
+
*
|
|
473
|
+
* Where `/proc` does not exist (macOS, Windows) or the read fails, the numbers
|
|
474
|
+
* are ABSENT — never zero. Zero would say the camera cost nothing.
|
|
475
|
+
*/
|
|
476
|
+
/**
|
|
477
|
+
* Kernel jiffies per second (`USER_HZ`). Same constant, same reasoning, as
|
|
478
|
+
* `packages/system/src/builtins/native-metrics/thread-cpu-sampler.ts`:
|
|
479
|
+
* `sysconf(_SC_CLK_TCK)` is not exposed to Node and this has been 100 on every
|
|
480
|
+
* kernel configuration we ship to. A wrong value would scale every CPU number
|
|
481
|
+
* by a constant — visible immediately, not a silent skew.
|
|
482
|
+
*/
|
|
483
|
+
var CLOCK_TICKS_PER_SEC = 100;
|
|
484
|
+
/** Bytes per page, for `/proc/<pid>/statm`'s page counts. */
|
|
485
|
+
var PAGE_BYTES = 4096;
|
|
486
|
+
/**
|
|
487
|
+
* The handle a spawn site gets when nobody is collecting — a test harness, or
|
|
488
|
+
* a runner built before its addon registered a registry. Reporting nothing is
|
|
489
|
+
* the correct behaviour: the process still appears in the node's process
|
|
490
|
+
* snapshot, unclaimed, which is exactly what "nobody reported this" should
|
|
491
|
+
* look like.
|
|
492
|
+
*/
|
|
493
|
+
var NO_COST_CLAIM = { release: () => void 0 };
|
|
494
|
+
var nodeProcStatReader = {
|
|
495
|
+
readStat: (pid) => readFile(`/proc/${pid}/stat`, "utf8"),
|
|
496
|
+
readStatm: (pid) => readFile(`/proc/${pid}/statm`, "utf8")
|
|
497
|
+
};
|
|
498
|
+
/**
|
|
499
|
+
* Cumulative CPU seconds from a `/proc/<pid>/stat` line.
|
|
500
|
+
*
|
|
501
|
+
* The `comm` field is field 2, wrapped in parentheses, and can itself contain
|
|
502
|
+
* spaces and parentheses — so the only correct parse cuts at the LAST `)` and
|
|
503
|
+
* indexes from there. After the cut, field 3 (`state`) is index 0, so `utime`
|
|
504
|
+
* (field 14) is index 11 and `stime` (field 15) is index 12.
|
|
505
|
+
*
|
|
506
|
+
* `null` for a line that does not parse: a process that exited between the
|
|
507
|
+
* claim and the read leaves a truncated or empty file, and that is normal.
|
|
508
|
+
*/
|
|
509
|
+
function parseProcCpuSeconds(line) {
|
|
510
|
+
const close = line.lastIndexOf(")");
|
|
511
|
+
if (close < 0) return null;
|
|
512
|
+
const rest = line.slice(close + 1).trim().split(/\s+/);
|
|
513
|
+
const utime = Number(rest[11]);
|
|
514
|
+
const stime = Number(rest[12]);
|
|
515
|
+
if (!Number.isFinite(utime) || !Number.isFinite(stime)) return null;
|
|
516
|
+
return (utime + stime) / CLOCK_TICKS_PER_SEC;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Resident bytes from a `/proc/<pid>/statm` line — field 2 is the resident set
|
|
520
|
+
* in pages. `null` when the line does not parse.
|
|
521
|
+
*/
|
|
522
|
+
function parseProcRssBytes(line) {
|
|
523
|
+
const fields = line.trim().split(/\s+/);
|
|
524
|
+
const residentPages = Number(fields[1]);
|
|
525
|
+
if (!Number.isFinite(residentPages)) return null;
|
|
526
|
+
return residentPages * PAGE_BYTES;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* What the OS will say about one process right now: cumulative CPU seconds and
|
|
530
|
+
* resident bytes, each ABSENT when it cannot be read.
|
|
531
|
+
*
|
|
532
|
+
* Exported because not every cost has a spawn site inside a registry. The
|
|
533
|
+
* shared inference pool is the case that forced it: its process is created
|
|
534
|
+
* deep inside the engine factory and belongs to no camera, so it is reported
|
|
535
|
+
* as an `unattributable` contribution built from the live pool's pids rather
|
|
536
|
+
* than from a claim — but its numbers must be read the same way, by the same
|
|
537
|
+
* parsers, or two "CPU seconds" in one payload would mean two things.
|
|
538
|
+
*/
|
|
539
|
+
async function readProcessCost(pid, reader = nodeProcStatReader) {
|
|
540
|
+
let cpuSeconds = null;
|
|
541
|
+
let rssBytes = null;
|
|
542
|
+
try {
|
|
543
|
+
cpuSeconds = parseProcCpuSeconds(await reader.readStat(pid));
|
|
544
|
+
} catch {
|
|
545
|
+
cpuSeconds = null;
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
rssBytes = parseProcRssBytes(await reader.readStatm(pid));
|
|
549
|
+
} catch {
|
|
550
|
+
rssBytes = null;
|
|
551
|
+
}
|
|
552
|
+
return {
|
|
553
|
+
...cpuSeconds === null ? {} : { cpuSeconds },
|
|
554
|
+
...rssBytes === null ? {} : { rssBytes }
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
var ChildCostRegistry = class {
|
|
558
|
+
reader;
|
|
559
|
+
now;
|
|
560
|
+
claims = /* @__PURE__ */ new Map();
|
|
561
|
+
constructor(reader = nodeProcStatReader, now = Date.now) {
|
|
562
|
+
this.reader = reader;
|
|
563
|
+
this.now = now;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Record one child. The returned handle is the only way to remove it.
|
|
567
|
+
*
|
|
568
|
+
* A claim with no pid is dropped rather than stored: it could carry no
|
|
569
|
+
* measurement, and an entry with a camera and no numbers reads on a chart as
|
|
570
|
+
* a camera that cost nothing.
|
|
571
|
+
*/
|
|
572
|
+
claim(input) {
|
|
573
|
+
const pid = input.pid;
|
|
574
|
+
if (pid === void 0 || !Number.isInteger(pid) || pid <= 0) return NO_COST_CLAIM;
|
|
575
|
+
const key = Symbol("child-cost-claim");
|
|
576
|
+
this.claims.set(key, {
|
|
577
|
+
role: input.role,
|
|
578
|
+
deviceId: input.deviceId,
|
|
579
|
+
unit: input.unit,
|
|
580
|
+
attribution: input.attribution ?? "measured",
|
|
581
|
+
pid,
|
|
582
|
+
startedAtMs: this.now()
|
|
583
|
+
});
|
|
584
|
+
return { release: () => {
|
|
585
|
+
this.claims.delete(key);
|
|
586
|
+
} };
|
|
587
|
+
}
|
|
588
|
+
/** Live claims, in claim order. Diagnostics and tests; not a contribution. */
|
|
589
|
+
list() {
|
|
590
|
+
return [...this.claims.values()].map((c) => ({
|
|
591
|
+
role: c.role,
|
|
592
|
+
deviceId: c.deviceId,
|
|
593
|
+
unit: c.unit,
|
|
594
|
+
attribution: c.attribution,
|
|
595
|
+
pid: c.pid
|
|
596
|
+
}));
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* This addon's contribution: one entry per live claim, with whatever the OS
|
|
600
|
+
* will tell us about that child right now.
|
|
601
|
+
*
|
|
602
|
+
* A child that has exited between the claim and this read contributes an
|
|
603
|
+
* entry with no numbers rather than no entry — the unit exists, the addon
|
|
604
|
+
* believes it is running, and hiding it would make a dying writer look like
|
|
605
|
+
* a writer that was never started.
|
|
606
|
+
*/
|
|
607
|
+
async contributions() {
|
|
608
|
+
const out = [];
|
|
609
|
+
for (const claim of this.claims.values()) out.push({
|
|
610
|
+
role: claim.role,
|
|
611
|
+
deviceId: claim.deviceId,
|
|
612
|
+
attribution: claim.attribution,
|
|
613
|
+
unit: claim.unit,
|
|
614
|
+
pid: claim.pid,
|
|
615
|
+
startedAtMs: claim.startedAtMs,
|
|
616
|
+
...await readProcessCost(claim.pid, this.reader)
|
|
617
|
+
});
|
|
618
|
+
return out;
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
//#endregion
|
|
437
622
|
//#region src/storage/filesystem-storage-provider.ts
|
|
438
623
|
var STORAGE_LOCATION_TYPES = [
|
|
439
624
|
"data",
|
|
@@ -1458,4 +1643,4 @@ var Fmp4FragmentChild = class {
|
|
|
1458
1643
|
}
|
|
1459
1644
|
};
|
|
1460
1645
|
//#endregion
|
|
1461
|
-
export { FfmpegProcess, FilesystemStorageProvider, Fmp4FragmentChild, Fmp4FragmentPlane, PYTHON_VERSION, buildBinaryPath, canonicalDeviceFingerprint, canonicalHash, diffExportTargets, downloadBinary, ensureBinary, ensureFfmpeg, ensurePython, findInPath, getFfmpegDownloadUrl, getPlatformInfo, getPythonDownloadUrl, installPythonPackages, installPythonRequirements, resolveExportFingerprint, signExpiringUrl, verifyExpiringUrl };
|
|
1646
|
+
export { ChildCostRegistry, FfmpegProcess, FilesystemStorageProvider, Fmp4FragmentChild, Fmp4FragmentPlane, NO_COST_CLAIM, PYTHON_VERSION, buildBinaryPath, canonicalDeviceFingerprint, canonicalHash, diffExportTargets, downloadBinary, ensureBinary, ensureFfmpeg, ensurePython, findInPath, getFfmpegDownloadUrl, getPlatformInfo, getPythonDownloadUrl, installPythonPackages, installPythonRequirements, nodeProcStatReader, parseProcCpuSeconds, parseProcRssBytes, readProcessCost, resolveExportFingerprint, signExpiringUrl, verifyExpiringUrl };
|