@camstack/addon-pipeline 1.2.199 → 1.2.201

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.
Files changed (43) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/{default-detection-model-O1uBusV2.mjs → default-detection-model-fUMLQHB2.mjs} +1 -1
  4. package/dist/{default-detection-model-CMe44Kid.js → default-detection-model-nKgB-_mg.js} +1 -1
  5. package/dist/detection-pipeline/index.js +5 -5
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-DmCrC2yx.js → dist-Bd875pfj.js} +912 -606
  8. package/dist/{dist-B23u1tmN.mjs → dist-D2jcEuZD.mjs} +865 -607
  9. package/dist/{lazy-sharp-DBHsD2lh.js → lazy-sharp-B4KrLp0u.js} +1 -1
  10. package/dist/motion-wasm/index.js +2 -2
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-D1MLn6oV.mjs → node-CmfXSgr3.mjs} +420 -381
  13. package/dist/{node-DCSTbt6C.js → node-dwhLC9Fh.js} +423 -378
  14. package/dist/pipeline-runner/index.js +8 -7
  15. package/dist/pipeline-runner/index.mjs +7 -6
  16. package/dist/{process-memory-9lvvhHym.js → process-memory-BDRJK5Vq.js} +1 -1
  17. package/dist/{process-memory-P24NTDb1.mjs → process-memory-CH5qO7AX.mjs} +1 -1
  18. package/dist/recorder/index.js +1208 -1015
  19. package/dist/recorder/index.mjs +1203 -1010
  20. package/dist/restream-intent-B1Difcnq.js +313 -0
  21. package/dist/restream-intent-DWe2kInY.mjs +248 -0
  22. package/dist/{prebuffer-DmBYRQWg.mjs → retire-root-keys-Csb8z2f3.js} +12 -64
  23. package/dist/{prebuffer-DqdeLSsk.js → retire-root-keys-DZvlso2l.mjs} +1 -99
  24. package/dist/{segment-demux-js-Bm0woPkn.mjs → segment-demux-js-BHTuEBhC.mjs} +1 -1
  25. package/dist/{segment-demux-js-Ck_F5bsO.js → segment-demux-js-cmfLROII.js} +1 -1
  26. package/dist/session-decode/decode-worker-child.js +25 -5
  27. package/dist/session-decode/decode-worker-child.mjs +24 -4
  28. package/dist/stream-broker/_stub.js +2 -2
  29. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Sq93hI-g.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DZcCQR5t.mjs} +3 -3
  30. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CrzkEz67.mjs +26 -0
  31. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Dj_qoc1e.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-cYr6Swud.mjs} +1 -1
  32. package/dist/stream-broker/demux-worker-child.js +1 -1
  33. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  34. package/dist/stream-broker/{hostInit-COt259tU.mjs → hostInit-CZt5Lq_M.mjs} +3 -3
  35. package/dist/stream-broker/index.js +181 -48
  36. package/dist/stream-broker/index.mjs +172 -39
  37. package/dist/stream-broker/remoteEntry.js +1 -1
  38. package/dist/{worker-protocol-c1r1Yddg.js → worker-protocol--PcFsRrE.js} +1 -1
  39. package/dist/{worker-protocol-BxpGZ0Dt.mjs → worker-protocol-D4GyRNnY.mjs} +1 -1
  40. package/package.json +5 -1
  41. package/dist/restream-intent-B4BXZra7.mjs +0 -72
  42. package/dist/restream-intent-Cv9x3jmu.js +0 -89
  43. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BcjLrxhF.mjs +0 -26
@@ -1,189 +1,222 @@
1
- import { At as Fmp4BoxSplitter, Nt as isSoftwareDecode, Ot as errMsg, jt as buildFfmpegArgs } from "./dist-B23u1tmN.mjs";
1
+ import { It as errMsg, Rt as Fmp4BoxSplitter, Vt as isSoftwareDecode, zt as buildFfmpegArgs } from "./dist-D2jcEuZD.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
- import "node:fs";
4
- import "node:path";
3
+ import { realpathSync } from "node:fs";
4
+ import path from "node:path";
5
5
  import "node:child_process";
6
6
  import { readFile } from "node:fs/promises";
