@camstack/system 1.2.102 → 1.2.104

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 (62) hide show
  1. package/dist/addon-runner.js +15 -1
  2. package/dist/addon-runner.mjs +15 -1
  3. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/alerts/alerts.addon.js +1 -1
  8. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  11. package/dist/builtins/console-logging/index.js +1 -1
  12. package/dist/builtins/console-logging/index.mjs +1 -1
  13. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  14. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  15. package/dist/builtins/device-manager/device-manager.addon.js +1 -1
  16. package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
  17. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  18. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  19. package/dist/builtins/hub-forwarder/index.js +1 -1
  20. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  21. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  22. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  23. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  24. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  25. package/dist/builtins/local-network/local-network.addon.js +1 -1
  26. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  27. package/dist/builtins/loki-logging/index.js +1 -1
  28. package/dist/builtins/loki-logging/index.mjs +1 -1
  29. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  30. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  31. package/dist/builtins/platform-probe/index.js +1 -1
  32. package/dist/builtins/platform-probe/index.mjs +1 -1
  33. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  34. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  35. package/dist/builtins/snapshot/index.js +150 -22
  36. package/dist/builtins/snapshot/index.mjs +150 -22
  37. package/dist/builtins/snapshot/snapshot-cache.d.ts +63 -0
  38. package/dist/builtins/snapshot/snapshot-resize.d.ts +33 -1
  39. package/dist/builtins/snapshot/snapshot.addon.d.ts +6 -0
  40. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  41. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  42. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
  43. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
  44. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  45. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  46. package/dist/builtins/system-config/system-config.addon.js +1 -1
  47. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  48. package/dist/builtins/winston-logging/index.js +1 -1
  49. package/dist/builtins/winston-logging/index.mjs +1 -1
  50. package/dist/{dist-3wh5LCad.mjs → dist-CAzHQ03i.mjs} +144 -21
  51. package/dist/{dist-9sC0wZw6.js → dist-DR1VmGz6.js} +144 -21
  52. package/dist/index.d.ts +2 -2
  53. package/dist/index.js +243 -328
  54. package/dist/index.mjs +233 -319
  55. package/dist/kernel/config-schema.d.ts +14 -10
  56. package/dist/kernel/heap-watch.d.ts +106 -7
  57. package/dist/kernel/moleculer/process-service.d.ts +34 -0
  58. package/dist/logging/log-manager.d.ts +7 -5
  59. package/dist/logging/partitioned-log-buffer.d.ts +95 -16
  60. package/dist/{manifest-python-deps-Bt_J04b0.mjs → manifest-python-deps-5ei2xVUh.mjs} +355 -1
  61. package/dist/{manifest-python-deps-BtVjvfvj.js → manifest-python-deps-GejnH--L.js} +422 -0
  62. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("./chunk-Cek0wNdY.js");
3
- const require_dist = require("./dist-9sC0wZw6.js");
3
+ const require_dist = require("./dist-DR1VmGz6.js");
4
4
  const require_builtins_alerts_alerts_addon = require("./builtins/alerts/alerts.addon.js");
5
5
  require("./builtins/alerts/index.js");
6
6
  const require_formatter = require("./formatter-DqAKDlvN.js");
