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