@camstack/addon-pipeline 1.2.200 → 1.2.202
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/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/{default-detection-model-c1LY6thP.mjs → default-detection-model-fUMLQHB2.mjs} +1 -1
- package/dist/{default-detection-model-BsmxtyZx.js → default-detection-model-nKgB-_mg.js} +1 -1
- package/dist/detection-pipeline/index.js +5 -5
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-D2YTTkRf.js → dist-Bd875pfj.js} +540 -232
- package/dist/{dist-mEsrAAc3.mjs → dist-D2jcEuZD.mjs} +493 -233
- package/dist/{lazy-sharp-B5bW34uC.js → lazy-sharp-B4KrLp0u.js} +1 -1
- package/dist/motion-wasm/index.js +2 -2
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-CUxep5tc.mjs → node-CmfXSgr3.mjs} +420 -381
- package/dist/{node-x3YokJkD.js → node-dwhLC9Fh.js} +423 -378
- package/dist/pipeline-runner/index.js +41 -26
- package/dist/pipeline-runner/index.mjs +40 -25
- package/dist/{process-memory-CFc4D9A2.js → process-memory-BDRJK5Vq.js} +1 -1
- package/dist/{process-memory-BCqpTruN.mjs → process-memory-CH5qO7AX.mjs} +1 -1
- package/dist/recorder/index.js +4455 -4097
- package/dist/recorder/index.mjs +6085 -5727
- package/dist/{segment-demux-js-xHtB450F.mjs → segment-demux-js-BHTuEBhC.mjs} +1 -1
- package/dist/{segment-demux-js-DUZVURQd.js → segment-demux-js-cmfLROII.js} +1 -1
- package/dist/session-decode/decode-worker-child.js +234 -24
- package/dist/session-decode/decode-worker-child.mjs +233 -23
- package/dist/stream-broker/_stub.js +2 -2
- 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
- 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
- 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
- package/dist/stream-broker/demux-worker-child.js +1 -1
- package/dist/stream-broker/demux-worker-child.mjs +1 -1
- package/dist/stream-broker/{hostInit-D_cZQhBA.mjs → hostInit-CZt5Lq_M.mjs} +3 -3
- package/dist/stream-broker/index.js +3 -3
- package/dist/stream-broker/index.mjs +3 -3
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-CRDaBlJl.js → worker-protocol--PcFsRrE.js} +1 -1
- package/dist/{worker-protocol-oUamwZSn.mjs → worker-protocol-D4GyRNnY.mjs} +1 -1
- package/package.json +5 -1
- 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,189 +1,222 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
*
|
|
168
|
-
*
|
|
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
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
615
|
+
try {
|
|
616
|
+
rssBytes = parseProcRssBytes(await reader.readStatm(pid));
|
|
617
|
+
} catch {
|
|
618
|
+
rssBytes = null;
|
|
517
619
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
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
|
-
|
|
656
|
-
const
|
|
657
|
-
this.
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
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 {
|
|
729
|
+
export { physicalRootOf as a, NO_COST_CLAIM as i, Fmp4FragmentChild as n, readProcessCost as o, Fmp4FragmentPlane as r, ChildCostRegistry as t };
|