@@ -25,7 +25,7 @@ const require_builtins_system_config_system_config_addon = require("./builtins/s
25
25
  require("./builtins/system-config/index.js");
26
26
  const require_builtins_winston_logging_index = require("./builtins/winston-logging/index.js");
27
27
  const require_file_data_plane = require("./file-data-plane-DUHPHa-Y.js");
28
- const require_manifest_python_deps = require("./manifest-python-deps-BtVjvfvj.js");
28
+ const require_manifest_python_deps = require("./manifest-python-deps-GejnH--L.js");
29
29
  const require_resource_monitor = require("./resource-monitor-CdnzxBLP.js");
30
30
  const require_custom_action_registry = require("./custom-action-registry-jY0NOZK8.js");
31
31
  let zod = require("zod");
@@ -43,8 +43,6 @@ let node_fs = require("node:fs");
43
43
  let node_fs$1 = require_chunk.__toESM(node_fs, 1);
44
44
  node_fs = require_chunk.__toESM(node_fs);
45
45
  let node_http = require("node:http");
46
- let node_v8 = require("node:v8");
47
- node_v8 = require_chunk.__toESM(node_v8);
48
46
  let node_vm = require("node:vm");
49
47
  node_vm = require_chunk.__toESM(node_vm);
50
48
  let _camstack_types_addon = require("@camstack/types/addon");
@@ -431,271 +429,6 @@ function proxyToUpstream(opts) {
431
429
  else clientReq.pipe(upstream);
432
430
  }
433
431
  //#endregion
434
- //#region src/kernel/heap-watch.ts
435
- /**
436
- * heap-watch — hub-main's own memory, on the record, and returned to the OS.
437
- *
438
- * ## Why this exists
439
- *
440
- * hub-main used to be launched with `--max-old-space-size=1536` (docker-entrypoint;
441
- * the cap was removed on 2026-08-02, so the live ceiling is now V8's own default —
442
- * 4288MB in this container — and this module reads it at runtime rather than
443
- * assuming a number). It logged NOTHING about its own heap. When it died on
444
- * 2026-08-01/02 the only trace was V8's own stderr line:
445
- *
446
- * FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
447
- *
448
- * which carries no process name, no heap number and no history — the whole
449
- * server restarts and the evidence is gone. Four of those in ~15h, and the one
450
- * at 06:57:57 landed nine seconds after a viewer opened a WebRTC session, which
451
- * is why the app sat in a permanent "loading": the hub was dying as it
452
- * connected.
453
- *
454
- * `[mem]` reports already exist for forked addon runners (SIGUSR2 in
455
- * `addon-runner.ts`) — hub-main, the one process that actually OOMs, was the
456
- * one process with no telemetry at all.
457
- *
458
- * ## What it gives
459
- *
460
- * A heartbeat carrying the numbers that separate the two possible stories, and
461
- * a WARN as the heap approaches the ceiling — the line that will exist BEFORE
462
- * the next fatal instead of after it:
463
- *
464
- * - `heapUsedMb` climbing steadily between restarts → a leak;
465
- * - `heapUsedMb` flat with a sharp jump under load → a burst allocation, and
466
- * the timestamp says what was being served.
467
- *
468
- * `external`/`arrayBuffers` ride along because they are NOT bounded by
469
- * `--max-old-space-size` and are most of the gap between a 1.5GB cap and the
470
- * ~2.05GB RSS observed live: a diagnosis that only looks at the JS heap
471
- * misreads that process by 500MB.
472
- *
473
- * ## And then the heartbeat explained the "off-heap leak", which was not one
474
- *
475
- * 2026-08-08, measured on the live hub over 1019 consecutive samples and seven
476
- * separate boots: **hub-main's RSS is a HIGH-WATER MARK, not a leak.** In every
477
- * one of those 1019 samples RSS was >= the running maximum of
478
- * `heapTotal + external`, never once below it, and
479
- * `RSS_peak - peak(heapTotal + external)` landed between 874MB and 1639MB on
480
- * every boot — a roughly constant native floor on top of a number that only
481
- * ever goes up. RSS tracked the highest point the process ever reached and
482
- * stayed there: 3.3GB -> 4.5GB -> 5.2GB, and 7.8GB on the worst boot.
483
- *
484
- * Two things push that peak, and neither is a retained object:
485
- *
486
- * - the JS heap sawtooths to 2.0-2.8GB because the ceiling is 4288MB and
487
- * nothing creates pressure before then;
488
- * - `arrayBuffers` accumulates DEAD Buffers — up to 3.65GB — because V8 only
489
- * sweeps them on a major GC. Eleven collapses were captured, and every
490
- * single one coincides with `heapUsed` dropping to its ~650MB floor. No code
491
- * released a reference at 13:23:03; a mark-compact ran.
492
- *
493
- * At the peak V8 has committed the pages, and **an ordinary major GC does not
494
- * give them back.** Proven on the production hub: `smaps` showed 10,819 mapped
495
- * 256KB pages (V8's `kRegularPageSize`) holding 2704MB while V8 itself reported
496
- * `total_physical_size` of only 846MB — ~7,800 pages committed, resident, and
497
- * outside V8's own accounting. One `gc({ flavor: 'last-resort' })` took 417ms
498
- * and returned RSS 3355MB -> 1237MB, pages 10,819 -> 2,963.
499
- *
500
- * So the fix for the "~3GB/2h off-heap growth" is not a retainer hunt. It is to
501
- * ask V8 for the pages back when the process is visibly holding more than it is
502
- * using — which is what `shouldReclaim` decides and what the heartbeat now does.
503
- *
504
- * The cost is a 250-450ms stop-the-world on the hub's router process, bounded to
505
- * once per `HEAP_RECLAIM_MIN_INTERVAL_MS`. The alternative, measured, is a
506
- * ratchet to 7.8GB and the OOM that ends it.
507
- */
508
- /** Heartbeat period. Slow enough to be free, fast enough that the sample before
509
- * a fatal is recent. */
510
- var HEAP_WATCH_INTERVAL_MS = 6e4;
511
- /** Warn above this share of the heap ceiling. Chosen so the warning precedes
512
- * the fatal by more than one interval under realistic growth. */
513
- var HEAP_WATCH_WARN_RATIO = .8;
514
- /**
515
- * Escalate above this share of the ceiling.
516
- *
517
- * 0.70 is 3002MB: clear of the measured routine max (2847MB) so steady state
518
- * never escalates, and below {@link HEAP_WATCH_WARN_RATIO} so the fast cadence
519
- * is already running by the time the warn is reachable.
520
- */
521
- var HEAP_WATCH_ESCALATE_RATIO = .7;
522
- /**
523
- * Release escalation below this share.
524
- *
525
- * Hysteresis, not a second threshold: hub-main's heap sawtooths continuously,
526
- * so a single ratio would flap the mode on every GC. 0.60 is 2573MB — a real
527
- * retreat rather than the trough of one collection.
528
- */
529
- var HEAP_WATCH_DEESCALATE_RATIO = .6;
530
- /**
531
- * The cadence state machine, pure so the thresholds are testable without
532
- * allocating three gigabytes to reach them.
533
- */
534
- function nextMode(current, usedRatio, escalateRatio = HEAP_WATCH_ESCALATE_RATIO, deescalateRatio = HEAP_WATCH_DEESCALATE_RATIO) {
535
- if (current === "escalated") return usedRatio < deescalateRatio ? "steady" : "escalated";
536
- return usedRatio >= escalateRatio ? "escalated" : "steady";
537
- }
538
- /**
539
- * Reclaim once the process holds this many MB beyond what it is USING.
540
- *
541
- * Live calibration, both readings from the same hub minutes apart: 2491MB
542
- * stranded before the reclaim, 438MB after. 438MB is this process's irreducible
543
- * native floor (sqlite pages, libvips, sharp, thread stacks, node's own text) —
544
- * collecting against it would buy nothing and cost a stall every minute. 1536
545
- * sits clear of that floor and still fires ~1.1GB before the ratchet is worth
546
- * anything, so it triggers on the real condition and stops as soon as it is fixed.
547
- */
548
- var HEAP_RECLAIM_TRIGGER_MB = 1536;
549
- /** Floor on how often the stop-the-world may run. 250-450ms measured, so ten
550
- * minutes keeps the worst case under 0.1% of the router's time. */
551
- var HEAP_RECLAIM_MIN_INTERVAL_MS = 6e5;
552
- var MB = (bytes) => Math.round(bytes / 1048576);
553
- /**
554
- * Build a sample from raw readings. Pure so the thresholds are testable without
555
- * allocating a gigabyte to reach them.
556
- */
557
- function buildHeapSample(mem, heapLimitBytes, warnRatio = HEAP_WATCH_WARN_RATIO) {
558
- const usedRatio = heapLimitBytes > 0 ? mem.heapUsed / heapLimitBytes : 0;
559
- return {
560
- rssMb: MB(mem.rss),
561
- heapUsedMb: MB(mem.heapUsed),
562
- heapTotalMb: MB(mem.heapTotal),
563
- heapLimitMb: MB(heapLimitBytes),
564
- externalMb: MB(mem.external),
565
- arrayBuffersMb: MB(mem.arrayBuffers),
566
- usedRatio: Math.round(usedRatio * 100) / 100,
567
- nearLimit: usedRatio >= warnRatio
568
- };
569
- }
570
- var consoleSink = {
571
- info: (line) => console.log(line),
572
- warn: (line) => console.warn(line)
573
- };
574
- /**
575
- * RSS the process is holding but not using.
576
- *
577
- * The subtrahend is heapUSED, not heapTotal, deliberately: the pages V8
578
- * committed at a sawtooth peak and no longer needs are counted inside heapTotal,
579
- * and they are precisely what this number exists to see.
580
- */
581
- function strandedMb(s) {
582
- return s.rssMb - s.heapUsedMb - s.externalMb;
583
- }
584
- /** Whether it is worth paying a stop-the-world to get those pages back. */
585
- function shouldReclaim(s, triggerMb = HEAP_RECLAIM_TRIGGER_MB) {
586
- return strandedMb(s) > triggerMb;
587
- }
588
- function isGcFunction(value) {
589
- return typeof value === "function";
590
- }
591
- /**
592
- * Build the reclaimer without requiring `--expose-gc` on argv.
593
- *
594
- * The flag is turned on, the function captured, and the flag turned straight
595
- * back off — a global `gc` left reachable by application code is a footgun, and
596
- * requiring an argv change would mean this cannot ship without a new image.
597
- * Verified against the live hub before being written: 266ms, RSS 1453 -> 1237.
598
- *
599
- * Returns undefined if V8 declines, so the caller keeps a heartbeat either way.
600
- */
601
- function createV8Reclaimer() {
602
- try {
603
- node_v8.setFlagsFromString("--expose-gc");
604
- const gc = node_vm.runInNewContext("gc");
605
- node_v8.setFlagsFromString("--no-expose-gc");
606
- if (!isGcFunction(gc)) return void 0;
607
- return () => gc({
608
- execution: "sync",
609
- flavor: "last-resort"
610
- });
611
- } catch {
612
- return;
613
- }
614
- }
615
- function format$1(label, s) {
616
- return `[mem] ${label} rss=${s.rssMb}MB heapUsed=${s.heapUsedMb}MB heapTotal=${s.heapTotalMb}MB heapLimit=${s.heapLimitMb}MB used=${Math.round(s.usedRatio * 100)}% external=${s.externalMb}MB arrayBuffers=${s.arrayBuffersMb}MB`;
617
- }
618
- /**
619
- * Start the heartbeat. Returns a stop function.
620
- *
621
- * `unref`ed so it can never hold the process open, and every read is wrapped:
622
- * a diagnostic that can crash the process it watches is worse than none.
623
- */
624
- function startHeapWatch(label = "hub-main", sink = consoleSink, intervalMs = HEAP_WATCH_INTERVAL_MS, reclaimOptions) {
625
- const readMemory = reclaimOptions?.readMemory ?? (() => process.memoryUsage());
626
- const now = reclaimOptions?.now ?? (() => Date.now());
627
- const triggerMb = reclaimOptions?.triggerMb ?? 1536;
628
- const minIntervalMs = reclaimOptions?.minIntervalMs ?? 6e5;
629
- const fastIntervalMs = reclaimOptions?.fastIntervalMs ?? 2e3;
630
- const escalateRatio = reclaimOptions?.escalateRatio ?? .7;
631
- const deescalateRatio = reclaimOptions?.deescalateRatio ?? .6;
632
- let lastReclaimAt = Number.NEGATIVE_INFINITY;
633
- const read = () => {
634
- const limit = reclaimOptions?.heapLimitBytes ?? node_v8.getHeapStatistics().heap_size_limit;
635
- return buildHeapSample(readMemory(), limit);
636
- };
637
- const maybeReclaim = (sample) => {
638
- if (reclaimOptions === void 0) return;
639
- if (!shouldReclaim(sample, triggerMb)) return;
640
- if (now() - lastReclaimAt < minIntervalMs) return;
641
- lastReclaimAt = now();
642
- const startedAt = now();
643
- try {
644
- reclaimOptions.reclaim();
645
- } catch (error) {
646
- sink.warn(`[mem] reclaim failed — ${error instanceof Error ? error.message : String(error)}`);
647
- return;
648
- }
649
- const after = read();
650
- sink.info(`[mem] reclaim ${label} stranded=${strandedMb(sample)}MB rss=${sample.rssMb}MB→${after.rssMb}MB freed=${sample.rssMb - after.rssMb}MB arrayBuffers=${sample.arrayBuffersMb}MB→${after.arrayBuffersMb}MB took=${now() - startedAt}ms`);
651
- };
652
- let mode = "steady";
653
- let lastLoggedAt = Number.NEGATIVE_INFINITY;
654
- /**
655
- * PROBING and LOGGING are separate cadences, and that separation is the whole
656
- * point.
657
- *
658
- * The 2026-08-08 fatal escaped a 60s heartbeat because the burst began and
659
- * ended between two samples. Deciding "is the heap elevated?" only when a
660
- * line is due would reproduce that exactly: the process would still be read
661
- * once a minute, and a 62-second climb would still be invisible. So the timer
662
- * runs at the FAST period always — `process.memoryUsage()` is tens of
663
- * microseconds, i.e. free at this rate — and the slow period governs only how
664
- * often a steady process is allowed to say so.
665
- *
666
- * Never probe slower than the log cadence, or a caller passing a short
667
- * `intervalMs` would silently get the long one.
668
- */
669
- const probeIntervalMs = Math.min(fastIntervalMs, intervalMs);
670
- const tick = () => {
671
- try {
672
- const sample = read();
673
- const previous = mode;
674
- mode = nextMode(previous, sample.usedRatio, escalateRatio, deescalateRatio);
675
- const at = now();
676
- const due = at - lastLoggedAt >= intervalMs;
677
- if (mode === "escalated" || due) {
678
- lastLoggedAt = at;
679
- const line = format$1(label, sample);
680
- if (sample.nearLimit) sink.warn(`${line} — APPROACHING HEAP LIMIT`);
681
- else if (mode === "escalated") sink.warn(`${line} — heap elevated, sampling every ${probeIntervalMs}ms`);
682
- else sink.info(line);
683
- }
684
- if (previous === "escalated" && mode === "steady") sink.info(`[mem] ${label} heap back to routine — logging every ${intervalMs}ms`);
685
- maybeReclaim(sample);
686
- } catch {}
687
- };
688
- const timer = setInterval(tick, probeIntervalMs);
689
- timer.unref?.();
690
- tick();
691
- let stopped = false;
692
- return () => {
693
- if (stopped) return;
694
- stopped = true;
695
- clearInterval(timer);
696
- };
697
- }
698
- //#endregion
699
432
  //#region src/lifecycle/lifecycle-state-machine.ts
700
433
  var VALID_TRANSITIONS = {
701
434
  stopped: ["starting", "disabled"],
@@ -968,6 +701,26 @@ function addonBucketKey(addonId) {
968
701
  */
969
702
  var PRUNE_TARGET_RATIO = .9;
970
703
  /**
704
+ * Default ceiling on retained entries across every bucket.
705
+ *
706
+ * This used to be `null` — no aggregate bound at all — and the only thing
707
+ * holding hub-main's log memory was `perAddonCapacity x buckets`, a product
708
+ * that grows with the roster. Measured on the live cluster: 37 forked runners
709
+ * on the hub plus its in-process builtins, and hub-main additionally ingests
710
+ * every line from both agents, so ~60 buckets is the ordinary case and
711
+ * 60 x 5000 = 300k retained entries.
712
+ *
713
+ * A realistic entry (timestamp, level, scope, ~70-char message, three tags, no
714
+ * `meta`) measures **~306 B retained** in V8. So the old unbounded case is
715
+ * ~92MB of a hub process that is being asked to fit inside a cgroup with a
716
+ * neighbour to protect, and 50_000 is **~15MB**.
717
+ *
718
+ * 50_000 across a ~60-bucket roster is ~830 entries per addon on average while
719
+ * still letting a single chatty addon hold its full 5000-entry ring — the
720
+ * partitioning is not weakened, only the total is held.
721
+ */
722
+ var DEFAULT_MAX_TOTAL_LOG_ENTRIES = 5e4;
723
+ /**
971
724
  * A log buffer partitioned into one fixed-capacity {@link LogRingBuffer} per
972
725
  * `addonId`. A chatty addon (e.g. detection-pipeline) only ever evicts its OWN
973
726
  * bucket, so a quiet addon's sparse lines (e.g. a HomeAssistant `image` entity)
@@ -976,38 +729,55 @@ var PRUNE_TARGET_RATIO = .9;
976
729
  * Drop-in for {@link LogRingBuffer} from {@link LogManager}'s perspective —
977
730
  * exposes the same `push` / `getAll` / `query` / `clear` surface.
978
731
  *
979
- * ## Two bounds, doing different jobs
732
+ * ## Three bounds, doing different jobs
980
733
  *
981
734
  * **`perAddonCapacity`** is a HARD bound: each bucket is a fixed-size ring, so
982
735
  * total memory can never exceed `perAddonCapacity × buckets` whatever else
983
736
  * happens. This is the safety net.
984
737
  *
985
- * **`maxTotalEntries`** is a SOFT target across all buckets, and it exists because
986
- * the hard bound scales with addon count — 70 addons × 5000 is a lot of retained
987
- * entries on a small box. When the total is exceeded, the sweep discards only
988
- * entries at or below `pruneLevel` (default `debug`), oldest first, from the
738
+ * **`maxTotalEntries`** targets the total across all buckets, and it exists because
739
+ * the hard bound scales with addon count — 60 buckets × 5000 was 300k retained
740
+ * entries on the live hub. When the total is exceeded, the sweep first discards
741
+ * only entries at or below `pruneLevel` (default `debug`), oldest first, from the
989
742
  * largest buckets first.
990
743
  *
991
- * It is deliberately soft. Meeting a total cap by discarding `warn` and `error`
992
- * would trade the entries that explain an incident for entries about frame
993
- * timings; if pruning every prunable entry still leaves the total above the cap,
994
- * the buffer stays above it and the hard per-bucket bound is what holds. A cap
995
- * that can evict an error is not a feature.
744
+ * It is deliberately soft ON A LIVE BUCKET. Meeting a total cap by discarding
745
+ * `warn` and `error` from an addon that is still logging would trade the entries
746
+ * that explain an incident for entries about frame timings. A cap that can evict
747
+ * a live addon's error is not a feature.
996
748
  *
997
749
  * Largest-bucket-first because the noisy addon is the one creating the pressure —
998
750
  * the same principle that motivated partitioning in the first place.
751
+ *
752
+ * **`maxBuckets`, and bucket eviction, are the hard backstop.** Pruning alone
753
+ * cannot bound this structure: it may only discard at or below `pruneLevel`, so
754
+ * a bucket full of `info` from an addon that no longer exists was immortal, and
755
+ * the roster the product scales with is not fixed. When pruning cannot reach the
756
+ * target — or the bucket count exceeds `maxBuckets` — whole buckets are dropped
757
+ * **least-recently-WRITTEN first**, and never the bucket currently being written
758
+ * to. That is precisely the dead addon's bucket, and it is precisely not the
759
+ * bucket of the addon whose incident is in progress.
999
760
  */
1000
761
  var PartitionedLogBuffer = class {
1001
762
  buffers = /* @__PURE__ */ new Map();
763
+ /**
764
+ * Bucket key → the write ordinal of its last `push`. A monotonic counter, not
765
+ * a clock: eviction order must follow the order writes actually happened, and
766
+ * a wall clock that steps backwards would pick the wrong victim.
767
+ */
768
+ lastWriteSeq = /* @__PURE__ */ new Map();
769
+ writeSeq = 0;
1002
770
  perAddonCapacity;
1003
771
  maxTotalEntries;
1004
772
  pruneLevel;
773
+ maxBuckets;
1005
774
  /** Running total, so `push` does not walk every bucket to decide. */
1006
775
  totalEntries = 0;
1007
776
  constructor(perAddonCapacity = 5e3, options = {}) {
1008
777
  this.perAddonCapacity = options.perAddonCapacity ?? perAddonCapacity;
1009
- this.maxTotalEntries = options.maxTotalEntries ?? null;
778
+ this.maxTotalEntries = options.maxTotalEntries === void 0 ? DEFAULT_MAX_TOTAL_LOG_ENTRIES : options.maxTotalEntries;
1010
779
  this.pruneLevel = options.pruneLevel ?? "debug";
780
+ this.maxBuckets = options.maxBuckets === void 0 ? 128 : options.maxBuckets;
1011
781
  }
1012
782
  /**
1013
783
  * Apply new limits at runtime. Shrinking takes effect as entries are pushed
@@ -1020,6 +790,7 @@ var PartitionedLogBuffer = class {
1020
790
  if (options.perAddonCapacity !== void 0 && options.perAddonCapacity > 0) this.perAddonCapacity = options.perAddonCapacity;
1021
791
  if (options.maxTotalEntries !== void 0) this.maxTotalEntries = options.maxTotalEntries;
1022
792
  if (options.pruneLevel !== void 0) this.pruneLevel = options.pruneLevel;
793
+ if (options.maxBuckets !== void 0) this.maxBuckets = options.maxBuckets;
1023
794
  }
1024
795
  bufferFor(key) {
1025
796
  let buf = this.buffers.get(key);
@@ -1030,34 +801,82 @@ var PartitionedLogBuffer = class {
1030
801
  return buf;
1031
802
  }
1032
803
  push(entry) {
1033
- const buf = this.bufferFor(addonBucketKey(entry.tags?.addonId));
804
+ const key = addonBucketKey(entry.tags?.addonId);
805
+ const buf = this.bufferFor(key);
1034
806
  const before = buf.size();
1035
807
  buf.push(entry);
1036
808
  this.totalEntries += buf.size() - before;
1037
- if (this.maxTotalEntries !== null && this.totalEntries > this.maxTotalEntries) this.enforceTotalCap(this.maxTotalEntries);
809
+ this.lastWriteSeq.set(key, ++this.writeSeq);
810
+ if (this.maxBuckets !== null && this.buffers.size > this.maxBuckets) this.evictBuckets(this.buffers.size - this.maxBuckets, key);
811
+ if (this.maxTotalEntries !== null && this.totalEntries > this.maxTotalEntries) this.enforceTotalCap(this.maxTotalEntries, key);
1038
812
  }
1039
813
  /**
1040
- * Bring the total down to `PRUNE_TARGET_RATIO × cap` by discarding prunable
1041
- * entries, largest bucket first. Stops early when nothing prunable is left —
1042
- * see the class docblock on why that is the correct outcome rather than a
1043
- * failure to enforce.
1044
- */
1045
- enforceTotalCap(cap) {
814
+ * Bring the total down to `PRUNE_TARGET_RATIO × cap`.
815
+ *
816
+ * Two passes, in this order and no other: first discard prunable entries,
817
+ * largest bucket first, which is free of information cost. Only if that
818
+ * cannot reach the target does the second pass drop WHOLE buckets,
819
+ * least-recently-written first — the bound has to hold even when every
820
+ * retained entry is above `pruneLevel`, and a bucket nobody has written to is
821
+ * the cheapest thing in the structure to lose.
822
+ */
823
+ enforceTotalCap(cap, protectedKey) {
1046
824
  const target = Math.max(1, Math.floor(cap * PRUNE_TARGET_RATIO));
1047
825
  let deficit = this.totalEntries - target;
1048
826
  if (deficit <= 0) return;
1049
- const bySizeDesc = [...this.buffers.values()].sort((a, b) => b.size() - a.size());
1050
- for (const buf of bySizeDesc) {
827
+ const bySizeDesc = [...this.buffers.entries()].toSorted((a, b) => b[1].size() - a[1].size());
828
+ for (const [key, buf] of bySizeDesc) {
1051
829
  if (deficit <= 0) break;
1052
830
  const removed = buf.pruneOldestAtOrBelow(this.pruneLevel, deficit);
1053
831
  this.totalEntries -= removed;
1054
832
  deficit -= removed;
833
+ if (buf.size() === 0) this.dropBucket(key);
1055
834
  }
835
+ if (deficit > 0) this.evictBucketsUntil(target, protectedKey);
836
+ }
837
+ /**
838
+ * Drop whole buckets, least-recently-WRITTEN first, until the total is at or
839
+ * below `target`. Never touches `protectedKey` (the bucket being written to
840
+ * right now) and always leaves at least one bucket standing.
841
+ */
842
+ evictBucketsUntil(target, protectedKey) {
843
+ for (const key of this.evictionOrder(protectedKey)) {
844
+ if (this.totalEntries <= target || this.buffers.size <= 1) return;
845
+ this.dropBucket(key);
846
+ }
847
+ }
848
+ /** Drop exactly `count` buckets, least-recently-written first. */
849
+ evictBuckets(count, protectedKey) {
850
+ let remaining = count;
851
+ for (const key of this.evictionOrder(protectedKey)) {
852
+ if (remaining <= 0 || this.buffers.size <= 1) return;
853
+ this.dropBucket(key);
854
+ remaining -= 1;
855
+ }
856
+ }
857
+ /** Bucket keys ordered least-recently-written first, excluding `protectedKey`.
858
+ * A bucket with no recorded write sorts first — it can only be a leftover. */
859
+ evictionOrder(protectedKey) {
860
+ return [...this.buffers.keys()].filter((key) => key !== protectedKey).toSorted((a, b) => (this.lastWriteSeq.get(a) ?? 0) - (this.lastWriteSeq.get(b) ?? 0));
861
+ }
862
+ /** Remove a bucket and everything that indexes it. An empty ring left behind
863
+ * is the leak this class shipped with — the map only ever grew. */
864
+ dropBucket(key) {
865
+ const buf = this.buffers.get(key);
866
+ if (buf === void 0) return;
867
+ this.totalEntries -= buf.size();
868
+ this.buffers.delete(key);
869
+ this.lastWriteSeq.delete(key);
1056
870
  }
1057
871
  /** Entries retained across every bucket. */
1058
872
  size() {
1059
873
  return this.totalEntries;
1060
874
  }
875
+ /** Buckets currently retained. Observability for the aggregate bound — an
876
+ * addon roster that only ever grows is what this class had to stop doing. */
877
+ bucketCount() {
878
+ return this.buffers.size;
879
+ }
1061
880
  /** Every retained entry across all buckets, newest-first (mirrors
1062
881
  * {@link LogRingBuffer.getAll}). Used to replay history to a destination. */
1063
882
  getAll() {
@@ -1085,13 +904,25 @@ var PartitionedLogBuffer = class {
1085
904
  if (pinned !== null) {
1086
905
  const removed = this.buffers.get(pinned)?.clear(filter) ?? 0;
1087
906
  this.totalEntries -= removed;
907
+ this.dropIfEmpty(pinned);
1088
908
  return removed;
1089
909
  }
1090
910
  let removed = 0;
1091
- for (const buf of this.buffers.values()) removed += buf.clear(filter);
911
+ for (const [key, buf] of [...this.buffers.entries()]) {
912
+ removed += buf.clear(filter);
913
+ this.dropIfEmpty(key);
914
+ }
1092
915
  this.totalEntries -= removed;
1093
916
  return removed;
1094
917
  }
918
+ /** An emptied bucket is removed rather than left as an empty ring: the map
919
+ * used to grow with every addon id that ever logged and never shrink. */
920
+ dropIfEmpty(key) {
921
+ if (this.buffers.get(key)?.size() === 0) {
922
+ this.buffers.delete(key);
923
+ this.lastWriteSeq.delete(key);
924
+ }
925
+ }
1095
926
  /** The bucket key a filter pins to via `tags.addonId`, or null when the
1096
927
  * filter doesn't constrain the addon (→ scan all buckets). */
1097
928
  pinnedAddonKey(filter) {
@@ -1189,11 +1020,13 @@ var LogManager = class {
1189
1020
  * `perAddonCapacity` bounds EACH addon's bucket, not the total — a chatty addon
1190
1021
  * evicts only its own lines, so quiet addons keep their sparse history.
1191
1022
  *
1192
- * `options.maxTotalEntries` adds a soft ceiling across all buckets, because the
1193
- * per-bucket bound scales with addon count. When it is exceeded, only entries at
1194
- * or below `options.pruneLevel` (default `debug`) are discarded `warn` and
1195
- * `error` are never evicted to satisfy it. Both default to the previous
1196
- * behaviour: no total cap. See {@link PartitionedLogBuffer}.
1023
+ * `options.maxTotalEntries` ceilings the total across all buckets, because the
1024
+ * per-bucket bound scales with addon count, and `options.maxBuckets` ceilings the
1025
+ * bucket count itself. When the total is exceeded, entries at or below
1026
+ * `options.pruneLevel` (default `debug`) are discarded first a live addon's
1027
+ * `warn`/`error` are never evicted to satisfy it — and only if that cannot reach
1028
+ * the target are whole least-recently-written buckets dropped. Both bounds are ON
1029
+ * by default; pass an explicit `null` to opt out. See {@link PartitionedLogBuffer}.
1197
1030
  */
1198
1031
  constructor(perAddonCapacity = 5e3, options = {}) {
1199
1032
  this.ringBuffer = new PartitionedLogBuffer(perAddonCapacity, options);
@@ -94304,33 +94137,45 @@ async function getPidStats$1(pids) {
94304
94137
  });
94305
94138
  });
94306
94139
  }
94140
+ var EMPTY_HEAP_DECLARATION = {
94141
+ profile: void 0,
94142
+ maxOldSpaceMb: void 0
94143
+ };
94307
94144
  /**
94308
- * Resolve the declared heap profile for one addon by reading its package.json manifest
94309
- * (`camstack.addons[].execution.heapProfile`). `addonDir` points at the addon's built `dist`, so the
94310
- * manifest is one level up; we also try `addonDir` itself for layouts where it IS the package root.
94145
+ * Resolve the declared heap profile + old-space ceiling for one addon by reading its package.json
94146
+ * manifest (`camstack.addons[].execution.{heapProfile,maxOldSpaceMb}`). `addonDir` points at the
94147
+ * addon's built `dist`, so the manifest is one level up; we also try `addonDir` itself for layouts
94148
+ * where it IS the package root.
94311
94149
  * Result is cached per (dir,id) — spawns are infrequent but a runner may re-spawn on crash/restart.
94312
- * Returns `undefined` when the manifest is unreadable or declares no profile → treated as light.
94150
+ * Returns an empty declaration when the manifest is unreadable → treated as light, default ceiling.
94313
94151
  */
94314
94152
  var heapProfileCache = /* @__PURE__ */ new Map();
94315
- function readAddonHeapProfile(spec) {
94153
+ function readAddonHeapDeclaration(spec) {
94316
94154
  const cacheKey = `${spec.addonDir}::${spec.addonId}`;
94317
94155
  const cached = heapProfileCache.get(cacheKey);
94318
- if (cached !== void 0 || heapProfileCache.has(cacheKey)) return cached;
94319
- let profile;
94156
+ if (cached !== void 0) return cached;
94157
+ let declaration = EMPTY_HEAP_DECLARATION;
94320
94158
  for (const manifestPath of [node_path.join(spec.addonDir, "package.json"), node_path.join(node_path.dirname(spec.addonDir), "package.json")]) try {
94321
94159
  const raw = node_fs.readFileSync(manifestPath, "utf8");
94322
94160
  const parsed = JSON.parse(raw);
94323
- profile = extractHeapProfile(parsed, spec.addonId);
94324
- if (profile !== void 0) break;
94161
+ const profile = extractHeapProfile(parsed, spec.addonId);
94162
+ const maxOldSpaceMb = extractMaxOldSpaceMb(parsed, spec.addonId);
94163
+ if (profile !== void 0 || maxOldSpaceMb !== void 0) {
94164
+ declaration = {
94165
+ profile,
94166
+ maxOldSpaceMb
94167
+ };
94168
+ break;
94169
+ }
94325
94170
  if (manifestHasAddon(parsed, spec.addonId)) break;
94326
94171
  } catch {}
94327
- heapProfileCache.set(cacheKey, profile);
94328
- return profile;
94172
+ heapProfileCache.set(cacheKey, declaration);
94173
+ return declaration;
94329
94174
  }
94330
94175
  /**
94331
- * Drop the cached heap profile for each addon so the NEXT `readAddonHeapProfile`
94176
+ * Drop the cached heap declaration for each addon so the NEXT `readAddonHeapDeclaration`
94332
94177
  * re-reads the manifest from disk. Called on operator-driven spawns / restarts
94333
- * (a deploy may have changed the addon's `execution.heapProfile`); the
94178
+ * (a deploy may have changed `execution.heapProfile` / `execution.maxOldSpaceMb`); the
94334
94179
  * crash-respawn fast path deliberately does NOT invalidate — a crashing runner
94335
94180
  * has the same on-disk manifest, so re-reading it is pure overhead.
94336
94181
  */
@@ -94344,6 +94189,10 @@ function extractHeapProfile(parsed, addonId) {
94344
94189
  const value = readManifestAddons(parsed).find((a) => a.id === addonId)?.execution?.heapProfile;
94345
94190
  return value === "heavy" || value === "light" ? value : void 0;
94346
94191
  }
94192
+ function extractMaxOldSpaceMb(parsed, addonId) {
94193
+ const value = readManifestAddons(parsed).find((a) => a.id === addonId)?.execution?.maxOldSpaceMb;
94194
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : void 0;
94195
+ }
94347
94196
  function readManifestAddons(parsed) {
94348
94197
  if (typeof parsed !== "object" || parsed === null) return [];
94349
94198
  const camstack = parsed.camstack;
@@ -94356,25 +94205,78 @@ function readManifestAddons(parsed) {
94356
94205
  * Everything else (the un-annotated default) is a light control-plane runner. No hard-coded names.
94357
94206
  */
94358
94207
  function isHeavyRunner(addons) {
94359
- return addons.some((a) => readAddonHeapProfile(a) === "heavy");
94208
+ return addons.some((a) => readAddonHeapDeclaration(a).profile === "heavy");
94209
+ }
94210
+ /**
94211
+ * Default old-space ceiling for a HEAVY runner, in MB.
94212
+ *
94213
+ * ## Why a heavy runner needs a ceiling at all
94214
+ *
94215
+ * `heapProfile: 'heavy'` used to mean "V8 defaults", i.e. no `--max-old-space-size` — which on this
94216
+ * host is V8's own memory-derived default (multiple GB). Nothing then bounds a burst, and V8 does
94217
+ * not hand committed pages back (the long argument for that is in `kernel/heap-watch.ts`), so a
94218
+ * one-off burst becomes the runner's permanent RSS.
94219
+ *
94220
+ * Measured on the live hub, 2026-08-17: the `recorder` runner reaches **2122 MB RSS** and stays
94221
+ * there after the deferred archive walk (99,809 segments in 84s), while the `RecordingIndex` it
94222
+ * actually retains is arithmetically ~21 MB (a `SegmentRow` is 5 numbers + 3 strings ≈ 207 B). The
94223
+ * same addon on two agent nodes carrying the same code and a smaller archive sits at **58 MB**.
94224
+ * The ~2 GB is walk garbage held as high-water in a heap that had no reason to collect it.
94225
+ *
94226
+ * ## Why 1024
94227
+ *
94228
+ * From the same live topology snapshot, every heavy runner's FULL RSS — which is a strict
94229
+ * over-estimate of its old space, since it also contains native allocations, `external` and
94230
+ * `arrayBuffers`, none of which `--max-old-space-size` bounds:
94231
+ *
94232
+ * detection 471 MB · provider-reolink 507 MB · pipeline-orchestrator 238 MB · ml 218 MB ·
94233
+ * snapshot 185 MB · decoder-nodeav 174 MB · audio-analyzer 170 MB
94234
+ *
94235
+ * 1024 MB clears the largest of those by ~2x while cutting the recorder's observed ratchet by
94236
+ * more than half. It is a CEILING, not a target: a runner that fits today keeps behaving exactly
94237
+ * as it does now, because V8 only collects harder as it approaches the limit.
94238
+ *
94239
+ * A runner whose working set genuinely does not fit declares its own number in its manifest
94240
+ * (`execution.maxOldSpaceMb`) — the number belongs next to the addon that knows its working set.
94241
+ * `stream-broker` (1323 MB measured, frame buffers) is the one shipped addon that does.
94242
+ */
94243
+ var HEAVY_MAX_OLD_MB_DEFAULT = 1024;
94244
+ /**
94245
+ * The runner's old-space ceiling in MB, or 0 for "uncapped".
94246
+ *
94247
+ * The runner takes the MAXIMUM ceiling any co-located addon declares — co-location must never
94248
+ * silence the hungriest member (D2/D29: a runner is respawned as a whole, so an OOM caused by
94249
+ * under-sizing one member kills every addon in the group). An addon declaring `0` therefore lifts
94250
+ * the cap for the whole runner, which is the intended escape hatch.
94251
+ */
94252
+ function runnerMaxOldSpaceMb(addons, heavy) {
94253
+ const envDefault = heavy ? positiveIntEnv("CAMSTACK_RUNNER_HEAVY_MAX_OLD_MB", HEAVY_MAX_OLD_MB_DEFAULT) : positiveIntEnv("CAMSTACK_RUNNER_LIGHT_MAX_OLD_MB", 0);
94254
+ const declared = addons.map((a) => readAddonHeapDeclaration(a).maxOldSpaceMb).filter((mb) => mb !== void 0);
94255
+ if (declared.length === 0) return envDefault;
94256
+ if (declared.includes(0)) return 0;
94257
+ return Math.max(...declared);
94360
94258
  }
94361
94259
  /**
94362
94260
  * Per-placement V8 heap flags (Block D). LIGHT runners cap their young generation
94363
- * (`--max-semi-space-size`) to reclaim V8's over-reservation — a measured ~15 MB/runner win.
94364
- * HEAVY runners keep V8 defaults. The old-space cap is opt-in via env (unset = uncapped = no OOM
94365
- * risk) so it can be enabled after before/after measurement on the real cluster.
94261
+ * (`--max-semi-space-size`) to reclaim V8's over-reservation — a measured ~15 MB/runner win — and
94262
+ * keep an opt-in old-space cap (`CAMSTACK_RUNNER_LIGHT_MAX_OLD_MB`, unset = uncapped). HEAVY
94263
+ * runners keep V8's default young generation but ALWAYS carry an old-space ceiling; see
94264
+ * {@link HEAVY_MAX_OLD_MB_DEFAULT} for why, and how a runner declares its own.
94366
94265
  *
94367
94266
  * Kill-switch: `CAMSTACK_RUNNER_HEAP_TUNING=off` disables all flags (recover without a rebuild).
94368
94267
  * Overrides: `CAMSTACK_RUNNER_LIGHT_SEMI_SPACE_MB` (default 2), `CAMSTACK_RUNNER_LIGHT_MAX_OLD_MB`
94369
- * (default unset = no old-space cap).
94268
+ * (default unset = no old-space cap), `CAMSTACK_RUNNER_HEAVY_MAX_OLD_MB`
94269
+ * (default {@link HEAVY_MAX_OLD_MB_DEFAULT}; `0` restores the old uncapped behaviour).
94370
94270
  */
94371
94271
  function runnerHeapFlags(addons) {
94372
94272
  if (process.env["CAMSTACK_RUNNER_HEAP_TUNING"] === "off") return [];
94373
- if (isHeavyRunner(addons)) return [];
94374
- const semiMb = positiveIntEnv("CAMSTACK_RUNNER_LIGHT_SEMI_SPACE_MB", 2);
94375
- const oldMb = positiveIntEnv("CAMSTACK_RUNNER_LIGHT_MAX_OLD_MB", 0);
94273
+ const heavy = isHeavyRunner(addons);
94376
94274
  const flags = [];
94377
- if (semiMb > 0) flags.push(`--max-semi-space-size=${semiMb}`);
94275
+ if (!heavy) {
94276
+ const semiMb = positiveIntEnv("CAMSTACK_RUNNER_LIGHT_SEMI_SPACE_MB", 2);
94277
+ if (semiMb > 0) flags.push(`--max-semi-space-size=${semiMb}`);
94278
+ }
94279
+ const oldMb = runnerMaxOldSpaceMb(addons, heavy);
94378
94280
  if (oldMb > 0) flags.push(`--max-old-space-size=${oldMb}`);
94379
94281
  return flags;
94380
94282
  }
@@ -94451,19 +94353,21 @@ function createProcessService(parentNodeId, dataDir, deps, parentTcpPort, parent
94451
94353
  function spawnRunner(runnerId, addons, env) {
94452
94354
  const nodeId = buildNodeId(runnerId);
94453
94355
  const runnerPath = node_path.resolve(__dirname, "addon-runner.js");
94356
+ const heavy = isHeavyRunner(addons);
94454
94357
  const childEnv = {
94455
94358
  ...process.env,
94456
94359
  CAMSTACK_RUNNER_ID: runnerId,
94457
94360
  CAMSTACK_RUNNER_ADDONS: JSON.stringify(addons),
94458
94361
  CAMSTACK_PARENT_NODE_ID: parentNodeId,
94459
94362
  CAMSTACK_DATA_DIR: dataDir,
94363
+ CAMSTACK_RUNNER_HEAP_PROFILE: heavy ? "heavy" : "light",
94460
94364
  CAMSTACK_LOG_LEVEL: process.env["CAMSTACK_LOG_LEVEL"] ?? "info",
94461
94365
  ...parentTcpPort !== void 0 ? { CAMSTACK_PARENT_TCP_PORT: String(parentTcpPort) } : {},
94462
94366
  ...parentUdsPath !== void 0 ? { CAMSTACK_PARENT_UDS_PATH: parentUdsPath } : {},
94463
94367
  ...env
94464
94368
  };
94465
94369
  const heapFlags = runnerHeapFlags(addons);
94466
- capturedBroker?.logger.info(`[${runnerId}] heap profile: ${isHeavyRunner(addons) ? "heavy" : "light"} flags=[${heapFlags.join(" ")}]`);
94370
+ capturedBroker?.logger.info(`[${runnerId}] heap profile: ${heavy ? "heavy" : "light"} flags=[${heapFlags.join(" ")}]`);
94467
94371
  const child = spawnFn(process.execPath, [...heapFlags, runnerPath], {
94468
94372
  env: childEnv,
94469
94373
  stdio: [
@@ -94507,6 +94411,15 @@ function createProcessService(parentNodeId, dataDir, deps, parentTcpPort, parent
94507
94411
  capturedBroker?.logger.error(`[${runnerId}] Crash circuit-breaker tripped — ${decision.crashesInWindow} crashes in ${CRASH_WINDOW_MS}ms. Not respawning.`);
94508
94412
  processes.set(runnerId, entry);
94509
94413
  if (capturedBroker) forceMoleculerDisconnect(capturedBroker, entry.nodeId);
94414
+ emitRunnerEvent(require_dist.EventCategory.AddonRunnerFailed, runnerId, {
94415
+ runnerId,
94416
+ nodeId: parentNodeId,
94417
+ addonIds: entry.runnerAddons.map((addon) => addon.addonId),
94418
+ crashesInWindow: decision.crashesInWindow,
94419
+ windowMs: CRASH_WINDOW_MS,
94420
+ ...code !== null ? { exitCode: code } : {},
94421
+ ...signal !== null ? { signal } : {}
94422
+ });
94510
94423
  for (const addon of entry.runnerAddons) emitRunnerEvent(require_dist.EventCategory.AddonCrashed, addon.addonId, {
94511
94424
  addonId: addon.addonId,
94512
94425
  error: `Crash circuit-breaker tripped after ${decision.crashesInWindow} crashes in ${CRASH_WINDOW_MS}ms`
@@ -95233,10 +95146,10 @@ exports.FsStorageBackend = FsStorageBackend;
95233
95146
  exports.HEALTH_MONITOR_GRACE_PERIOD_MS = HEALTH_MONITOR_GRACE_PERIOD_MS;
95234
95147
  exports.HEALTH_MONITOR_RETRY_INTERVALS_MS = HEALTH_MONITOR_RETRY_INTERVALS_MS;
95235
95148
  exports.HEALTH_MONITOR_TICK_MS = HEALTH_MONITOR_TICK_MS;
95236
- exports.HEAP_RECLAIM_MIN_INTERVAL_MS = HEAP_RECLAIM_MIN_INTERVAL_MS;
95237
- exports.HEAP_RECLAIM_TRIGGER_MB = HEAP_RECLAIM_TRIGGER_MB;
95238
- exports.HEAP_WATCH_INTERVAL_MS = HEAP_WATCH_INTERVAL_MS;
95239
- exports.HEAP_WATCH_WARN_RATIO = HEAP_WATCH_WARN_RATIO;
95149
+ exports.HEAP_RECLAIM_MIN_INTERVAL_MS = require_manifest_python_deps.HEAP_RECLAIM_MIN_INTERVAL_MS;
95150
+ exports.HEAP_RECLAIM_TRIGGER_MB = require_manifest_python_deps.HEAP_RECLAIM_TRIGGER_MB;
95151
+ exports.HEAP_WATCH_INTERVAL_MS = require_manifest_python_deps.HEAP_WATCH_INTERVAL_MS;
95152
+ exports.HEAP_WATCH_WARN_RATIO = require_manifest_python_deps.HEAP_WATCH_WARN_RATIO;
95240
95153
  exports.HUB_CAP_FWD_ACTION = require_manifest_python_deps.HUB_CAP_FWD_ACTION;
95241
95154
  exports.HUB_CAP_FWD_SERVICE = require_manifest_python_deps.HUB_CAP_FWD_SERVICE;
95242
95155
  exports.HubForwarderAddon = require_builtins_hub_forwarder_index.HubForwarderAddon$1;
@@ -95275,6 +95188,7 @@ exports.PipelineValidator = PipelineValidator;
95275
95188
  exports.PythonEnvManager = PythonEnvManager;
95276
95189
  exports.QUARANTINE_DIRNAME = QUARANTINE_DIRNAME;
95277
95190
  exports.RESTART_MARKER_FILE = RESTART_MARKER_FILE;
95191
+ exports.RUNNER_HEAP_WATCH_INTERVAL_MS = require_manifest_python_deps.RUNNER_HEAP_WATCH_INTERVAL_MS;
95278
95192
  exports.RUNTIME_DEFAULTS = require_dist.RUNTIME_DEFAULTS;
95279
95193
  exports.ReadinessRegistry = require_dist.ReadinessRegistry;
95280
95194
  exports.ReadinessTimeoutError = require_dist.ReadinessTimeoutError;
@@ -95313,7 +95227,7 @@ Object.defineProperty(exports, "buildBinaryPath", {
95313
95227
  }
95314
95228
  });
95315
95229
  exports.buildCapRouters = buildCapRouters;
95316
- exports.buildHeapSample = buildHeapSample;
95230
+ exports.buildHeapSample = require_manifest_python_deps.buildHeapSample;
95317
95231
  exports.buildLinkChain = require_manifest_python_deps.buildLinkChain;
95318
95232
  exports.buildNativeCapProxy = require_manifest_python_deps.buildNativeCapProxy;
95319
95233
  exports.buildNodeManifest = buildNodeManifest;
@@ -95358,7 +95272,7 @@ exports.createUdsEventBridge = require_manifest_python_deps.createUdsEventBridge
95358
95272
  exports.createUdsEventBus = require_manifest_python_deps.createUdsEventBus;
95359
95273
  exports.createUdsLogger = require_manifest_python_deps.createUdsLogger;
95360
95274
  exports.createUdsLoggerWithControl = require_manifest_python_deps.createUdsLoggerWithControl;
95361
- exports.createV8Reclaimer = createV8Reclaimer;
95275
+ exports.createV8Reclaimer = require_manifest_python_deps.createV8Reclaimer;
95362
95276
  exports.deleteModelFromDisk = require_file_data_plane.deleteModelFromDisk;
95363
95277
  exports.deriveAgentListenPort = deriveAgentListenPort;
95364
95278
  exports.describeProviderKindDrift = describeProviderKindDrift;
@@ -95484,9 +95398,10 @@ exports.selectAddonResidue = selectAddonResidue;
95484
95398
  exports.serializeTypedArrays = require_manifest_python_deps.serializeTypedArrays;
95485
95399
  exports.setHubConnected = require_manifest_python_deps.setHubConnected;
95486
95400
  exports.setNodeEventInterest = require_manifest_python_deps.setNodeEventInterest;
95487
- exports.shouldReclaim = shouldReclaim;
95488
- exports.startHeapWatch = startHeapWatch;
95489
- exports.strandedMb = strandedMb;
95401
+ exports.shouldReclaim = require_manifest_python_deps.shouldReclaim;
95402
+ exports.startHeapWatch = require_manifest_python_deps.startHeapWatch;
95403
+ exports.startRunnerHeapWatch = require_manifest_python_deps.startRunnerHeapWatch;
95404
+ exports.strandedMb = require_manifest_python_deps.strandedMb;
95490
95405
  exports.stripCamstackDeps = stripCamstackDeps;
95491
95406
  exports.subscribePassthrough = require_manifest_python_deps.subscribePassthrough;
95492
95407
  exports.udsChildLogToWorkerEntry = require_manifest_python_deps.udsChildLogToWorkerEntry;