7
- /**
8
- * The registry an addon claims its OWN child processes in — the spawn-site
9
- * half of `load-contribution.cap.ts`.
10
- *
11
- * ## Where a claim is made, and where it is released
12
- *
13
- * At the spawn site, and nowhere else. It is the only place that knows both
14
- * halves: the recorder's controller knows it is starting ffmpeg for camera 615
15
- * profile `high`; the decode coordinator knows the session it is forking a
16
- * worker for. Nothing has to deduce it afterwards, and no entity needs a
17
- * global list of which pid belongs to which camera.
18
- *
19
- * {@link ChildCostRegistry.claim} returns a HANDLE, and the handle is the only
20
- * way to release. That is not decoration: the alternative — `release(pid)` —
21
- * lets a late release from a dead generation delete the live claim of a
22
- * process that inherited the same pid, which is precisely how a per-camera
23
- * chart charges one camera for another camera's work. A handle can only ever
24
- * remove the entry it created.
25
- *
26
- * ## What the registry does NOT do
27
- *
28
- * It keeps no history, no counters and no timers. It holds live claims and
29
- * answers questions about them; when the process holding it dies, every claim
30
- * in it dies with it, which is correct — those children were its children.
31
- *
32
- * ## The numbers
33
- *
34
- * {@link ChildCostRegistry.contributions} reads each claimed child's
35
- * CUMULATIVE CPU seconds and resident bytes out of `/proc/<pid>` at the moment
36
- * it is asked. On demand, never on a timer: a new periodic per-node sampler is
37
- * the defect half of `docs/architecture/load-ledger.md` documents. A counter
38
- * can be differenced by whoever already keeps a history; a rate cannot be
39
- * un-averaged.
40
- *
41
- * Where `/proc` does not exist (macOS, Windows) or the read fails, the numbers
42
- * are ABSENT — never zero. Zero would say the camera cost nothing.
43
- */
44
- /**
45
- * Kernel jiffies per second (`USER_HZ`). Same constant, same reasoning, as
46
- * `packages/system/src/builtins/native-metrics/thread-cpu-sampler.ts`:
47
- * `sysconf(_SC_CLK_TCK)` is not exposed to Node and this has been 100 on every
48
- * kernel configuration we ship to. A wrong value would scale every CPU number
49
- * by a constant — visible immediately, not a silent skew.
50
- */
51
- var CLOCK_TICKS_PER_SEC = 100;
52
- /** Bytes per page, for `/proc/<pid>/statm`'s page counts. */
53
- var PAGE_BYTES = 4096;
54
- /**
55
- * The handle a spawn site gets when nobody is collecting — a test harness, or
56
- * a runner built before its addon registered a registry. Reporting nothing is
57
- * the correct behaviour: the process still appears in the node's process
58
- * snapshot, unclaimed, which is exactly what "nobody reported this" should
59
- * look like.
60
- */
61
- var NO_COST_CLAIM = { release: () => void 0 };
62
- var nodeProcStatReader = {
63
- readStat: (pid) => readFile(`/proc/${pid}/stat`, "utf8"),
64
- readStatm: (pid) => readFile(`/proc/${pid}/statm`, "utf8")
65
- };
66
- /**
67
- * Cumulative CPU seconds from a `/proc/<pid>/stat` line.
68
- *
69
- * The `comm` field is field 2, wrapped in parentheses, and can itself contain
70
- * spaces and parentheses — so the only correct parse cuts at the LAST `)` and
71
- * indexes from there. After the cut, field 3 (`state`) is index 0, so `utime`
72
- * (field 14) is index 11 and `stime` (field 15) is index 12.
73
- *
74
- * `null` for a line that does not parse: a process that exited between the
75
- * claim and the read leaves a truncated or empty file, and that is normal.
76
- */
77
- function parseProcCpuSeconds(line) {
78
- const close = line.lastIndexOf(")");
79
- if (close < 0) return null;
80
- const rest = line.slice(close + 1).trim().split(/\s+/);
81
- const utime = Number(rest[11]);
82
- const stime = Number(rest[12]);
83
- if (!Number.isFinite(utime) || !Number.isFinite(stime)) return null;
84
- return (utime + stime) / CLOCK_TICKS_PER_SEC;
85
- }
86
- /**
87
- * Resident bytes from a `/proc/<pid>/statm` line — field 2 is the resident set
88
- * in pages. `null` when the line does not parse.
89
- */
90
- function parseProcRssBytes(line) {
91
- const fields = line.trim().split(/\s+/);
92
- const residentPages = Number(fields[1]);
93
- if (!Number.isFinite(residentPages)) return null;
94
- return residentPages * PAGE_BYTES;
95
- }
96
- /**
97
- * What the OS will say about one process right now: cumulative CPU seconds and
98
- * resident bytes, each ABSENT when it cannot be read.
99
- *
100
- * Exported because not every cost has a spawn site inside a registry. The
101
- * shared inference pool is the case that forced it: its process is created
102
- * deep inside the engine factory and belongs to no camera, so it is reported
103
- * as an `unattributable` contribution built from the live pool's pids rather
104
- * than from a claim — but its numbers must be read the same way, by the same
105
- * parsers, or two "CPU seconds" in one payload would mean two things.
106
- */
107
- async function readProcessCost(pid, reader = nodeProcStatReader) {
108
- let cpuSeconds = null;
109
- let rssBytes = null;
110
- try {
111
- cpuSeconds = parseProcCpuSeconds(await reader.readStat(pid));
112
- } catch {
113
- cpuSeconds = null;
7
+ var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
8
+ /** Heartbeat cadence — ~2 minutes of 4 s fragments. */
9
+ var FRAGMENT_LOG_EVERY = 30;
10
+ var Fmp4FragmentChild = class {
11
+ deps;
12
+ args;
13
+ child = null;
14
+ splitter = new Fmp4BoxSplitter();
15
+ stopped = false;
16
+ unitsOut = 0;
17
+ activeHwAccel = null;
18
+ constructor(deps, args) {
19
+ this.deps = deps;
20
+ this.args = args;
114
21
  }
115
- try {
116
- rssBytes = parseProcRssBytes(await reader.readStatm(pid));
117
- } catch {
118
- rssBytes = null;
22
+ /** Spawn, and resolve once the INIT segment has been cut out of stdout. */
23
+ async start() {
24
+ const requested = this.args.invocation.decodeHwAccel;
25
+ this.activeHwAccel = requested;
26
+ try {
27
+ await this.spawnAttempt(requested);
28
+ return;
29
+ } catch (err) {
30
+ if (this.stopped) throw err;
31
+ if (requested === null || isSoftwareDecode(requested)) throw err;
32
+ this.deps.logger.warn("fmp4 fragment child: hardware decode produced NO fragment — retrying in SOFTWARE", {
33
+ tags: { deviceId: this.args.deviceId },
34
+ meta: {
35
+ sourceId: this.args.sourceId,
36
+ decodeHwAccel: requested,
37
+ error: errMsg(err)
38
+ }
39
+ });
40
+ this.killChild();
41
+ this.splitter = new Fmp4BoxSplitter();
42
+ this.activeHwAccel = null;
43
+ await this.spawnAttempt(null);
44
+ }
119
45
  }
120
- return {
121
- ...cpuSeconds === null ? {} : { cpuSeconds },
122
- ...rssBytes === null ? {} : { rssBytes }
123
- };
124
- }
125
- var ChildCostRegistry = class {
126
- reader;
127
- now;
128
- claims = /* @__PURE__ */ new Map();
129
- constructor(reader = nodeProcStatReader, now = Date.now) {
130
- this.reader = reader;
131
- this.now = now;
46
+ /** The backend the child ACTUALLY ran with — `null` for software. */
47
+ activeDecodeHwAccel() {
48
+ const value = this.activeHwAccel;
49
+ return value === null || value === "none" || value === "copy" ? null : value;
132
50
  }
133
- /**
134
- * Record one child. The returned handle is the only way to remove it.
135
- *
136
- * A claim with no pid is dropped rather than stored: it could carry no
137
- * measurement, and an entry with a camera and no numbers reads on a chart as
138
- * a camera that cost nothing.
139
- */
140
- claim(input) {
141
- const pid = input.pid;
142
- if (pid === void 0 || !Number.isInteger(pid) || pid <= 0) return NO_COST_CLAIM;
143
- const key = Symbol("child-cost-claim");
144
- this.claims.set(key, {
145
- role: input.role,
146
- deviceId: input.deviceId,
147
- unit: input.unit,
148
- attribution: input.attribution ?? "measured",
149
- pid,
150
- startedAtMs: this.now()
151
- });
152
- return { release: () => {
153
- this.claims.delete(key);
154
- } };
51
+ /** Kill ffmpeg and end the plane. Idempotent. */
52
+ async stop() {
53
+ if (this.stopped) return;
54
+ this.stopped = true;
55
+ this.killChild();
56
+ this.args.plane.end("the fragment child stopped");
155
57
  }
156
- /** Live claims, in claim order. Diagnostics and tests; not a contribution. */
157
- list() {
158
- return [...this.claims.values()].map((c) => ({
159
- role: c.role,
160
- deviceId: c.deviceId,
161
- unit: c.unit,
162
- attribution: c.attribution,
163
- pid: c.pid
164
- }));
58
+ spawnAttempt(decodeHwAccel) {
59
+ const args = buildFfmpegArgs({
60
+ ...this.args.invocation,
61
+ decodeHwAccel,
62
+ sink: {
63
+ kind: "stdout",
64
+ container: "mp4",
65
+ fragmentMs: this.args.fragmentMs
66
+ }
67
+ });
68
+ this.deps.logger.info("fmp4 fragment child: spawning ffmpeg", {
69
+ tags: { deviceId: this.args.deviceId },
70
+ meta: {
71
+ sourceId: this.args.sourceId,
72
+ fragmentMs: this.args.fragmentMs,
73
+ decodeHwAccel: decodeHwAccel ?? "software",
74
+ argv: args.join(" ")
75
+ }
76
+ });
77
+ return new Promise((resolve, reject) => {
78
+ const child = this.deps.spawnFn(this.deps.ffmpegBinaryPath, args, { stdio: [
79
+ "ignore",
80
+ "pipe",
81
+ "pipe"
82
+ ] });
83
+ this.child = child;
84
+ let settled = false;
85
+ /**
86
+ * This attempt FAILED. Set before the kill, because SIGTERM makes the
87
+ * child exit and that exit must not be reported as a death: the retry —
88
+ * or the caller's rejection — already owns what happens next. Without it
89
+ * the timeout path ends the plane the software retry is about to fill,
90
+ * and the consumer sees a stream that stopped for no reason. A "which
91
+ * spawn is current" counter does NOT cover this: the retry has not been
92
+ * spawned when the kill's exit arrives.
93
+ */
94
+ let failed = false;
95
+ /**
96
+ * This attempt is still the live producer: it has not failed (a failure
97
+ * hands ownership to the retry, or to the caller's rejection) and nothing
98
+ * has stopped the child. Those two cover every way an attempt stops being
99
+ * current — `start` only respawns after a rejection.
100
+ */
101
+ const isCurrent = () => !this.stopped && !failed;
102
+ const timeoutMs = this.deps.firstUnitTimeoutMs ?? DEFAULT_FIRST_UNIT_TIMEOUT_MS;
103
+ const settle = (fail) => {
104
+ if (settled) return;
105
+ settled = true;
106
+ clearTimeout(timer);
107
+ if (fail) {
108
+ failed = true;
109
+ reject(fail);
110
+ } else resolve();
111
+ };
112
+ const timer = setTimeout(() => {
113
+ settle(/* @__PURE__ */ new Error(`fmp4 fragment child: no fragment within ${timeoutMs}ms`));
114
+ this.killChild();
115
+ }, timeoutMs);
116
+ timer.unref?.();
117
+ child.stdout?.on("data", (chunk) => {
118
+ for (const unit of this.splitter.push(chunk)) {
119
+ this.unitsOut += 1;
120
+ this.args.plane.publish(unit);
121
+ if (unit.kind === "init") {
122
+ this.deps.logger.info("fmp4 fragment child: INIT segment cut", {
123
+ tags: { deviceId: this.args.deviceId },
124
+ meta: {
125
+ sourceId: this.args.sourceId,
126
+ bytes: unit.data.length
127
+ }
128
+ });
129
+ settle();
130
+ } else if (this.unitsOut % FRAGMENT_LOG_EVERY === 0) this.deps.logger.info("fmp4 fragment child: fragments still flowing", {
131
+ tags: { deviceId: this.args.deviceId },
132
+ meta: {
133
+ sourceId: this.args.sourceId,
134
+ unitsOut: this.unitsOut,
135
+ bytes: unit.data.length,
136
+ subscribers: this.args.plane.subscriberCount
137
+ }
138
+ });
139
+ }
140
+ const fault = this.splitter.fault;
141
+ if (fault !== null) this.onFault(fault, settled, isCurrent(), settle);
142
+ });
143
+ child.stderr?.setEncoding("utf8");
144
+ child.stderr?.on("data", (line) => {
145
+ this.deps.logger.debug("fmp4 fragment child ffmpeg", {
146
+ tags: { deviceId: this.args.deviceId },
147
+ meta: {
148
+ sourceId: this.args.sourceId,
149
+ line: line.trim()
150
+ }
151
+ });
152
+ });
153
+ child.once("error", (err) => {
154
+ if (!settled) {
155
+ settle(err);
156
+ return;
157
+ }
158
+ if (!isCurrent()) return;
159
+ this.args.plane.end("the fragment child errored");
160
+ this.deps.onChildExit?.(err);
161
+ });
162
+ child.once("exit", (code, signal) => {
163
+ if (!settled) {
164
+ settle(/* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited before any fragment (code=${code} signal=${signal})`));
165
+ return;
166
+ }
167
+ if (!isCurrent()) return;
168
+ const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited while live (code=${code} signal=${signal})`);
169
+ this.deps.logger.warn("fmp4 fragment child: ffmpeg exited while live", {
170
+ tags: { deviceId: this.args.deviceId },
171
+ meta: {
172
+ sourceId: this.args.sourceId,
173
+ code,
174
+ signal,
175
+ unitsOut: this.unitsOut
176
+ }
177
+ });
178
+ this.args.plane.end("the fragment child exited");
179
+ this.deps.onChildExit?.(error);
180
+ });
181
+ });
165
182
  }
166
183
  /**
167
- * This addon's contribution: one entry per live claim, with whatever the OS
168
- * will tell us about that child right now.
169
- *
170
- * A child that has exited between the claim and this read contributes an
171
- * entry with no numbers rather than no entry — the unit exists, the addon
172
- * believes it is running, and hiding it would make a dying writer look like
173
- * a writer that was never started.
184
+ * The byte stream stopped being splittable. Not recoverable — the splitter
185
+ * cannot resynchronise mid-box — so the child is a corpse and every consumer
186
+ * has to be told, loudly, with the reason.
174
187
  */
175
- async contributions() {
176
- const out = [];
177
- for (const claim of this.claims.values()) out.push({
178
- role: claim.role,
179
- deviceId: claim.deviceId,
180
- attribution: claim.attribution,
181
- unit: claim.unit,
182
- pid: claim.pid,
183
- startedAtMs: claim.startedAtMs,
184
- ...await readProcessCost(claim.pid, this.reader)
188
+ onFault(reason, wasLive, current, settle) {
189
+ const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ${reason}`);
190
+ this.deps.logger.error("fmp4 fragment child: the ffmpeg output stopped parsing as fMP4", {
191
+ tags: { deviceId: this.args.deviceId },
192
+ meta: {
193
+ sourceId: this.args.sourceId,
194
+ unitsOut: this.unitsOut,
195
+ interstitial: this.splitter.discardedInterstitialTypes,
196
+ reason
197
+ }
185
198
  });
186
- return out;
199
+ this.killChild();
200
+ settle(error);
201
+ if (wasLive && current) {
202
+ this.args.plane.end("the fragment child produced unsplittable output");
203
+ this.deps.onChildExit?.(error);
204
+ }
205
+ }
206
+ killChild() {
207
+ const child = this.child;
208
+ this.child = null;
209
+ if (child && !child.killed) try {
210
+ child.kill("SIGTERM");
211
+ } catch (err) {
212
+ this.deps.logger.warn("fmp4 fragment child: kill error", {
213
+ tags: { deviceId: this.args.deviceId },
214
+ meta: {
215
+ sourceId: this.args.sourceId,
216
+ error: errMsg(err)
217
+ }
218
+ });
219
+ }
187
220
  }
188
221
  };
189
222
  /**
@@ -471,220 +504,226 @@ var Fmp4FragmentPlane = class {
471
504
  }
472
505
  }
473
506
  };
474
- var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
475
- /** Heartbeat cadence — ~2 minutes of 4 s fragments. */
476
- var FRAGMENT_LOG_EVERY = 30;
477
- var Fmp4FragmentChild = class {
478
- deps;
479
- args;
480
- child = null;
481
- splitter = new Fmp4BoxSplitter();
482
- stopped = false;
483
- unitsOut = 0;
484
- activeHwAccel = null;
485
- constructor(deps, args) {
486
- this.deps = deps;
487
- this.args = args;
488
- }
489
- /** Spawn, and resolve once the INIT segment has been cut out of stdout. */
490
- async start() {
491
- const requested = this.args.invocation.decodeHwAccel;
492
- this.activeHwAccel = requested;
493
- try {
494
- await this.spawnAttempt(requested);
495
- return;
496
- } catch (err) {
497
- if (this.stopped) throw err;
498
- if (requested === null || isSoftwareDecode(requested)) throw err;
499
- this.deps.logger.warn("fmp4 fragment child: hardware decode produced NO fragment — retrying in SOFTWARE", {
500
- tags: { deviceId: this.args.deviceId },
501
- meta: {
502
- sourceId: this.args.sourceId,
503
- decodeHwAccel: requested,
504
- error: errMsg(err)
505
- }
506
- });
507
- this.killChild();
508
- this.splitter = new Fmp4BoxSplitter();
509
- this.activeHwAccel = null;
510
- await this.spawnAttempt(null);
511
- }
507
+ /**
508
+ * The registry an addon claims its OWN child processes in — the spawn-site
509
+ * half of `load-contribution.cap.ts`.
510
+ *
511
+ * ## Where a claim is made, and where it is released
512
+ *
513
+ * At the spawn site, and nowhere else. It is the only place that knows both
514
+ * halves: the recorder's controller knows it is starting ffmpeg for camera 615
515
+ * profile `high`; the decode coordinator knows the session it is forking a
516
+ * worker for. Nothing has to deduce it afterwards, and no entity needs a
517
+ * global list of which pid belongs to which camera.
518
+ *
519
+ * {@link ChildCostRegistry.claim} returns a HANDLE, and the handle is the only
520
+ * way to release. That is not decoration: the alternative — `release(pid)` —
521
+ * lets a late release from a dead generation delete the live claim of a
522
+ * process that inherited the same pid, which is precisely how a per-camera
523
+ * chart charges one camera for another camera's work. A handle can only ever
524
+ * remove the entry it created.
525
+ *
526
+ * ## What the registry does NOT do
527
+ *
528
+ * It keeps no history, no counters and no timers. It holds live claims and
529
+ * answers questions about them; when the process holding it dies, every claim
530
+ * in it dies with it, which is correct — those children were its children.
531
+ *
532
+ * ## The numbers
533
+ *
534
+ * {@link ChildCostRegistry.contributions} reads each claimed child's
535
+ * CUMULATIVE CPU seconds and resident bytes out of `/proc/<pid>` at the moment
536
+ * it is asked. On demand, never on a timer: a new periodic per-node sampler is
537
+ * the defect half of `docs/architecture/load-ledger.md` documents. A counter
538
+ * can be differenced by whoever already keeps a history; a rate cannot be
539
+ * un-averaged.
540
+ *
541
+ * Where `/proc` does not exist (macOS, Windows) or the read fails, the numbers
542
+ * are ABSENT — never zero. Zero would say the camera cost nothing.
543
+ */
544
+ /**
545
+ * Kernel jiffies per second (`USER_HZ`). Same constant, same reasoning, as
546
+ * `packages/system/src/builtins/native-metrics/thread-cpu-sampler.ts`:
547
+ * `sysconf(_SC_CLK_TCK)` is not exposed to Node and this has been 100 on every
548
+ * kernel configuration we ship to. A wrong value would scale every CPU number
549
+ * by a constant — visible immediately, not a silent skew.
550
+ */
551
+ var CLOCK_TICKS_PER_SEC = 100;
552
+ /** Bytes per page, for `/proc/<pid>/statm`'s page counts. */
553
+ var PAGE_BYTES = 4096;
554
+ /**
555
+ * The handle a spawn site gets when nobody is collecting — a test harness, or
556
+ * a runner built before its addon registered a registry. Reporting nothing is
557
+ * the correct behaviour: the process still appears in the node's process
558
+ * snapshot, unclaimed, which is exactly what "nobody reported this" should
559
+ * look like.
560
+ */
561
+ var NO_COST_CLAIM = { release: () => void 0 };
562
+ var nodeProcStatReader = {
563
+ readStat: (pid) => readFile(`/proc/${pid}/stat`, "utf8"),
564
+ readStatm: (pid) => readFile(`/proc/${pid}/statm`, "utf8")
565
+ };
566
+ /**
567
+ * Cumulative CPU seconds from a `/proc/<pid>/stat` line.
568
+ *
569
+ * The `comm` field is field 2, wrapped in parentheses, and can itself contain
570
+ * spaces and parentheses — so the only correct parse cuts at the LAST `)` and
571
+ * indexes from there. After the cut, field 3 (`state`) is index 0, so `utime`
572
+ * (field 14) is index 11 and `stime` (field 15) is index 12.
573
+ *
574
+ * `null` for a line that does not parse: a process that exited between the
575
+ * claim and the read leaves a truncated or empty file, and that is normal.
576
+ */
577
+ function parseProcCpuSeconds(line) {
578
+ const close = line.lastIndexOf(")");
579
+ if (close < 0) return null;
580
+ const rest = line.slice(close + 1).trim().split(/\s+/);
581
+ const utime = Number(rest[11]);
582
+ const stime = Number(rest[12]);
583
+ if (!Number.isFinite(utime) || !Number.isFinite(stime)) return null;
584
+ return (utime + stime) / CLOCK_TICKS_PER_SEC;
585
+ }
586
+ /**
587
+ * Resident bytes from a `/proc/<pid>/statm` line — field 2 is the resident set
588
+ * in pages. `null` when the line does not parse.
589
+ */
590
+ function parseProcRssBytes(line) {
591
+ const fields = line.trim().split(/\s+/);
592
+ const residentPages = Number(fields[1]);
593
+ if (!Number.isFinite(residentPages)) return null;
594
+ return residentPages * PAGE_BYTES;
595
+ }
596
+ /**
597
+ * What the OS will say about one process right now: cumulative CPU seconds and
598
+ * resident bytes, each ABSENT when it cannot be read.
599
+ *
600
+ * Exported because not every cost has a spawn site inside a registry. The
601
+ * shared inference pool is the case that forced it: its process is created
602
+ * deep inside the engine factory and belongs to no camera, so it is reported
603
+ * as an `unattributable` contribution built from the live pool's pids rather
604
+ * than from a claim — but its numbers must be read the same way, by the same
605
+ * parsers, or two "CPU seconds" in one payload would mean two things.
606
+ */
607
+ async function readProcessCost(pid, reader = nodeProcStatReader) {
608
+ let cpuSeconds = null;
609
+ let rssBytes = null;
610
+ try {
611
+ cpuSeconds = parseProcCpuSeconds(await reader.readStat(pid));
612
+ } catch {
613
+ cpuSeconds = null;
512
614
  }
513
- /** The backend the child ACTUALLY ran with — `null` for software. */
514
- activeDecodeHwAccel() {
515
- const value = this.activeHwAccel;
516
- return value === null || value === "none" || value === "copy" ? null : value;
615
+ try {
616
+ rssBytes = parseProcRssBytes(await reader.readStatm(pid));
617
+ } catch {
618
+ rssBytes = null;
517
619
  }
518
- /** Kill ffmpeg and end the plane. Idempotent. */
519
- async stop() {
520
- if (this.stopped) return;
521
- this.stopped = true;
522
- this.killChild();
523
- this.args.plane.end("the fragment child stopped");
620
+ return {
621
+ ...cpuSeconds === null ? {} : { cpuSeconds },
622
+ ...rssBytes === null ? {} : { rssBytes }
623
+ };
624
+ }
625
+ var ChildCostRegistry = class {
626
+ reader;
627
+ now;
628
+ claims = /* @__PURE__ */ new Map();
629
+ constructor(reader = nodeProcStatReader, now = Date.now) {
630
+ this.reader = reader;
631
+ this.now = now;
524
632
  }
525
- spawnAttempt(decodeHwAccel) {
526
- const args = buildFfmpegArgs({
527
- ...this.args.invocation,
528
- decodeHwAccel,
529
- sink: {
530
- kind: "stdout",
531
- container: "mp4",
532
- fragmentMs: this.args.fragmentMs
533
- }
534
- });
535
- this.deps.logger.info("fmp4 fragment child: spawning ffmpeg", {
536
- tags: { deviceId: this.args.deviceId },
537
- meta: {
538
- sourceId: this.args.sourceId,
539
- fragmentMs: this.args.fragmentMs,
540
- decodeHwAccel: decodeHwAccel ?? "software",
541
- argv: args.join(" ")
542
- }
543
- });
544
- return new Promise((resolve, reject) => {
545
- const child = this.deps.spawnFn(this.deps.ffmpegBinaryPath, args, { stdio: [
546
- "ignore",
547
- "pipe",
548
- "pipe"
549
- ] });
550
- this.child = child;
551
- let settled = false;
552
- /**
553
- * This attempt FAILED. Set before the kill, because SIGTERM makes the
554
- * child exit and that exit must not be reported as a death: the retry —
555
- * or the caller's rejection — already owns what happens next. Without it
556
- * the timeout path ends the plane the software retry is about to fill,
557
- * and the consumer sees a stream that stopped for no reason. A "which
558
- * spawn is current" counter does NOT cover this: the retry has not been
559
- * spawned when the kill's exit arrives.
560
- */
561
- let failed = false;
562
- /**
563
- * This attempt is still the live producer: it has not failed (a failure
564
- * hands ownership to the retry, or to the caller's rejection) and nothing
565
- * has stopped the child. Those two cover every way an attempt stops being
566
- * current — `start` only respawns after a rejection.
567
- */
568
- const isCurrent = () => !this.stopped && !failed;
569
- const timeoutMs = this.deps.firstUnitTimeoutMs ?? DEFAULT_FIRST_UNIT_TIMEOUT_MS;
570
- const settle = (fail) => {
571
- if (settled) return;
572
- settled = true;
573
- clearTimeout(timer);
574
- if (fail) {
575
- failed = true;
576
- reject(fail);
577
- } else resolve();
578
- };
579
- const timer = setTimeout(() => {
580
- settle(/* @__PURE__ */ new Error(`fmp4 fragment child: no fragment within ${timeoutMs}ms`));
581
- this.killChild();
582
- }, timeoutMs);
583
- timer.unref?.();
584
- child.stdout?.on("data", (chunk) => {
585
- for (const unit of this.splitter.push(chunk)) {
586
- this.unitsOut += 1;
587
- this.args.plane.publish(unit);
588
- if (unit.kind === "init") {
589
- this.deps.logger.info("fmp4 fragment child: INIT segment cut", {
590
- tags: { deviceId: this.args.deviceId },
591
- meta: {
592
- sourceId: this.args.sourceId,
593
- bytes: unit.data.length
594
- }
595
- });
596
- settle();
597
- } else if (this.unitsOut % FRAGMENT_LOG_EVERY === 0) this.deps.logger.info("fmp4 fragment child: fragments still flowing", {
598
- tags: { deviceId: this.args.deviceId },
599
- meta: {
600
- sourceId: this.args.sourceId,
601
- unitsOut: this.unitsOut,
602
- bytes: unit.data.length,
603
- subscribers: this.args.plane.subscriberCount
604
- }
605
- });
606
- }
607
- const fault = this.splitter.fault;
608
- if (fault !== null) this.onFault(fault, settled, isCurrent(), settle);
609
- });
610
- child.stderr?.setEncoding("utf8");
611
- child.stderr?.on("data", (line) => {
612
- this.deps.logger.debug("fmp4 fragment child ffmpeg", {
613
- tags: { deviceId: this.args.deviceId },
614
- meta: {
615
- sourceId: this.args.sourceId,
616
- line: line.trim()
617
- }
618
- });
619
- });
620
- child.once("error", (err) => {
621
- if (!settled) {
622
- settle(err);
623
- return;
624
- }
625
- if (!isCurrent()) return;
626
- this.args.plane.end("the fragment child errored");
627
- this.deps.onChildExit?.(err);
628
- });
629
- child.once("exit", (code, signal) => {
630
- if (!settled) {
631
- settle(/* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited before any fragment (code=${code} signal=${signal})`));
632
- return;
633
- }
634
- if (!isCurrent()) return;
635
- const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited while live (code=${code} signal=${signal})`);
636
- this.deps.logger.warn("fmp4 fragment child: ffmpeg exited while live", {
637
- tags: { deviceId: this.args.deviceId },
638
- meta: {
639
- sourceId: this.args.sourceId,
640
- code,
641
- signal,
642
- unitsOut: this.unitsOut
643
- }
644
- });
645
- this.args.plane.end("the fragment child exited");
646
- this.deps.onChildExit?.(error);
647
- });
633
+ /**
634
+ * Record one child. The returned handle is the only way to remove it.
635
+ *
636
+ * A claim with no pid is dropped rather than stored: it could carry no
637
+ * measurement, and an entry with a camera and no numbers reads on a chart as
638
+ * a camera that cost nothing.
639
+ */
640
+ claim(input) {
641
+ const pid = input.pid;
642
+ if (pid === void 0 || !Number.isInteger(pid) || pid <= 0) return NO_COST_CLAIM;
643
+ const key = Symbol("child-cost-claim");
644
+ this.claims.set(key, {
645
+ role: input.role,
646
+ deviceId: input.deviceId,
647
+ unit: input.unit,
648
+ attribution: input.attribution ?? "measured",
649
+ pid,
650
+ startedAtMs: this.now()
648
651
  });
652
+ return { release: () => {
653
+ this.claims.delete(key);
654
+ } };
655
+ }
656
+ /** Live claims, in claim order. Diagnostics and tests; not a contribution. */
657
+ list() {
658
+ return [...this.claims.values()].map((c) => ({
659
+ role: c.role,
660
+ deviceId: c.deviceId,
661
+ unit: c.unit,
662
+ attribution: c.attribution,
663
+ pid: c.pid
664
+ }));
649
665
  }
650
666
  /**
651
- * The byte stream stopped being splittable. Not recoverable — the splitter
652
- * cannot resynchronise mid-box — so the child is a corpse and every consumer
653
- * has to be told, loudly, with the reason.
667
+ * This addon's contribution: one entry per live claim, with whatever the OS
668
+ * will tell us about that child right now.
669
+ *
670
+ * A child that has exited between the claim and this read contributes an
671
+ * entry with no numbers rather than no entry — the unit exists, the addon
672
+ * believes it is running, and hiding it would make a dying writer look like
673
+ * a writer that was never started.
654
674
  */
655
- onFault(reason, wasLive, current, settle) {
656
- const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ${reason}`);
657
- this.deps.logger.error("fmp4 fragment child: the ffmpeg output stopped parsing as fMP4", {
658
- tags: { deviceId: this.args.deviceId },
659
- meta: {
660
- sourceId: this.args.sourceId,
661
- unitsOut: this.unitsOut,
662
- interstitial: this.splitter.discardedInterstitialTypes,
663
- reason
664
- }
675
+ async contributions() {
676
+ const out = [];
677
+ for (const claim of this.claims.values()) out.push({
678
+ role: claim.role,
679
+ deviceId: claim.deviceId,
680
+ attribution: claim.attribution,
681
+ unit: claim.unit,
682
+ pid: claim.pid,
683
+ startedAtMs: claim.startedAtMs,
684
+ ...await readProcessCost(claim.pid, this.reader)
665
685
  });
666
- this.killChild();
667
- settle(error);
668
- if (wasLive && current) {
669
- this.args.plane.end("the fragment child produced unsplittable output");
670
- this.deps.onChildExit?.(error);
671
- }
672
- }
673
- killChild() {
674
- const child = this.child;
675
- this.child = null;
676
- if (child && !child.killed) try {
677
- child.kill("SIGTERM");
678
- } catch (err) {
679
- this.deps.logger.warn("fmp4 fragment child: kill error", {
680
- tags: { deviceId: this.args.deviceId },
681
- meta: {
682
- sourceId: this.args.sourceId,
683
- error: errMsg(err)
684
- }
685
- });
686
- }
686
+ return out;
687
687
  }
688
688
  };
689
+ /**
690
+ * The PHYSICAL root behind a storage-location path — ONE derivation, shared.
691
+ *
692
+ * A symlink or a bind mount reaches one disk under two names, and a string
693
+ * comparison misses exactly the case that matters: two locations that look
694
+ * distinct in the config and are the same directory. Every consumer that has to
695
+ * answer "are these the same disk" resolves it here.
696
+ *
697
+ * Two consumers today, and they need different things from the SAME derivation,
698
+ * which is why the resolution failure is reported rather than swallowed:
699
+ *
700
+ * - the recorder's eviction domain merges same-root aliases into one
701
+ * oldest-first pool. It wants a key either way — an unresolvable root falls
702
+ * back to the lexically-resolved path, which can only ever OVER-partition
703
+ * (treat one disk as two) and never merge two different disks. That is the
704
+ * safe failure mode for a data-loss guard.
705
+ * - the orchestrator's collision refusal (D389) must NOT refuse on a root it
706
+ * could not resolve: a location on another node, or behind a remote
707
+ * provider, is unresolvable from here, and refusing on an unanswerable read
708
+ * blocks the operator on a doubt (D49's spirit). It needs to know.
709
+ *
710
+ * Node-only (`node:fs`), so it lives off the root entry and is re-exported from
711
+ * `@camstack/types/node`.
712
+ */
713
+ /** Resolve a location root to its physical key. Never throws. */
714
+ function physicalRootOf(root, realpath = realpathSync) {
715
+ const resolved = path.resolve(root);
716
+ try {
717
+ return {
718
+ key: realpath(resolved),
719
+ resolved: true
720
+ };
721
+ } catch {
722
+ return {
723
+ key: resolved,
724
+ resolved: false
725
+ };
726
+ }
727
+ }
689
728
  //#endregion
690
- export { readProcessCost as a, NO_COST_CLAIM as i, Fmp4FragmentChild as n, Fmp4FragmentPlane as r, ChildCostRegistry as t };
729
+ export { physicalRootOf as a, NO_COST_CLAIM as i, Fmp4FragmentChild as n, readProcessCost as o, Fmp4FragmentPlane as r, ChildCostRegistry as t };