@camstack/addon-pipeline 1.2.36 → 1.2.38
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/detection-pipeline/index.js +199 -4
- package/dist/detection-pipeline/index.mjs +199 -4
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C5m4hC2K.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-ByuMjhL3.mjs} +3 -3
- package/dist/stream-broker/{hostInit-WXFrgppm.mjs → hostInit-CfC64ye2.mjs} +3 -3
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/python/__pycache__/inference_pool.cpython-312.pyc +0 -0
- package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
- package/python/__pycache__/inference_pool.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_backpressure.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_coreml_cache.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_device_selection.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_layout.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_preprocess.cpython-314.pyc +0 -0
- package/python/__pycache__/yamnet_audio.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/__init__.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/arcface.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/clip.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/clip.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/clip.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/ctc.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/saliency.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/scrfd.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/softmax.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/ssd.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/ssd.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/ssd.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/test_clip.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_clip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_ctc.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_ctc.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/test_ctc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_ctc.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/test_saliency.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_saliency.cpython-313-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_softmax.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_yolo_nms.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_yolo_nms.cpython-39.pyc +0 -0
- package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/yamnet.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo.cpython-39.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo_seg.cpython-314.pyc +0 -0
|
@@ -16,6 +16,7 @@ let node_fs = require("node:fs");
|
|
|
16
16
|
node_fs = require_chunk.__toESM(node_fs);
|
|
17
17
|
let node_path = require("node:path");
|
|
18
18
|
node_path = require_chunk.__toESM(node_path);
|
|
19
|
+
let node_perf_hooks = require("node:perf_hooks");
|
|
19
20
|
//#region src/detection-pipeline/runtimes.ts
|
|
20
21
|
var KNOWN_PLATFORMS = [
|
|
21
22
|
"darwin",
|
|
@@ -597,8 +598,12 @@ var PoolWorker = class {
|
|
|
597
598
|
this.ready = false;
|
|
598
599
|
});
|
|
599
600
|
this.process.stdout.on("data", (chunk) => {
|
|
601
|
+
const t0 = Date.now();
|
|
600
602
|
this.receiveBuffer = Buffer.concat([this.receiveBuffer, chunk]);
|
|
603
|
+
this.recvConcatMs += Date.now() - t0;
|
|
604
|
+
if (this.receiveBuffer.length > this.recvPeakBytes) this.recvPeakBytes = this.receiveBuffer.length;
|
|
601
605
|
this.drainResponses();
|
|
606
|
+
this.reportRecvCostIfDue();
|
|
602
607
|
});
|
|
603
608
|
const config = {
|
|
604
609
|
runtime: this.opts.poolRuntime,
|
|
@@ -785,7 +790,7 @@ var PoolWorker = class {
|
|
|
785
790
|
device: this.opts.device ?? "default",
|
|
786
791
|
inFlight: this.pending.size,
|
|
787
792
|
reqId,
|
|
788
|
-
timeoutMs
|
|
793
|
+
timeoutMs
|
|
789
794
|
} });
|
|
790
795
|
reject(/* @__PURE__ */ new Error(`PoolWorker[${this.opts.workerLabel}]: inference request ${reqId} timed out after ${timeoutMs}ms (worker alive, no reply)`));
|
|
791
796
|
}
|
|
@@ -841,6 +846,36 @@ var PoolWorker = class {
|
|
|
841
846
|
ensureReady() {
|
|
842
847
|
if (!this.ready || !this.process?.stdin) throw new Error(`PoolWorker[${this.opts.workerLabel}]: not initialized`);
|
|
843
848
|
}
|
|
849
|
+
/** Time spent in `Buffer.concat` since the last report. */
|
|
850
|
+
recvConcatMs = 0;
|
|
851
|
+
/** Largest the receive buffer has grown since the last report. */
|
|
852
|
+
recvPeakBytes = 0;
|
|
853
|
+
/** When the last receive-cost report was emitted. */
|
|
854
|
+
recvReportedAt = 0;
|
|
855
|
+
/**
|
|
856
|
+
* Emit the receive-path cost at most once every 10s, and only when it is
|
|
857
|
+
* actually interesting — silence when the reader costs nothing is the whole
|
|
858
|
+
* point, otherwise this becomes another line nobody reads.
|
|
859
|
+
*/
|
|
860
|
+
reportRecvCostIfDue() {
|
|
861
|
+
const now = Date.now();
|
|
862
|
+
if (this.recvReportedAt === 0) this.recvReportedAt = now;
|
|
863
|
+
if (now - this.recvReportedAt < 1e4) return;
|
|
864
|
+
const concatMs = this.recvConcatMs;
|
|
865
|
+
const peak = this.recvPeakBytes;
|
|
866
|
+
this.recvConcatMs = 0;
|
|
867
|
+
this.recvPeakBytes = 0;
|
|
868
|
+
this.recvReportedAt = now;
|
|
869
|
+
if (concatMs < 250) return;
|
|
870
|
+
this.log.warn("pool receive path is copying", { meta: {
|
|
871
|
+
worker: this.opts.workerLabel,
|
|
872
|
+
runtime: this.opts.poolRuntime,
|
|
873
|
+
device: this.opts.device ?? "default",
|
|
874
|
+
concatMs,
|
|
875
|
+
peakBufferKb: Math.round(peak / 1024),
|
|
876
|
+
windowMs: 1e4
|
|
877
|
+
} });
|
|
878
|
+
}
|
|
844
879
|
drainResponses() {
|
|
845
880
|
while (this.receiveBuffer.length >= 8) {
|
|
846
881
|
const totalLen = this.receiveBuffer.readUInt32LE(0);
|
|
@@ -4584,10 +4619,27 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
4584
4619
|
* Idle-TTL for a per-device inference pool (multi-device C3). A device pool
|
|
4585
4620
|
* with no dispatch for this long is disposed and recreated on next use — the
|
|
4586
4621
|
* memory guardrail that keeps `factoriesByDevice` from accumulating warm pools
|
|
4587
|
-
* forever
|
|
4588
|
-
*
|
|
4622
|
+
* forever as an operator toggles device sets over a long run.
|
|
4623
|
+
*
|
|
4624
|
+
* It was 120s, on the stated assumption that this was "well above any real
|
|
4625
|
+
* inter-session gap for an active camera". Measured 2026-08-02, that is
|
|
4626
|
+
* false: detection is dispatched ON MOTION, so at night the gap between
|
|
4627
|
+
* events routinely exceeds two minutes, and the eviction fires constantly.
|
|
4628
|
+
* The rebuild is not cheap — `ensureModelsForSteps` loads EVERY enabled step
|
|
4629
|
+
* into the fresh factory, which on the hub is seven models
|
|
4630
|
+
* (yolov9m-320-int8, scrfd-2.5g, arcface-r100, yolov8n-plate,
|
|
4631
|
+
* vgg_english_g2, mobileclip-s1-vision, yolov8n-package). The hub log shows
|
|
4632
|
+
* the cycle plainly: `COMPILE ×7` → shed within 7-15s → `EVICT` ~2.5 min
|
|
4633
|
+
* later → repeat. So the first seconds of a motion event — precisely when
|
|
4634
|
+
* detection matters — were spent recompiling models that had just been
|
|
4635
|
+
* thrown away.
|
|
4636
|
+
*
|
|
4637
|
+
* Hours, not minutes. At this TTL only a pool that is genuinely unused
|
|
4638
|
+
* (device disabled, routing changed) is reclaimed, which is what the
|
|
4639
|
+
* guardrail was for; a nightly lull no longer costs a recompile. Override
|
|
4640
|
+
* via env for a memory-constrained node.
|
|
4589
4641
|
*/
|
|
4590
|
-
static DEVICE_POOL_IDLE_TTL_MS =
|
|
4642
|
+
static DEVICE_POOL_IDLE_TTL_MS = Math.max(6e4, Number(process.env["CAMSTACK_DEVICE_POOL_IDLE_TTL_MS"]) || 360 * 60 * 1e3);
|
|
4591
4643
|
deviceReaper = new IdlePoolReaper({
|
|
4592
4644
|
ttlMs: DetectionPipelineProvider.DEVICE_POOL_IDLE_TTL_MS,
|
|
4593
4645
|
dispose: (deviceKey) => this.disposeDevicePool(deviceKey),
|
|
@@ -7154,6 +7206,142 @@ function applyDeviceOverridesToTree(tree, rootStepId, overrides) {
|
|
|
7154
7206
|
} : root) };
|
|
7155
7207
|
}
|
|
7156
7208
|
//#endregion
|
|
7209
|
+
//#region src/detection-pipeline/diagnostics/event-loop-stall-monitor.ts
|
|
7210
|
+
/**
|
|
7211
|
+
* event-loop-stall-monitor — names the thing that freezes the detection process.
|
|
7212
|
+
*
|
|
7213
|
+
* ## What this exists to settle
|
|
7214
|
+
*
|
|
7215
|
+
* On 2026-08-01/02 inference across the whole fleet stalled in bursts: 759
|
|
7216
|
+
* calls in 90 minutes crossed the runner's 20s deadline, p50 41s and max 80s,
|
|
7217
|
+
* and EVERY one of them eventually resolved. Six explanations were killed with
|
|
7218
|
+
* telemetry alone:
|
|
7219
|
+
*
|
|
7220
|
+
* - not a slow GPU or a wedged pool — the work completes, just enormously late;
|
|
7221
|
+
* - not CPU starvation — hosts sat at 8-48% during the freezes;
|
|
7222
|
+
* - not swap — `pswpin/pswpout` are flat zero on every host;
|
|
7223
|
+
* - not a logging artifact — Loki's timestamp trails the in-process one by
|
|
7224
|
+
* 0.1-1.0s at p95, so the gaps are real, not buffered delivery;
|
|
7225
|
+
* - not the v1.2.30 release — the stalls predate it by a day;
|
|
7226
|
+
* - not only the idle-pool recompile — that is real and hub-side, but the
|
|
7227
|
+
* agent stalls with no eviction and no compile at all.
|
|
7228
|
+
*
|
|
7229
|
+
* What IS established: the 10s stats timer — which touches no inference code —
|
|
7230
|
+
* stops firing for 22-88s and resumes at the same instant the abandoned calls
|
|
7231
|
+
* settle. The event loop itself freezes. From outside the process, GC and a
|
|
7232
|
+
* synchronous block are indistinguishable, and guessing between them is how
|
|
7233
|
+
* this investigation would go wrong. So measure it.
|
|
7234
|
+
*
|
|
7235
|
+
* ## How
|
|
7236
|
+
*
|
|
7237
|
+
* A 1s interval whose LATENESS is the freeze: a tick that arrives 40s late means
|
|
7238
|
+
* the loop was blocked ~39s, whatever blocked it. In the same window the GC
|
|
7239
|
+
* observer accumulates pause time. The two numbers together answer the question
|
|
7240
|
+
* outright:
|
|
7241
|
+
*
|
|
7242
|
+
* - `gcMs` ≈ `stallMs` → garbage collection. Look at heap growth.
|
|
7243
|
+
* - `gcMs` ≈ 0 → something SYNCHRONOUS blocked the loop, and GC is innocent.
|
|
7244
|
+
*
|
|
7245
|
+
* Heap and RSS ride along because if it is GC, the next question is always
|
|
7246
|
+
* "growing since when?".
|
|
7247
|
+
*
|
|
7248
|
+
* Deliberately not a histogram (`monitorEventLoopDelay`): that reports a
|
|
7249
|
+
* distribution, and the question here is about ONE outlier and what coincided
|
|
7250
|
+
* with it.
|
|
7251
|
+
*/
|
|
7252
|
+
/** Tick period. Small enough to bound attribution error, large enough to cost
|
|
7253
|
+
* nothing on a process already doing frame work. */
|
|
7254
|
+
var STALL_TICK_MS = 1e3;
|
|
7255
|
+
/**
|
|
7256
|
+
* Report a freeze only past this. The runner's own inference deadline is 20s
|
|
7257
|
+
* and the observed freezes run 22-88s, so this is far below the signal while
|
|
7258
|
+
* staying clear of ordinary scheduling jitter (a busy frame path routinely
|
|
7259
|
+
* overruns a 1s timer by tens of ms).
|
|
7260
|
+
*/
|
|
7261
|
+
var STALL_REPORT_MS = 2e3;
|
|
7262
|
+
/**
|
|
7263
|
+
* Attribute a freeze. Pure, because this is the ONE judgement the whole module
|
|
7264
|
+
* exists to make and it must be testable without freezing a real event loop.
|
|
7265
|
+
*
|
|
7266
|
+
* `gc` when garbage collection accounts for most of the stall; a
|
|
7267
|
+
* `synchronous-block` when it accounts for almost none — that verdict points
|
|
7268
|
+
* away from the heap entirely and at whatever ran without yielding.
|
|
7269
|
+
*/
|
|
7270
|
+
function attributeStall(stallMs, gcMs, gcCount, longestGcMs) {
|
|
7271
|
+
const share = stallMs > 0 ? gcMs / stallMs : 0;
|
|
7272
|
+
return {
|
|
7273
|
+
stallMs,
|
|
7274
|
+
gcMs,
|
|
7275
|
+
gcCount,
|
|
7276
|
+
longestGcMs,
|
|
7277
|
+
cause: share >= .7 ? "gc" : share <= .2 ? "synchronous-block" : "mixed"
|
|
7278
|
+
};
|
|
7279
|
+
}
|
|
7280
|
+
/**
|
|
7281
|
+
* Start watching. Returns a stop function; safe to call twice.
|
|
7282
|
+
*
|
|
7283
|
+
* Never throws into the caller: a diagnostic that can take the detection
|
|
7284
|
+
* process down is worse than no diagnostic. `PerformanceObserver` for `gc` is
|
|
7285
|
+
* unavailable in some embedded runtimes, and that must degrade to
|
|
7286
|
+
* stall-detection-without-attribution rather than a boot failure.
|
|
7287
|
+
*/
|
|
7288
|
+
function startEventLoopStallMonitor(logger, reportThresholdMs = STALL_REPORT_MS) {
|
|
7289
|
+
let gc = {
|
|
7290
|
+
totalMs: 0,
|
|
7291
|
+
count: 0,
|
|
7292
|
+
longestMs: 0
|
|
7293
|
+
};
|
|
7294
|
+
let observer = null;
|
|
7295
|
+
try {
|
|
7296
|
+
observer = new node_perf_hooks.PerformanceObserver((list) => {
|
|
7297
|
+
for (const entry of list.getEntries()) {
|
|
7298
|
+
gc.totalMs += entry.duration;
|
|
7299
|
+
gc.count += 1;
|
|
7300
|
+
if (entry.duration > gc.longestMs) gc.longestMs = entry.duration;
|
|
7301
|
+
}
|
|
7302
|
+
});
|
|
7303
|
+
observer.observe({ entryTypes: ["gc"] });
|
|
7304
|
+
} catch {
|
|
7305
|
+
observer = null;
|
|
7306
|
+
}
|
|
7307
|
+
let expected = Date.now() + STALL_TICK_MS;
|
|
7308
|
+
const timer = setInterval(() => {
|
|
7309
|
+
const now = Date.now();
|
|
7310
|
+
const lateBy = now - expected;
|
|
7311
|
+
const window = gc;
|
|
7312
|
+
gc = {
|
|
7313
|
+
totalMs: 0,
|
|
7314
|
+
count: 0,
|
|
7315
|
+
longestMs: 0
|
|
7316
|
+
};
|
|
7317
|
+
expected = now + STALL_TICK_MS;
|
|
7318
|
+
if (lateBy < reportThresholdMs) return;
|
|
7319
|
+
const verdict = attributeStall(lateBy, Math.round(window.totalMs), window.count, Math.round(window.longestMs));
|
|
7320
|
+
const mem = process.memoryUsage();
|
|
7321
|
+
logger.warn("event loop stalled", { meta: {
|
|
7322
|
+
stallMs: verdict.stallMs,
|
|
7323
|
+
cause: verdict.cause,
|
|
7324
|
+
gcMs: verdict.gcMs,
|
|
7325
|
+
gcCount: verdict.gcCount,
|
|
7326
|
+
longestGcMs: verdict.longestGcMs,
|
|
7327
|
+
gcObserved: observer !== null,
|
|
7328
|
+
heapUsedMb: Math.round(mem.heapUsed / 1e6),
|
|
7329
|
+
heapTotalMb: Math.round(mem.heapTotal / 1e6),
|
|
7330
|
+
rssMb: Math.round(mem.rss / 1e6),
|
|
7331
|
+
externalMb: Math.round(mem.external / 1e6),
|
|
7332
|
+
arrayBuffersMb: Math.round(mem.arrayBuffers / 1e6)
|
|
7333
|
+
} });
|
|
7334
|
+
}, STALL_TICK_MS);
|
|
7335
|
+
timer.unref?.();
|
|
7336
|
+
let stopped = false;
|
|
7337
|
+
return () => {
|
|
7338
|
+
if (stopped) return;
|
|
7339
|
+
stopped = true;
|
|
7340
|
+
clearInterval(timer);
|
|
7341
|
+
observer?.disconnect();
|
|
7342
|
+
};
|
|
7343
|
+
}
|
|
7344
|
+
//#endregion
|
|
7157
7345
|
//#region src/detection-pipeline/python-readiness.ts
|
|
7158
7346
|
/**
|
|
7159
7347
|
* Resolve the embedded Python once, turning the null/throw failure modes into
|
|
@@ -7274,6 +7462,8 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
|
|
|
7274
7462
|
* doesn't flip a remote node's env back to the serving hub's platform. */
|
|
7275
7463
|
lastGoodHardwareByNode = /* @__PURE__ */ new Map();
|
|
7276
7464
|
engineMetricsTimer = null;
|
|
7465
|
+
/** Stops the event-loop stall monitor on shutdown. */
|
|
7466
|
+
stopStallMonitor = null;
|
|
7277
7467
|
/** Snapshot-equality cache for engine-metrics emit. Most ticks
|
|
7278
7468
|
* the engine inventory is unchanged (no model load/unload), so
|
|
7279
7469
|
* we skip the bus emit and let the heartbeat re-emit at
|
|
@@ -7612,6 +7802,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
|
|
|
7612
7802
|
async onInitialize() {
|
|
7613
7803
|
const modelsDir = await this.resolveModelsDir();
|
|
7614
7804
|
if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
|
|
7805
|
+
this.stopStallMonitor = startEventLoopStallMonitor(this.ctx.logger);
|
|
7615
7806
|
this.pythonAddonDir = resolveAddonPythonDir();
|
|
7616
7807
|
const py = await ensurePythonReady(this.ctx.deps, this.ctx.logger);
|
|
7617
7808
|
if (py.ok && py.pythonPath) this.pythonPath = py.pythonPath;
|
|
@@ -7698,6 +7889,10 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
|
|
|
7698
7889
|
}
|
|
7699
7890
|
}
|
|
7700
7891
|
async onShutdown() {
|
|
7892
|
+
if (this.stopStallMonitor) {
|
|
7893
|
+
this.stopStallMonitor();
|
|
7894
|
+
this.stopStallMonitor = null;
|
|
7895
|
+
}
|
|
7701
7896
|
if (this.engineMetricsTimer) {
|
|
7702
7897
|
clearInterval(this.engineMetricsTimer);
|
|
7703
7898
|
this.engineMetricsTimer = null;
|
|
@@ -8,6 +8,7 @@ import { spawn } from "node:child_process";
|
|
|
8
8
|
import sharp from "sharp";
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
10
|
import * as path$1 from "node:path";
|
|
11
|
+
import { PerformanceObserver } from "node:perf_hooks";
|
|
11
12
|
//#region src/detection-pipeline/runtimes.ts
|
|
12
13
|
var KNOWN_PLATFORMS = [
|
|
13
14
|
"darwin",
|
|
@@ -589,8 +590,12 @@ var PoolWorker = class {
|
|
|
589
590
|
this.ready = false;
|
|
590
591
|
});
|
|
591
592
|
this.process.stdout.on("data", (chunk) => {
|
|
593
|
+
const t0 = Date.now();
|
|
592
594
|
this.receiveBuffer = Buffer.concat([this.receiveBuffer, chunk]);
|
|
595
|
+
this.recvConcatMs += Date.now() - t0;
|
|
596
|
+
if (this.receiveBuffer.length > this.recvPeakBytes) this.recvPeakBytes = this.receiveBuffer.length;
|
|
593
597
|
this.drainResponses();
|
|
598
|
+
this.reportRecvCostIfDue();
|
|
594
599
|
});
|
|
595
600
|
const config = {
|
|
596
601
|
runtime: this.opts.poolRuntime,
|
|
@@ -777,7 +782,7 @@ var PoolWorker = class {
|
|
|
777
782
|
device: this.opts.device ?? "default",
|
|
778
783
|
inFlight: this.pending.size,
|
|
779
784
|
reqId,
|
|
780
|
-
timeoutMs
|
|
785
|
+
timeoutMs
|
|
781
786
|
} });
|
|
782
787
|
reject(/* @__PURE__ */ new Error(`PoolWorker[${this.opts.workerLabel}]: inference request ${reqId} timed out after ${timeoutMs}ms (worker alive, no reply)`));
|
|
783
788
|
}
|
|
@@ -833,6 +838,36 @@ var PoolWorker = class {
|
|
|
833
838
|
ensureReady() {
|
|
834
839
|
if (!this.ready || !this.process?.stdin) throw new Error(`PoolWorker[${this.opts.workerLabel}]: not initialized`);
|
|
835
840
|
}
|
|
841
|
+
/** Time spent in `Buffer.concat` since the last report. */
|
|
842
|
+
recvConcatMs = 0;
|
|
843
|
+
/** Largest the receive buffer has grown since the last report. */
|
|
844
|
+
recvPeakBytes = 0;
|
|
845
|
+
/** When the last receive-cost report was emitted. */
|
|
846
|
+
recvReportedAt = 0;
|
|
847
|
+
/**
|
|
848
|
+
* Emit the receive-path cost at most once every 10s, and only when it is
|
|
849
|
+
* actually interesting — silence when the reader costs nothing is the whole
|
|
850
|
+
* point, otherwise this becomes another line nobody reads.
|
|
851
|
+
*/
|
|
852
|
+
reportRecvCostIfDue() {
|
|
853
|
+
const now = Date.now();
|
|
854
|
+
if (this.recvReportedAt === 0) this.recvReportedAt = now;
|
|
855
|
+
if (now - this.recvReportedAt < 1e4) return;
|
|
856
|
+
const concatMs = this.recvConcatMs;
|
|
857
|
+
const peak = this.recvPeakBytes;
|
|
858
|
+
this.recvConcatMs = 0;
|
|
859
|
+
this.recvPeakBytes = 0;
|
|
860
|
+
this.recvReportedAt = now;
|
|
861
|
+
if (concatMs < 250) return;
|
|
862
|
+
this.log.warn("pool receive path is copying", { meta: {
|
|
863
|
+
worker: this.opts.workerLabel,
|
|
864
|
+
runtime: this.opts.poolRuntime,
|
|
865
|
+
device: this.opts.device ?? "default",
|
|
866
|
+
concatMs,
|
|
867
|
+
peakBufferKb: Math.round(peak / 1024),
|
|
868
|
+
windowMs: 1e4
|
|
869
|
+
} });
|
|
870
|
+
}
|
|
836
871
|
drainResponses() {
|
|
837
872
|
while (this.receiveBuffer.length >= 8) {
|
|
838
873
|
const totalLen = this.receiveBuffer.readUInt32LE(0);
|
|
@@ -4576,10 +4611,27 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
|
|
|
4576
4611
|
* Idle-TTL for a per-device inference pool (multi-device C3). A device pool
|
|
4577
4612
|
* with no dispatch for this long is disposed and recreated on next use — the
|
|
4578
4613
|
* memory guardrail that keeps `factoriesByDevice` from accumulating warm pools
|
|
4579
|
-
* forever
|
|
4580
|
-
*
|
|
4614
|
+
* forever as an operator toggles device sets over a long run.
|
|
4615
|
+
*
|
|
4616
|
+
* It was 120s, on the stated assumption that this was "well above any real
|
|
4617
|
+
* inter-session gap for an active camera". Measured 2026-08-02, that is
|
|
4618
|
+
* false: detection is dispatched ON MOTION, so at night the gap between
|
|
4619
|
+
* events routinely exceeds two minutes, and the eviction fires constantly.
|
|
4620
|
+
* The rebuild is not cheap — `ensureModelsForSteps` loads EVERY enabled step
|
|
4621
|
+
* into the fresh factory, which on the hub is seven models
|
|
4622
|
+
* (yolov9m-320-int8, scrfd-2.5g, arcface-r100, yolov8n-plate,
|
|
4623
|
+
* vgg_english_g2, mobileclip-s1-vision, yolov8n-package). The hub log shows
|
|
4624
|
+
* the cycle plainly: `COMPILE ×7` → shed within 7-15s → `EVICT` ~2.5 min
|
|
4625
|
+
* later → repeat. So the first seconds of a motion event — precisely when
|
|
4626
|
+
* detection matters — were spent recompiling models that had just been
|
|
4627
|
+
* thrown away.
|
|
4628
|
+
*
|
|
4629
|
+
* Hours, not minutes. At this TTL only a pool that is genuinely unused
|
|
4630
|
+
* (device disabled, routing changed) is reclaimed, which is what the
|
|
4631
|
+
* guardrail was for; a nightly lull no longer costs a recompile. Override
|
|
4632
|
+
* via env for a memory-constrained node.
|
|
4581
4633
|
*/
|
|
4582
|
-
static DEVICE_POOL_IDLE_TTL_MS =
|
|
4634
|
+
static DEVICE_POOL_IDLE_TTL_MS = Math.max(6e4, Number(process.env["CAMSTACK_DEVICE_POOL_IDLE_TTL_MS"]) || 360 * 60 * 1e3);
|
|
4583
4635
|
deviceReaper = new IdlePoolReaper({
|
|
4584
4636
|
ttlMs: DetectionPipelineProvider.DEVICE_POOL_IDLE_TTL_MS,
|
|
4585
4637
|
dispose: (deviceKey) => this.disposeDevicePool(deviceKey),
|
|
@@ -7146,6 +7198,142 @@ function applyDeviceOverridesToTree(tree, rootStepId, overrides) {
|
|
|
7146
7198
|
} : root) };
|
|
7147
7199
|
}
|
|
7148
7200
|
//#endregion
|
|
7201
|
+
//#region src/detection-pipeline/diagnostics/event-loop-stall-monitor.ts
|
|
7202
|
+
/**
|
|
7203
|
+
* event-loop-stall-monitor — names the thing that freezes the detection process.
|
|
7204
|
+
*
|
|
7205
|
+
* ## What this exists to settle
|
|
7206
|
+
*
|
|
7207
|
+
* On 2026-08-01/02 inference across the whole fleet stalled in bursts: 759
|
|
7208
|
+
* calls in 90 minutes crossed the runner's 20s deadline, p50 41s and max 80s,
|
|
7209
|
+
* and EVERY one of them eventually resolved. Six explanations were killed with
|
|
7210
|
+
* telemetry alone:
|
|
7211
|
+
*
|
|
7212
|
+
* - not a slow GPU or a wedged pool — the work completes, just enormously late;
|
|
7213
|
+
* - not CPU starvation — hosts sat at 8-48% during the freezes;
|
|
7214
|
+
* - not swap — `pswpin/pswpout` are flat zero on every host;
|
|
7215
|
+
* - not a logging artifact — Loki's timestamp trails the in-process one by
|
|
7216
|
+
* 0.1-1.0s at p95, so the gaps are real, not buffered delivery;
|
|
7217
|
+
* - not the v1.2.30 release — the stalls predate it by a day;
|
|
7218
|
+
* - not only the idle-pool recompile — that is real and hub-side, but the
|
|
7219
|
+
* agent stalls with no eviction and no compile at all.
|
|
7220
|
+
*
|
|
7221
|
+
* What IS established: the 10s stats timer — which touches no inference code —
|
|
7222
|
+
* stops firing for 22-88s and resumes at the same instant the abandoned calls
|
|
7223
|
+
* settle. The event loop itself freezes. From outside the process, GC and a
|
|
7224
|
+
* synchronous block are indistinguishable, and guessing between them is how
|
|
7225
|
+
* this investigation would go wrong. So measure it.
|
|
7226
|
+
*
|
|
7227
|
+
* ## How
|
|
7228
|
+
*
|
|
7229
|
+
* A 1s interval whose LATENESS is the freeze: a tick that arrives 40s late means
|
|
7230
|
+
* the loop was blocked ~39s, whatever blocked it. In the same window the GC
|
|
7231
|
+
* observer accumulates pause time. The two numbers together answer the question
|
|
7232
|
+
* outright:
|
|
7233
|
+
*
|
|
7234
|
+
* - `gcMs` ≈ `stallMs` → garbage collection. Look at heap growth.
|
|
7235
|
+
* - `gcMs` ≈ 0 → something SYNCHRONOUS blocked the loop, and GC is innocent.
|
|
7236
|
+
*
|
|
7237
|
+
* Heap and RSS ride along because if it is GC, the next question is always
|
|
7238
|
+
* "growing since when?".
|
|
7239
|
+
*
|
|
7240
|
+
* Deliberately not a histogram (`monitorEventLoopDelay`): that reports a
|
|
7241
|
+
* distribution, and the question here is about ONE outlier and what coincided
|
|
7242
|
+
* with it.
|
|
7243
|
+
*/
|
|
7244
|
+
/** Tick period. Small enough to bound attribution error, large enough to cost
|
|
7245
|
+
* nothing on a process already doing frame work. */
|
|
7246
|
+
var STALL_TICK_MS = 1e3;
|
|
7247
|
+
/**
|
|
7248
|
+
* Report a freeze only past this. The runner's own inference deadline is 20s
|
|
7249
|
+
* and the observed freezes run 22-88s, so this is far below the signal while
|
|
7250
|
+
* staying clear of ordinary scheduling jitter (a busy frame path routinely
|
|
7251
|
+
* overruns a 1s timer by tens of ms).
|
|
7252
|
+
*/
|
|
7253
|
+
var STALL_REPORT_MS = 2e3;
|
|
7254
|
+
/**
|
|
7255
|
+
* Attribute a freeze. Pure, because this is the ONE judgement the whole module
|
|
7256
|
+
* exists to make and it must be testable without freezing a real event loop.
|
|
7257
|
+
*
|
|
7258
|
+
* `gc` when garbage collection accounts for most of the stall; a
|
|
7259
|
+
* `synchronous-block` when it accounts for almost none — that verdict points
|
|
7260
|
+
* away from the heap entirely and at whatever ran without yielding.
|
|
7261
|
+
*/
|
|
7262
|
+
function attributeStall(stallMs, gcMs, gcCount, longestGcMs) {
|
|
7263
|
+
const share = stallMs > 0 ? gcMs / stallMs : 0;
|
|
7264
|
+
return {
|
|
7265
|
+
stallMs,
|
|
7266
|
+
gcMs,
|
|
7267
|
+
gcCount,
|
|
7268
|
+
longestGcMs,
|
|
7269
|
+
cause: share >= .7 ? "gc" : share <= .2 ? "synchronous-block" : "mixed"
|
|
7270
|
+
};
|
|
7271
|
+
}
|
|
7272
|
+
/**
|
|
7273
|
+
* Start watching. Returns a stop function; safe to call twice.
|
|
7274
|
+
*
|
|
7275
|
+
* Never throws into the caller: a diagnostic that can take the detection
|
|
7276
|
+
* process down is worse than no diagnostic. `PerformanceObserver` for `gc` is
|
|
7277
|
+
* unavailable in some embedded runtimes, and that must degrade to
|
|
7278
|
+
* stall-detection-without-attribution rather than a boot failure.
|
|
7279
|
+
*/
|
|
7280
|
+
function startEventLoopStallMonitor(logger, reportThresholdMs = STALL_REPORT_MS) {
|
|
7281
|
+
let gc = {
|
|
7282
|
+
totalMs: 0,
|
|
7283
|
+
count: 0,
|
|
7284
|
+
longestMs: 0
|
|
7285
|
+
};
|
|
7286
|
+
let observer = null;
|
|
7287
|
+
try {
|
|
7288
|
+
observer = new PerformanceObserver((list) => {
|
|
7289
|
+
for (const entry of list.getEntries()) {
|
|
7290
|
+
gc.totalMs += entry.duration;
|
|
7291
|
+
gc.count += 1;
|
|
7292
|
+
if (entry.duration > gc.longestMs) gc.longestMs = entry.duration;
|
|
7293
|
+
}
|
|
7294
|
+
});
|
|
7295
|
+
observer.observe({ entryTypes: ["gc"] });
|
|
7296
|
+
} catch {
|
|
7297
|
+
observer = null;
|
|
7298
|
+
}
|
|
7299
|
+
let expected = Date.now() + STALL_TICK_MS;
|
|
7300
|
+
const timer = setInterval(() => {
|
|
7301
|
+
const now = Date.now();
|
|
7302
|
+
const lateBy = now - expected;
|
|
7303
|
+
const window = gc;
|
|
7304
|
+
gc = {
|
|
7305
|
+
totalMs: 0,
|
|
7306
|
+
count: 0,
|
|
7307
|
+
longestMs: 0
|
|
7308
|
+
};
|
|
7309
|
+
expected = now + STALL_TICK_MS;
|
|
7310
|
+
if (lateBy < reportThresholdMs) return;
|
|
7311
|
+
const verdict = attributeStall(lateBy, Math.round(window.totalMs), window.count, Math.round(window.longestMs));
|
|
7312
|
+
const mem = process.memoryUsage();
|
|
7313
|
+
logger.warn("event loop stalled", { meta: {
|
|
7314
|
+
stallMs: verdict.stallMs,
|
|
7315
|
+
cause: verdict.cause,
|
|
7316
|
+
gcMs: verdict.gcMs,
|
|
7317
|
+
gcCount: verdict.gcCount,
|
|
7318
|
+
longestGcMs: verdict.longestGcMs,
|
|
7319
|
+
gcObserved: observer !== null,
|
|
7320
|
+
heapUsedMb: Math.round(mem.heapUsed / 1e6),
|
|
7321
|
+
heapTotalMb: Math.round(mem.heapTotal / 1e6),
|
|
7322
|
+
rssMb: Math.round(mem.rss / 1e6),
|
|
7323
|
+
externalMb: Math.round(mem.external / 1e6),
|
|
7324
|
+
arrayBuffersMb: Math.round(mem.arrayBuffers / 1e6)
|
|
7325
|
+
} });
|
|
7326
|
+
}, STALL_TICK_MS);
|
|
7327
|
+
timer.unref?.();
|
|
7328
|
+
let stopped = false;
|
|
7329
|
+
return () => {
|
|
7330
|
+
if (stopped) return;
|
|
7331
|
+
stopped = true;
|
|
7332
|
+
clearInterval(timer);
|
|
7333
|
+
observer?.disconnect();
|
|
7334
|
+
};
|
|
7335
|
+
}
|
|
7336
|
+
//#endregion
|
|
7149
7337
|
//#region src/detection-pipeline/python-readiness.ts
|
|
7150
7338
|
/**
|
|
7151
7339
|
* Resolve the embedded Python once, turning the null/throw failure modes into
|
|
@@ -7266,6 +7454,8 @@ var DetectionPipelineAddon = class extends BaseAddon {
|
|
|
7266
7454
|
* doesn't flip a remote node's env back to the serving hub's platform. */
|
|
7267
7455
|
lastGoodHardwareByNode = /* @__PURE__ */ new Map();
|
|
7268
7456
|
engineMetricsTimer = null;
|
|
7457
|
+
/** Stops the event-loop stall monitor on shutdown. */
|
|
7458
|
+
stopStallMonitor = null;
|
|
7269
7459
|
/** Snapshot-equality cache for engine-metrics emit. Most ticks
|
|
7270
7460
|
* the engine inventory is unchanged (no model load/unload), so
|
|
7271
7461
|
* we skip the bus emit and let the heartbeat re-emit at
|
|
@@ -7604,6 +7794,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
|
|
|
7604
7794
|
async onInitialize() {
|
|
7605
7795
|
const modelsDir = await this.resolveModelsDir();
|
|
7606
7796
|
if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
|
|
7797
|
+
this.stopStallMonitor = startEventLoopStallMonitor(this.ctx.logger);
|
|
7607
7798
|
this.pythonAddonDir = resolveAddonPythonDir();
|
|
7608
7799
|
const py = await ensurePythonReady(this.ctx.deps, this.ctx.logger);
|
|
7609
7800
|
if (py.ok && py.pythonPath) this.pythonPath = py.pythonPath;
|
|
@@ -7690,6 +7881,10 @@ var DetectionPipelineAddon = class extends BaseAddon {
|
|
|
7690
7881
|
}
|
|
7691
7882
|
}
|
|
7692
7883
|
async onShutdown() {
|
|
7884
|
+
if (this.stopStallMonitor) {
|
|
7885
|
+
this.stopStallMonitor();
|
|
7886
|
+
this.stopStallMonitor = null;
|
|
7887
|
+
}
|
|
7693
7888
|
if (this.engineMetricsTimer) {
|
|
7694
7889
|
clearInterval(this.engineMetricsTimer);
|
|
7695
7890
|
this.engineMetricsTimer = null;
|
|
@@ -3,7 +3,7 @@ import "./dist-CYZr2fwk.mjs";
|
|
|
3
3
|
var e = {
|
|
4
4
|
"@camstack/sdk": {
|
|
5
5
|
name: "@camstack/sdk",
|
|
6
|
-
version: "1.2.
|
|
6
|
+
version: "1.2.9",
|
|
7
7
|
scope: ["default"],
|
|
8
8
|
loaded: !1,
|
|
9
9
|
from: "addon_stream_broker_widgets",
|
|
@@ -18,7 +18,7 @@ var e = {
|
|
|
18
18
|
},
|
|
19
19
|
"@camstack/types": {
|
|
20
20
|
name: "@camstack/types",
|
|
21
|
-
version: "1.2.
|
|
21
|
+
version: "1.2.20",
|
|
22
22
|
scope: ["default"],
|
|
23
23
|
loaded: !1,
|
|
24
24
|
from: "addon_stream_broker_widgets",
|
|
@@ -33,7 +33,7 @@ var e = {
|
|
|
33
33
|
},
|
|
34
34
|
"@camstack/ui-library": {
|
|
35
35
|
name: "@camstack/ui-library",
|
|
36
|
-
version: "1.2.
|
|
36
|
+
version: "1.2.16",
|
|
37
37
|
scope: ["default"],
|
|
38
38
|
loaded: !1,
|
|
39
39
|
from: "addon_stream_broker_widgets",
|
|
@@ -36,7 +36,7 @@ async function r() {
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"@camstack/types": {
|
|
39
|
-
version: "1.2.
|
|
39
|
+
version: "1.2.20",
|
|
40
40
|
scope: "default",
|
|
41
41
|
shareConfig: {
|
|
42
42
|
singleton: !0,
|
|
@@ -45,7 +45,7 @@ async function r() {
|
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
47
|
"@camstack/sdk": {
|
|
48
|
-
version: "1.2.
|
|
48
|
+
version: "1.2.9",
|
|
49
49
|
scope: "default",
|
|
50
50
|
shareConfig: {
|
|
51
51
|
singleton: !0,
|
|
@@ -81,7 +81,7 @@ async function r() {
|
|
|
81
81
|
}
|
|
82
82
|
},
|
|
83
83
|
"@camstack/ui-library": {
|
|
84
|
-
version: "1.2.
|
|
84
|
+
version: "1.2.16",
|
|
85
85
|
scope: "default",
|
|
86
86
|
shareConfig: {
|
|
87
87
|
singleton: !0,
|
|
@@ -30,7 +30,7 @@ async function d(e) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
async function f() {
|
|
33
|
-
return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-
|
|
33
|
+
return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-ByuMjhL3.mjs")).catch((e) => {
|
|
34
34
|
throw l = void 0, e;
|
|
35
35
|
}), l;
|
|
36
36
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-pipeline",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.38",
|
|
4
4
|
"description": "CamStack Pipeline bundle — runner, detection, motion, audio + stream broker. Multi-entry npm package shipping pipeline addons under a single bundle.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|