@camstack/addon-pipeline 1.2.36 → 1.2.37

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 (58) hide show
  1. package/dist/detection-pipeline/index.js +199 -4
  2. package/dist/detection-pipeline/index.mjs +199 -4
  3. package/package.json +1 -1
  4. package/python/__pycache__/inference_pool.cpython-312.pyc +0 -0
  5. package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
  6. package/python/__pycache__/inference_pool.cpython-314.pyc +0 -0
  7. package/python/__pycache__/test_inference_pool_backpressure.cpython-314.pyc +0 -0
  8. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-314.pyc +0 -0
  9. package/python/__pycache__/test_inference_pool_device_selection.cpython-314.pyc +0 -0
  10. package/python/__pycache__/test_inference_pool_layout.cpython-314.pyc +0 -0
  11. package/python/__pycache__/test_inference_pool_preprocess.cpython-314.pyc +0 -0
  12. package/python/__pycache__/yamnet_audio.cpython-314.pyc +0 -0
  13. package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
  14. package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
  15. package/python/postprocessors/__pycache__/__init__.cpython-314.pyc +0 -0
  16. package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
  17. package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
  18. package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
  19. package/python/postprocessors/__pycache__/arcface.cpython-314.pyc +0 -0
  20. package/python/postprocessors/__pycache__/clip.cpython-312.pyc +0 -0
  21. package/python/postprocessors/__pycache__/clip.cpython-313.pyc +0 -0
  22. package/python/postprocessors/__pycache__/clip.cpython-314.pyc +0 -0
  23. package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
  24. package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
  25. package/python/postprocessors/__pycache__/ctc.cpython-314.pyc +0 -0
  26. package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
  27. package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
  28. package/python/postprocessors/__pycache__/saliency.cpython-314.pyc +0 -0
  29. package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
  30. package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
  31. package/python/postprocessors/__pycache__/scrfd.cpython-314.pyc +0 -0
  32. package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
  33. package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
  34. package/python/postprocessors/__pycache__/softmax.cpython-314.pyc +0 -0
  35. package/python/postprocessors/__pycache__/ssd.cpython-312.pyc +0 -0
  36. package/python/postprocessors/__pycache__/ssd.cpython-313.pyc +0 -0
  37. package/python/postprocessors/__pycache__/ssd.cpython-314.pyc +0 -0
  38. package/python/postprocessors/__pycache__/test_clip.cpython-312-pytest-9.1.1.pyc +0 -0
  39. package/python/postprocessors/__pycache__/test_clip.cpython-313-pytest-9.1.1.pyc +0 -0
  40. package/python/postprocessors/__pycache__/test_ctc.cpython-312-pytest-9.1.1.pyc +0 -0
  41. package/python/postprocessors/__pycache__/test_ctc.cpython-312.pyc +0 -0
  42. package/python/postprocessors/__pycache__/test_ctc.cpython-313-pytest-9.1.1.pyc +0 -0
  43. package/python/postprocessors/__pycache__/test_ctc.cpython-313.pyc +0 -0
  44. package/python/postprocessors/__pycache__/test_saliency.cpython-312-pytest-9.1.1.pyc +0 -0
  45. package/python/postprocessors/__pycache__/test_saliency.cpython-313-pytest-9.1.1.pyc +0 -0
  46. package/python/postprocessors/__pycache__/test_softmax.cpython-312-pytest-9.1.1.pyc +0 -0
  47. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-312-pytest-9.1.1.pyc +0 -0
  48. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-39.pyc +0 -0
  49. package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
  50. package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
  51. package/python/postprocessors/__pycache__/yamnet.cpython-314.pyc +0 -0
  52. package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
  53. package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
  54. package/python/postprocessors/__pycache__/yolo.cpython-314.pyc +0 -0
  55. package/python/postprocessors/__pycache__/yolo.cpython-39.pyc +0 -0
  56. package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
  57. package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
  58. 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: POOL_INFER_TIMEOUT_MS
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. Longer than the override cache (60s) since a device pool is heavier
4588
- * to respin; still well above any real inter-session gap for an active camera.
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 = 12e4;
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: POOL_INFER_TIMEOUT_MS
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. Longer than the override cache (60s) since a device pool is heavier
4580
- * to respin; still well above any real inter-session gap for an active camera.
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 = 12e4;
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline",
3
- "version": "1.2.36",
3
+ "version": "1.2.37",
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",