@camstack/system 1.2.102 → 1.2.103

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 (57) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  8. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  9. package/dist/builtins/console-logging/index.js +1 -1
  10. package/dist/builtins/console-logging/index.mjs +1 -1
  11. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  12. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  13. package/dist/builtins/device-manager/device-manager.addon.js +1 -1
  14. package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
  15. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  16. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  17. package/dist/builtins/hub-forwarder/index.js +1 -1
  18. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  19. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  20. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  21. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  22. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  23. package/dist/builtins/local-network/local-network.addon.js +1 -1
  24. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  25. package/dist/builtins/loki-logging/index.js +1 -1
  26. package/dist/builtins/loki-logging/index.mjs +1 -1
  27. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  28. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  29. package/dist/builtins/platform-probe/index.js +1 -1
  30. package/dist/builtins/platform-probe/index.mjs +1 -1
  31. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  32. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  33. package/dist/builtins/snapshot/index.js +150 -22
  34. package/dist/builtins/snapshot/index.mjs +150 -22
  35. package/dist/builtins/snapshot/snapshot-cache.d.ts +63 -0
  36. package/dist/builtins/snapshot/snapshot-resize.d.ts +33 -1
  37. package/dist/builtins/snapshot/snapshot.addon.d.ts +6 -0
  38. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  39. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  40. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
  41. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
  42. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  43. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  44. package/dist/builtins/system-config/system-config.addon.js +1 -1
  45. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  46. package/dist/builtins/winston-logging/index.js +1 -1
  47. package/dist/builtins/winston-logging/index.mjs +1 -1
  48. package/dist/{dist-3wh5LCad.mjs → dist-DHKXob1m.mjs} +124 -20
  49. package/dist/{dist-9sC0wZw6.js → dist-qnwy_iWM.js} +124 -20
  50. package/dist/index.js +264 -62
  51. package/dist/index.mjs +264 -62
  52. package/dist/kernel/config-schema.d.ts +14 -10
  53. package/dist/kernel/heap-watch.d.ts +39 -7
  54. package/dist/kernel/moleculer/process-service.d.ts +34 -0
  55. package/dist/logging/log-manager.d.ts +7 -5
  56. package/dist/logging/partitioned-log-buffer.d.ts +95 -16
  57. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { a as __toCommonJS, i as __require, n as __esmMin, o as __toESM$1, r as __exportAll, t as __commonJSMin$1 } from "./chunk-CNf5ZN-e.mjs";
2
- import { At as asNumber, B as extractNestedAddonId, Bt as parseJsonUnknown$1, Dt as ReadinessTimeoutError, Et as ReadinessRegistry, Ft as expandCapMethods, Gt as EventCategory$1, H as isArrayOutputSchema, Ht as resolveCapMount, J as lifecycleJobSchema, K as isVoidInput, Mt as createEvent, Nt as emitDownForOwnedCaps, Q as looseSchema, St as DEVICE_STATUS_METHOD, U as isCollectionArrayMethod, Ut as scopeKey, Vt as readinessKey, W as isObjectInput, Z as logLevelAtMost, bt as DATAPLANE_SECRET_HEADER$1, d as METHOD_ACCESS_MAP, f as RUNTIME_DEFAULTS, it as procedureAuthKey, jt as asString$1, kt as asJsonObject$1, ot as scopesAllowAddon, q as kebabToCamel, st as scopesAllowDeviceCap, t as ALL_CAPABILITY_DEFINITIONS, tt as objectInputDeclaresAddonId, vt as errMsg$1, xt as DEVICE_SETTINGS_CONTRIBUTION_METHODS, y as addonSettingsCapability, zt as parseJsonObject } from "./dist-3wh5LCad.mjs";
2
+ import { At as asNumber, B as extractNestedAddonId, Bt as parseJsonUnknown$1, Dt as ReadinessTimeoutError, Et as ReadinessRegistry, Ft as expandCapMethods, Gt as EventCategory$1, H as isArrayOutputSchema, Ht as resolveCapMount, J as lifecycleJobSchema, K as isVoidInput, Mt as createEvent, Nt as emitDownForOwnedCaps, Q as looseSchema, St as DEVICE_STATUS_METHOD, U as isCollectionArrayMethod, Ut as scopeKey, Vt as readinessKey, W as isObjectInput, Z as logLevelAtMost, bt as DATAPLANE_SECRET_HEADER$1, d as METHOD_ACCESS_MAP, f as RUNTIME_DEFAULTS, it as procedureAuthKey, jt as asString$1, kt as asJsonObject$1, ot as scopesAllowAddon, q as kebabToCamel, st as scopesAllowDeviceCap, t as ALL_CAPABILITY_DEFINITIONS, tt as objectInputDeclaresAddonId, vt as errMsg$1, xt as DEVICE_SETTINGS_CONTRIBUTION_METHODS, y as addonSettingsCapability, zt as parseJsonObject } from "./dist-DHKXob1m.mjs";
3
3
  import { AlertCenterAddon } from "./builtins/alerts/alerts.addon.mjs";
4
4
  import "./builtins/alerts/index.mjs";
5
5
  import { t as formatLogLine } from "./formatter-B7qW8bPJ.mjs";
@@ -493,9 +493,10 @@ function proxyToUpstream(opts) {
493
493
  * ask V8 for the pages back when the process is visibly holding more than it is
494
494
  * using — which is what `shouldReclaim` decides and what the heartbeat now does.
495
495
  *
496
- * The cost is a 250-450ms stop-the-world on the hub's router process, bounded to
497
- * once per `HEAP_RECLAIM_MIN_INTERVAL_MS`. The alternative, measured, is a
498
- * ratchet to 7.8GB and the OOM that ends it.
496
+ * The cost is a stop-the-world on the hub's router process 250-450ms when
497
+ * first measured, 654ms on the largest pass seen since — bounded to once per
498
+ * `HEAP_RECLAIM_MIN_INTERVAL_MS`. The alternative, measured, is a ratchet to
499
+ * 7.8GB and the OOM that ends it.
499
500
  */
500
501
  /** Heartbeat period. Slow enough to be free, fast enough that the sample before
501
502
  * a fatal is recent. */
@@ -533,14 +534,46 @@ function nextMode(current, usedRatio, escalateRatio = HEAP_WATCH_ESCALATE_RATIO,
533
534
  * Live calibration, both readings from the same hub minutes apart: 2491MB
534
535
  * stranded before the reclaim, 438MB after. 438MB is this process's irreducible
535
536
  * native floor (sqlite pages, libvips, sharp, thread stacks, node's own text) —
536
- * collecting against it would buy nothing and cost a stall every minute. 1536
537
- * sits clear of that floor and still fires ~1.1GB before the ratchet is worth
538
- * anything, so it triggers on the real condition and stops as soon as it is fixed.
537
+ * collecting against it would buy nothing and cost a stall every minute.
538
+ *
539
+ * ## Why this came down from 1536 (2026-08-17)
540
+ *
541
+ * The trigger was originally set to fire only on an unmistakable strand. It
542
+ * does, and the pass on the record works exactly as designed:
543
+ *
544
+ * [mem] reclaim hub-main stranded=2365MB rss=4016MB→2368MB freed=1648MB took=654ms
545
+ *
546
+ * But the number it was protecting against changed. The host now runs with a
547
+ * 24GiB cgroup ceiling and **no swap**, and is OOM-killing a neighbouring VM.
548
+ * RSS this process is holding and not using is no longer merely untidy — it is
549
+ * the headroom something else needed, and the OOM is triggered by the PEAK, not
550
+ * by the average. A trigger of 1536 means the peak is allowed to stand 1.5GiB
551
+ * above the floor before anything happens.
552
+ *
553
+ * 1024 is 2.3x the measured 438MB floor, so ordinary movement around the floor
554
+ * can never trip it, and every pass that does fire still returns roughly
555
+ * `1024 - 438` ≈ **580MB minimum** — a real return for a 250-650ms stall,
556
+ * not a stall spent collecting noise.
539
557
  */
540
- var HEAP_RECLAIM_TRIGGER_MB = 1536;
541
- /** Floor on how often the stop-the-world may run. 250-450ms measured, so ten
542
- * minutes keeps the worst case under 0.1% of the router's time. */
543
- var HEAP_RECLAIM_MIN_INTERVAL_MS = 6e5;
558
+ var HEAP_RECLAIM_TRIGGER_MB = 1024;
559
+ /**
560
+ * Floor on how often the stop-the-world may run.
561
+ *
562
+ * Ten minutes was chosen to keep the worst case under 0.1% of the router's
563
+ * time. The worst case is not what governs the cost, though: the reclaim only
564
+ * runs when {@link shouldReclaim} says the process is genuinely stranded, and
565
+ * after a pass it is at the ~438MB floor. Live off-heap growth was measured at
566
+ * roughly 3GB/2h ≈ 25MB/min, so re-stranding the ~580MB needed to qualify again
567
+ * takes on the order of **20 minutes** — well outside any floor considered
568
+ * here. In steady state this constant therefore costs nothing at all.
569
+ *
570
+ * What it does govern is a BURST, which is the only case that matters on a host
571
+ * with no swap: the ten-minute floor let a peak that re-formed in seconds stand
572
+ * for ten minutes. Two minutes bounds that, and even the pathological case of a
573
+ * process that re-strands instantly costs 654ms per 120s — 0.55% of the
574
+ * router's time, against a ~1.5GiB peak it removes each pass.
575
+ */
576
+ var HEAP_RECLAIM_MIN_INTERVAL_MS = 12e4;
544
577
  var MB = (bytes) => Math.round(bytes / 1048576);
545
578
  /**
546
579
  * Build a sample from raw readings. Pure so the thresholds are testable without
@@ -616,8 +649,8 @@ function format$1(label, s) {
616
649
  function startHeapWatch(label = "hub-main", sink = consoleSink, intervalMs = HEAP_WATCH_INTERVAL_MS, reclaimOptions) {
617
650
  const readMemory = reclaimOptions?.readMemory ?? (() => process.memoryUsage());
618
651
  const now = reclaimOptions?.now ?? (() => Date.now());
619
- const triggerMb = reclaimOptions?.triggerMb ?? 1536;
620
- const minIntervalMs = reclaimOptions?.minIntervalMs ?? 6e5;
652
+ const triggerMb = reclaimOptions?.triggerMb ?? 1024;
653
+ const minIntervalMs = reclaimOptions?.minIntervalMs ?? 12e4;
621
654
  const fastIntervalMs = reclaimOptions?.fastIntervalMs ?? 2e3;
622
655
  const escalateRatio = reclaimOptions?.escalateRatio ?? .7;
623
656
  const deescalateRatio = reclaimOptions?.deescalateRatio ?? .6;
@@ -960,6 +993,26 @@ function addonBucketKey(addonId) {
960
993
  */
961
994
  var PRUNE_TARGET_RATIO = .9;
962
995
  /**
996
+ * Default ceiling on retained entries across every bucket.
997
+ *
998
+ * This used to be `null` — no aggregate bound at all — and the only thing
999
+ * holding hub-main's log memory was `perAddonCapacity x buckets`, a product
1000
+ * that grows with the roster. Measured on the live cluster: 37 forked runners
1001
+ * on the hub plus its in-process builtins, and hub-main additionally ingests
1002
+ * every line from both agents, so ~60 buckets is the ordinary case and
1003
+ * 60 x 5000 = 300k retained entries.
1004
+ *
1005
+ * A realistic entry (timestamp, level, scope, ~70-char message, three tags, no
1006
+ * `meta`) measures **~306 B retained** in V8. So the old unbounded case is
1007
+ * ~92MB of a hub process that is being asked to fit inside a cgroup with a
1008
+ * neighbour to protect, and 50_000 is **~15MB**.
1009
+ *
1010
+ * 50_000 across a ~60-bucket roster is ~830 entries per addon on average while
1011
+ * still letting a single chatty addon hold its full 5000-entry ring — the
1012
+ * partitioning is not weakened, only the total is held.
1013
+ */
1014
+ var DEFAULT_MAX_TOTAL_LOG_ENTRIES = 5e4;
1015
+ /**
963
1016
  * A log buffer partitioned into one fixed-capacity {@link LogRingBuffer} per
964
1017
  * `addonId`. A chatty addon (e.g. detection-pipeline) only ever evicts its OWN
965
1018
  * bucket, so a quiet addon's sparse lines (e.g. a HomeAssistant `image` entity)
@@ -968,38 +1021,55 @@ var PRUNE_TARGET_RATIO = .9;
968
1021
  * Drop-in for {@link LogRingBuffer} from {@link LogManager}'s perspective —
969
1022
  * exposes the same `push` / `getAll` / `query` / `clear` surface.
970
1023
  *
971
- * ## Two bounds, doing different jobs
1024
+ * ## Three bounds, doing different jobs
972
1025
  *
973
1026
  * **`perAddonCapacity`** is a HARD bound: each bucket is a fixed-size ring, so
974
1027
  * total memory can never exceed `perAddonCapacity × buckets` whatever else
975
1028
  * happens. This is the safety net.
976
1029
  *
977
- * **`maxTotalEntries`** is a SOFT target across all buckets, and it exists because
978
- * the hard bound scales with addon count — 70 addons × 5000 is a lot of retained
979
- * entries on a small box. When the total is exceeded, the sweep discards only
980
- * entries at or below `pruneLevel` (default `debug`), oldest first, from the
1030
+ * **`maxTotalEntries`** targets the total across all buckets, and it exists because
1031
+ * the hard bound scales with addon count — 60 buckets × 5000 was 300k retained
1032
+ * entries on the live hub. When the total is exceeded, the sweep first discards
1033
+ * only entries at or below `pruneLevel` (default `debug`), oldest first, from the
981
1034
  * largest buckets first.
982
1035
  *
983
- * It is deliberately soft. Meeting a total cap by discarding `warn` and `error`
984
- * would trade the entries that explain an incident for entries about frame
985
- * timings; if pruning every prunable entry still leaves the total above the cap,
986
- * the buffer stays above it and the hard per-bucket bound is what holds. A cap
987
- * that can evict an error is not a feature.
1036
+ * It is deliberately soft ON A LIVE BUCKET. Meeting a total cap by discarding
1037
+ * `warn` and `error` from an addon that is still logging would trade the entries
1038
+ * that explain an incident for entries about frame timings. A cap that can evict
1039
+ * a live addon's error is not a feature.
988
1040
  *
989
1041
  * Largest-bucket-first because the noisy addon is the one creating the pressure —
990
1042
  * the same principle that motivated partitioning in the first place.
1043
+ *
1044
+ * **`maxBuckets`, and bucket eviction, are the hard backstop.** Pruning alone
1045
+ * cannot bound this structure: it may only discard at or below `pruneLevel`, so
1046
+ * a bucket full of `info` from an addon that no longer exists was immortal, and
1047
+ * the roster the product scales with is not fixed. When pruning cannot reach the
1048
+ * target — or the bucket count exceeds `maxBuckets` — whole buckets are dropped
1049
+ * **least-recently-WRITTEN first**, and never the bucket currently being written
1050
+ * to. That is precisely the dead addon's bucket, and it is precisely not the
1051
+ * bucket of the addon whose incident is in progress.
991
1052
  */
992
1053
  var PartitionedLogBuffer = class {
993
1054
  buffers = /* @__PURE__ */ new Map();
1055
+ /**
1056
+ * Bucket key → the write ordinal of its last `push`. A monotonic counter, not
1057
+ * a clock: eviction order must follow the order writes actually happened, and
1058
+ * a wall clock that steps backwards would pick the wrong victim.
1059
+ */
1060
+ lastWriteSeq = /* @__PURE__ */ new Map();
1061
+ writeSeq = 0;
994
1062
  perAddonCapacity;
995
1063
  maxTotalEntries;
996
1064
  pruneLevel;
1065
+ maxBuckets;
997
1066
  /** Running total, so `push` does not walk every bucket to decide. */
998
1067
  totalEntries = 0;
999
1068
  constructor(perAddonCapacity = 5e3, options = {}) {
1000
1069
  this.perAddonCapacity = options.perAddonCapacity ?? perAddonCapacity;
1001
- this.maxTotalEntries = options.maxTotalEntries ?? null;
1070
+ this.maxTotalEntries = options.maxTotalEntries === void 0 ? DEFAULT_MAX_TOTAL_LOG_ENTRIES : options.maxTotalEntries;
1002
1071
  this.pruneLevel = options.pruneLevel ?? "debug";
1072
+ this.maxBuckets = options.maxBuckets === void 0 ? 128 : options.maxBuckets;
1003
1073
  }
1004
1074
  /**
1005
1075
  * Apply new limits at runtime. Shrinking takes effect as entries are pushed
@@ -1012,6 +1082,7 @@ var PartitionedLogBuffer = class {
1012
1082
  if (options.perAddonCapacity !== void 0 && options.perAddonCapacity > 0) this.perAddonCapacity = options.perAddonCapacity;
1013
1083
  if (options.maxTotalEntries !== void 0) this.maxTotalEntries = options.maxTotalEntries;
1014
1084
  if (options.pruneLevel !== void 0) this.pruneLevel = options.pruneLevel;
1085
+ if (options.maxBuckets !== void 0) this.maxBuckets = options.maxBuckets;
1015
1086
  }
1016
1087
  bufferFor(key) {
1017
1088
  let buf = this.buffers.get(key);
@@ -1022,34 +1093,82 @@ var PartitionedLogBuffer = class {
1022
1093
  return buf;
1023
1094
  }
1024
1095
  push(entry) {
1025
- const buf = this.bufferFor(addonBucketKey(entry.tags?.addonId));
1096
+ const key = addonBucketKey(entry.tags?.addonId);
1097
+ const buf = this.bufferFor(key);
1026
1098
  const before = buf.size();
1027
1099
  buf.push(entry);
1028
1100
  this.totalEntries += buf.size() - before;
1029
- if (this.maxTotalEntries !== null && this.totalEntries > this.maxTotalEntries) this.enforceTotalCap(this.maxTotalEntries);
1101
+ this.lastWriteSeq.set(key, ++this.writeSeq);
1102
+ if (this.maxBuckets !== null && this.buffers.size > this.maxBuckets) this.evictBuckets(this.buffers.size - this.maxBuckets, key);
1103
+ if (this.maxTotalEntries !== null && this.totalEntries > this.maxTotalEntries) this.enforceTotalCap(this.maxTotalEntries, key);
1030
1104
  }
1031
1105
  /**
1032
- * Bring the total down to `PRUNE_TARGET_RATIO × cap` by discarding prunable
1033
- * entries, largest bucket first. Stops early when nothing prunable is left —
1034
- * see the class docblock on why that is the correct outcome rather than a
1035
- * failure to enforce.
1036
- */
1037
- enforceTotalCap(cap) {
1106
+ * Bring the total down to `PRUNE_TARGET_RATIO × cap`.
1107
+ *
1108
+ * Two passes, in this order and no other: first discard prunable entries,
1109
+ * largest bucket first, which is free of information cost. Only if that
1110
+ * cannot reach the target does the second pass drop WHOLE buckets,
1111
+ * least-recently-written first — the bound has to hold even when every
1112
+ * retained entry is above `pruneLevel`, and a bucket nobody has written to is
1113
+ * the cheapest thing in the structure to lose.
1114
+ */
1115
+ enforceTotalCap(cap, protectedKey) {
1038
1116
  const target = Math.max(1, Math.floor(cap * PRUNE_TARGET_RATIO));
1039
1117
  let deficit = this.totalEntries - target;
1040
1118
  if (deficit <= 0) return;
1041
- const bySizeDesc = [...this.buffers.values()].sort((a, b) => b.size() - a.size());
1042
- for (const buf of bySizeDesc) {
1119
+ const bySizeDesc = [...this.buffers.entries()].toSorted((a, b) => b[1].size() - a[1].size());
1120
+ for (const [key, buf] of bySizeDesc) {
1043
1121
  if (deficit <= 0) break;
1044
1122
  const removed = buf.pruneOldestAtOrBelow(this.pruneLevel, deficit);
1045
1123
  this.totalEntries -= removed;
1046
1124
  deficit -= removed;
1125
+ if (buf.size() === 0) this.dropBucket(key);
1047
1126
  }
1127
+ if (deficit > 0) this.evictBucketsUntil(target, protectedKey);
1128
+ }
1129
+ /**
1130
+ * Drop whole buckets, least-recently-WRITTEN first, until the total is at or
1131
+ * below `target`. Never touches `protectedKey` (the bucket being written to
1132
+ * right now) and always leaves at least one bucket standing.
1133
+ */
1134
+ evictBucketsUntil(target, protectedKey) {
1135
+ for (const key of this.evictionOrder(protectedKey)) {
1136
+ if (this.totalEntries <= target || this.buffers.size <= 1) return;
1137
+ this.dropBucket(key);
1138
+ }
1139
+ }
1140
+ /** Drop exactly `count` buckets, least-recently-written first. */
1141
+ evictBuckets(count, protectedKey) {
1142
+ let remaining = count;
1143
+ for (const key of this.evictionOrder(protectedKey)) {
1144
+ if (remaining <= 0 || this.buffers.size <= 1) return;
1145
+ this.dropBucket(key);
1146
+ remaining -= 1;
1147
+ }
1148
+ }
1149
+ /** Bucket keys ordered least-recently-written first, excluding `protectedKey`.
1150
+ * A bucket with no recorded write sorts first — it can only be a leftover. */
1151
+ evictionOrder(protectedKey) {
1152
+ return [...this.buffers.keys()].filter((key) => key !== protectedKey).toSorted((a, b) => (this.lastWriteSeq.get(a) ?? 0) - (this.lastWriteSeq.get(b) ?? 0));
1153
+ }
1154
+ /** Remove a bucket and everything that indexes it. An empty ring left behind
1155
+ * is the leak this class shipped with — the map only ever grew. */
1156
+ dropBucket(key) {
1157
+ const buf = this.buffers.get(key);
1158
+ if (buf === void 0) return;
1159
+ this.totalEntries -= buf.size();
1160
+ this.buffers.delete(key);
1161
+ this.lastWriteSeq.delete(key);
1048
1162
  }
1049
1163
  /** Entries retained across every bucket. */
1050
1164
  size() {
1051
1165
  return this.totalEntries;
1052
1166
  }
1167
+ /** Buckets currently retained. Observability for the aggregate bound — an
1168
+ * addon roster that only ever grows is what this class had to stop doing. */
1169
+ bucketCount() {
1170
+ return this.buffers.size;
1171
+ }
1053
1172
  /** Every retained entry across all buckets, newest-first (mirrors
1054
1173
  * {@link LogRingBuffer.getAll}). Used to replay history to a destination. */
1055
1174
  getAll() {
@@ -1077,13 +1196,25 @@ var PartitionedLogBuffer = class {
1077
1196
  if (pinned !== null) {
1078
1197
  const removed = this.buffers.get(pinned)?.clear(filter) ?? 0;
1079
1198
  this.totalEntries -= removed;
1199
+ this.dropIfEmpty(pinned);
1080
1200
  return removed;
1081
1201
  }
1082
1202
  let removed = 0;
1083
- for (const buf of this.buffers.values()) removed += buf.clear(filter);
1203
+ for (const [key, buf] of [...this.buffers.entries()]) {
1204
+ removed += buf.clear(filter);
1205
+ this.dropIfEmpty(key);
1206
+ }
1084
1207
  this.totalEntries -= removed;
1085
1208
  return removed;
1086
1209
  }
1210
+ /** An emptied bucket is removed rather than left as an empty ring: the map
1211
+ * used to grow with every addon id that ever logged and never shrink. */
1212
+ dropIfEmpty(key) {
1213
+ if (this.buffers.get(key)?.size() === 0) {
1214
+ this.buffers.delete(key);
1215
+ this.lastWriteSeq.delete(key);
1216
+ }
1217
+ }
1087
1218
  /** The bucket key a filter pins to via `tags.addonId`, or null when the
1088
1219
  * filter doesn't constrain the addon (→ scan all buckets). */
1089
1220
  pinnedAddonKey(filter) {
@@ -1181,11 +1312,13 @@ var LogManager = class {
1181
1312
  * `perAddonCapacity` bounds EACH addon's bucket, not the total — a chatty addon
1182
1313
  * evicts only its own lines, so quiet addons keep their sparse history.
1183
1314
  *
1184
- * `options.maxTotalEntries` adds a soft ceiling across all buckets, because the
1185
- * per-bucket bound scales with addon count. When it is exceeded, only entries at
1186
- * or below `options.pruneLevel` (default `debug`) are discarded `warn` and
1187
- * `error` are never evicted to satisfy it. Both default to the previous
1188
- * behaviour: no total cap. See {@link PartitionedLogBuffer}.
1315
+ * `options.maxTotalEntries` ceilings the total across all buckets, because the
1316
+ * per-bucket bound scales with addon count, and `options.maxBuckets` ceilings the
1317
+ * bucket count itself. When the total is exceeded, entries at or below
1318
+ * `options.pruneLevel` (default `debug`) are discarded first a live addon's
1319
+ * `warn`/`error` are never evicted to satisfy it — and only if that cannot reach
1320
+ * the target are whole least-recently-written buckets dropped. Both bounds are ON
1321
+ * by default; pass an explicit `null` to opt out. See {@link PartitionedLogBuffer}.
1189
1322
  */
1190
1323
  constructor(perAddonCapacity = 5e3, options = {}) {
1191
1324
  this.ringBuffer = new PartitionedLogBuffer(perAddonCapacity, options);
@@ -94296,33 +94429,45 @@ async function getPidStats$1(pids) {
94296
94429
  });
94297
94430
  });
94298
94431
  }
94432
+ var EMPTY_HEAP_DECLARATION = {
94433
+ profile: void 0,
94434
+ maxOldSpaceMb: void 0
94435
+ };
94299
94436
  /**
94300
- * Resolve the declared heap profile for one addon by reading its package.json manifest
94301
- * (`camstack.addons[].execution.heapProfile`). `addonDir` points at the addon's built `dist`, so the
94302
- * manifest is one level up; we also try `addonDir` itself for layouts where it IS the package root.
94437
+ * Resolve the declared heap profile + old-space ceiling for one addon by reading its package.json
94438
+ * manifest (`camstack.addons[].execution.{heapProfile,maxOldSpaceMb}`). `addonDir` points at the
94439
+ * addon's built `dist`, so the manifest is one level up; we also try `addonDir` itself for layouts
94440
+ * where it IS the package root.
94303
94441
  * Result is cached per (dir,id) — spawns are infrequent but a runner may re-spawn on crash/restart.
94304
- * Returns `undefined` when the manifest is unreadable or declares no profile → treated as light.
94442
+ * Returns an empty declaration when the manifest is unreadable → treated as light, default ceiling.
94305
94443
  */
94306
94444
  var heapProfileCache = /* @__PURE__ */ new Map();
94307
- function readAddonHeapProfile(spec) {
94445
+ function readAddonHeapDeclaration(spec) {
94308
94446
  const cacheKey = `${spec.addonDir}::${spec.addonId}`;
94309
94447
  const cached = heapProfileCache.get(cacheKey);
94310
- if (cached !== void 0 || heapProfileCache.has(cacheKey)) return cached;
94311
- let profile;
94448
+ if (cached !== void 0) return cached;
94449
+ let declaration = EMPTY_HEAP_DECLARATION;
94312
94450
  for (const manifestPath of [path$39.join(spec.addonDir, "package.json"), path$39.join(path$39.dirname(spec.addonDir), "package.json")]) try {
94313
94451
  const raw = fs$17.readFileSync(manifestPath, "utf8");
94314
94452
  const parsed = JSON.parse(raw);
94315
- profile = extractHeapProfile(parsed, spec.addonId);
94316
- if (profile !== void 0) break;
94453
+ const profile = extractHeapProfile(parsed, spec.addonId);
94454
+ const maxOldSpaceMb = extractMaxOldSpaceMb(parsed, spec.addonId);
94455
+ if (profile !== void 0 || maxOldSpaceMb !== void 0) {
94456
+ declaration = {
94457
+ profile,
94458
+ maxOldSpaceMb
94459
+ };
94460
+ break;
94461
+ }
94317
94462
  if (manifestHasAddon(parsed, spec.addonId)) break;
94318
94463
  } catch {}
94319
- heapProfileCache.set(cacheKey, profile);
94320
- return profile;
94464
+ heapProfileCache.set(cacheKey, declaration);
94465
+ return declaration;
94321
94466
  }
94322
94467
  /**
94323
- * Drop the cached heap profile for each addon so the NEXT `readAddonHeapProfile`
94468
+ * Drop the cached heap declaration for each addon so the NEXT `readAddonHeapDeclaration`
94324
94469
  * re-reads the manifest from disk. Called on operator-driven spawns / restarts
94325
- * (a deploy may have changed the addon's `execution.heapProfile`); the
94470
+ * (a deploy may have changed `execution.heapProfile` / `execution.maxOldSpaceMb`); the
94326
94471
  * crash-respawn fast path deliberately does NOT invalidate — a crashing runner
94327
94472
  * has the same on-disk manifest, so re-reading it is pure overhead.
94328
94473
  */
@@ -94336,6 +94481,10 @@ function extractHeapProfile(parsed, addonId) {
94336
94481
  const value = readManifestAddons(parsed).find((a) => a.id === addonId)?.execution?.heapProfile;
94337
94482
  return value === "heavy" || value === "light" ? value : void 0;
94338
94483
  }
94484
+ function extractMaxOldSpaceMb(parsed, addonId) {
94485
+ const value = readManifestAddons(parsed).find((a) => a.id === addonId)?.execution?.maxOldSpaceMb;
94486
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : void 0;
94487
+ }
94339
94488
  function readManifestAddons(parsed) {
94340
94489
  if (typeof parsed !== "object" || parsed === null) return [];
94341
94490
  const camstack = parsed.camstack;
@@ -94348,25 +94497,78 @@ function readManifestAddons(parsed) {
94348
94497
  * Everything else (the un-annotated default) is a light control-plane runner. No hard-coded names.
94349
94498
  */
94350
94499
  function isHeavyRunner(addons) {
94351
- return addons.some((a) => readAddonHeapProfile(a) === "heavy");
94500
+ return addons.some((a) => readAddonHeapDeclaration(a).profile === "heavy");
94501
+ }
94502
+ /**
94503
+ * Default old-space ceiling for a HEAVY runner, in MB.
94504
+ *
94505
+ * ## Why a heavy runner needs a ceiling at all
94506
+ *
94507
+ * `heapProfile: 'heavy'` used to mean "V8 defaults", i.e. no `--max-old-space-size` — which on this
94508
+ * host is V8's own memory-derived default (multiple GB). Nothing then bounds a burst, and V8 does
94509
+ * not hand committed pages back (the long argument for that is in `kernel/heap-watch.ts`), so a
94510
+ * one-off burst becomes the runner's permanent RSS.
94511
+ *
94512
+ * Measured on the live hub, 2026-08-17: the `recorder` runner reaches **2122 MB RSS** and stays
94513
+ * there after the deferred archive walk (99,809 segments in 84s), while the `RecordingIndex` it
94514
+ * actually retains is arithmetically ~21 MB (a `SegmentRow` is 5 numbers + 3 strings ≈ 207 B). The
94515
+ * same addon on two agent nodes carrying the same code and a smaller archive sits at **58 MB**.
94516
+ * The ~2 GB is walk garbage held as high-water in a heap that had no reason to collect it.
94517
+ *
94518
+ * ## Why 1024
94519
+ *
94520
+ * From the same live topology snapshot, every heavy runner's FULL RSS — which is a strict
94521
+ * over-estimate of its old space, since it also contains native allocations, `external` and
94522
+ * `arrayBuffers`, none of which `--max-old-space-size` bounds:
94523
+ *
94524
+ * detection 471 MB · provider-reolink 507 MB · pipeline-orchestrator 238 MB · ml 218 MB ·
94525
+ * snapshot 185 MB · decoder-nodeav 174 MB · audio-analyzer 170 MB
94526
+ *
94527
+ * 1024 MB clears the largest of those by ~2x while cutting the recorder's observed ratchet by
94528
+ * more than half. It is a CEILING, not a target: a runner that fits today keeps behaving exactly
94529
+ * as it does now, because V8 only collects harder as it approaches the limit.
94530
+ *
94531
+ * A runner whose working set genuinely does not fit declares its own number in its manifest
94532
+ * (`execution.maxOldSpaceMb`) — the number belongs next to the addon that knows its working set.
94533
+ * `stream-broker` (1323 MB measured, frame buffers) is the one shipped addon that does.
94534
+ */
94535
+ var HEAVY_MAX_OLD_MB_DEFAULT = 1024;
94536
+ /**
94537
+ * The runner's old-space ceiling in MB, or 0 for "uncapped".
94538
+ *
94539
+ * The runner takes the MAXIMUM ceiling any co-located addon declares — co-location must never
94540
+ * silence the hungriest member (D2/D29: a runner is respawned as a whole, so an OOM caused by
94541
+ * under-sizing one member kills every addon in the group). An addon declaring `0` therefore lifts
94542
+ * the cap for the whole runner, which is the intended escape hatch.
94543
+ */
94544
+ function runnerMaxOldSpaceMb(addons, heavy) {
94545
+ const envDefault = heavy ? positiveIntEnv("CAMSTACK_RUNNER_HEAVY_MAX_OLD_MB", HEAVY_MAX_OLD_MB_DEFAULT) : positiveIntEnv("CAMSTACK_RUNNER_LIGHT_MAX_OLD_MB", 0);
94546
+ const declared = addons.map((a) => readAddonHeapDeclaration(a).maxOldSpaceMb).filter((mb) => mb !== void 0);
94547
+ if (declared.length === 0) return envDefault;
94548
+ if (declared.includes(0)) return 0;
94549
+ return Math.max(...declared);
94352
94550
  }
94353
94551
  /**
94354
94552
  * Per-placement V8 heap flags (Block D). LIGHT runners cap their young generation
94355
- * (`--max-semi-space-size`) to reclaim V8's over-reservation — a measured ~15 MB/runner win.
94356
- * HEAVY runners keep V8 defaults. The old-space cap is opt-in via env (unset = uncapped = no OOM
94357
- * risk) so it can be enabled after before/after measurement on the real cluster.
94553
+ * (`--max-semi-space-size`) to reclaim V8's over-reservation — a measured ~15 MB/runner win — and
94554
+ * keep an opt-in old-space cap (`CAMSTACK_RUNNER_LIGHT_MAX_OLD_MB`, unset = uncapped). HEAVY
94555
+ * runners keep V8's default young generation but ALWAYS carry an old-space ceiling; see
94556
+ * {@link HEAVY_MAX_OLD_MB_DEFAULT} for why, and how a runner declares its own.
94358
94557
  *
94359
94558
  * Kill-switch: `CAMSTACK_RUNNER_HEAP_TUNING=off` disables all flags (recover without a rebuild).
94360
94559
  * Overrides: `CAMSTACK_RUNNER_LIGHT_SEMI_SPACE_MB` (default 2), `CAMSTACK_RUNNER_LIGHT_MAX_OLD_MB`
94361
- * (default unset = no old-space cap).
94560
+ * (default unset = no old-space cap), `CAMSTACK_RUNNER_HEAVY_MAX_OLD_MB`
94561
+ * (default {@link HEAVY_MAX_OLD_MB_DEFAULT}; `0` restores the old uncapped behaviour).
94362
94562
  */
94363
94563
  function runnerHeapFlags(addons) {
94364
94564
  if (process.env["CAMSTACK_RUNNER_HEAP_TUNING"] === "off") return [];
94365
- if (isHeavyRunner(addons)) return [];
94366
- const semiMb = positiveIntEnv("CAMSTACK_RUNNER_LIGHT_SEMI_SPACE_MB", 2);
94367
- const oldMb = positiveIntEnv("CAMSTACK_RUNNER_LIGHT_MAX_OLD_MB", 0);
94565
+ const heavy = isHeavyRunner(addons);
94368
94566
  const flags = [];
94369
- if (semiMb > 0) flags.push(`--max-semi-space-size=${semiMb}`);
94567
+ if (!heavy) {
94568
+ const semiMb = positiveIntEnv("CAMSTACK_RUNNER_LIGHT_SEMI_SPACE_MB", 2);
94569
+ if (semiMb > 0) flags.push(`--max-semi-space-size=${semiMb}`);
94570
+ }
94571
+ const oldMb = runnerMaxOldSpaceMb(addons, heavy);
94370
94572
  if (oldMb > 0) flags.push(`--max-old-space-size=${oldMb}`);
94371
94573
  return flags;
94372
94574
  }
@@ -85,18 +85,22 @@ export type AppConfig = BootstrapConfig & {
85
85
  */
86
86
  perAddonLogBufferSize?: number;
87
87
  /**
88
- * SOFT ceiling on retained entries across every addon bucket. `null` (the
89
- * default) means the per-addon rings are the only bound — the historical
90
- * behaviour, unchanged unless you set this.
88
+ * Ceiling on retained entries across every addon bucket.
91
89
  *
92
- * Set it when `perAddonLogBufferSize × addons` is more than the box can spare:
93
- * 70 addons × 5000 is 350k retained entries, which is a lot on a mini PC.
90
+ * UNSET (the normal case) takes `DEFAULT_MAX_TOTAL_LOG_ENTRIES` the buffer is
91
+ * bounded in aggregate out of the box. It has to be: `perAddonLogBufferSize ×
92
+ * addons` grows with the roster, and hub-main ingests from every local runner
93
+ * AND every agent, so the measured ~60-bucket hub retained ~300k entries
94
+ * (~92MB at ~306 B/entry) with no aggregate bound at all.
94
95
  *
95
- * Soft, and deliberately so: it is met by discarding only entries at or below
96
- * `logBufferPruneLevel`. If pruning every prunable entry still leaves the total
97
- * above this number, the buffer stays above it. A cap that could evict an
98
- * `error` would trade the lines that explain an incident for lines about frame
99
- * timings.
96
+ * Set a number to tune it. Set an explicit `null` to opt out entirely — the
97
+ * per-addon rings then become the only bound again.
98
+ *
99
+ * It is met by discarding entries at or below `logBufferPruneLevel` FIRST, so a
100
+ * live addon's `warn`/`error` are never traded for lines about frame timings.
101
+ * Only when that cannot reach the target are whole least-recently-WRITTEN
102
+ * buckets dropped — a bound that pruning alone cannot hold is not a bound, and
103
+ * the bucket nobody has written to is the cheapest thing in the buffer to lose.
100
104
  */
101
105
  maxTotalLogBufferSize?: number | null;
102
106
  /**
@@ -59,14 +59,46 @@ export declare function nextMode(current: HeapWatchMode, usedRatio: number, esca
59
59
  * Live calibration, both readings from the same hub minutes apart: 2491MB
60
60
  * stranded before the reclaim, 438MB after. 438MB is this process's irreducible
61
61
  * native floor (sqlite pages, libvips, sharp, thread stacks, node's own text) —
62
- * collecting against it would buy nothing and cost a stall every minute. 1536
63
- * sits clear of that floor and still fires ~1.1GB before the ratchet is worth
64
- * anything, so it triggers on the real condition and stops as soon as it is fixed.
62
+ * collecting against it would buy nothing and cost a stall every minute.
63
+ *
64
+ * ## Why this came down from 1536 (2026-08-17)
65
+ *
66
+ * The trigger was originally set to fire only on an unmistakable strand. It
67
+ * does, and the pass on the record works exactly as designed:
68
+ *
69
+ * [mem] reclaim hub-main stranded=2365MB rss=4016MB→2368MB freed=1648MB took=654ms
70
+ *
71
+ * But the number it was protecting against changed. The host now runs with a
72
+ * 24GiB cgroup ceiling and **no swap**, and is OOM-killing a neighbouring VM.
73
+ * RSS this process is holding and not using is no longer merely untidy — it is
74
+ * the headroom something else needed, and the OOM is triggered by the PEAK, not
75
+ * by the average. A trigger of 1536 means the peak is allowed to stand 1.5GiB
76
+ * above the floor before anything happens.
77
+ *
78
+ * 1024 is 2.3x the measured 438MB floor, so ordinary movement around the floor
79
+ * can never trip it, and every pass that does fire still returns roughly
80
+ * `1024 - 438` ≈ **580MB minimum** — a real return for a 250-650ms stall,
81
+ * not a stall spent collecting noise.
82
+ */
83
+ export declare const HEAP_RECLAIM_TRIGGER_MB = 1024;
84
+ /**
85
+ * Floor on how often the stop-the-world may run.
86
+ *
87
+ * Ten minutes was chosen to keep the worst case under 0.1% of the router's
88
+ * time. The worst case is not what governs the cost, though: the reclaim only
89
+ * runs when {@link shouldReclaim} says the process is genuinely stranded, and
90
+ * after a pass it is at the ~438MB floor. Live off-heap growth was measured at
91
+ * roughly 3GB/2h ≈ 25MB/min, so re-stranding the ~580MB needed to qualify again
92
+ * takes on the order of **20 minutes** — well outside any floor considered
93
+ * here. In steady state this constant therefore costs nothing at all.
94
+ *
95
+ * What it does govern is a BURST, which is the only case that matters on a host
96
+ * with no swap: the ten-minute floor let a peak that re-formed in seconds stand
97
+ * for ten minutes. Two minutes bounds that, and even the pathological case of a
98
+ * process that re-strands instantly costs 654ms per 120s — 0.55% of the
99
+ * router's time, against a ~1.5GiB peak it removes each pass.
65
100
  */
66
- export declare const HEAP_RECLAIM_TRIGGER_MB = 1536;
67
- /** Floor on how often the stop-the-world may run. 250-450ms measured, so ten
68
- * minutes keeps the worst case under 0.1% of the router's time. */
69
- export declare const HEAP_RECLAIM_MIN_INTERVAL_MS = 600000;
101
+ export declare const HEAP_RECLAIM_MIN_INTERVAL_MS = 120000;
70
102
  export interface HeapSample {
71
103
  readonly rssMb: number;
72
104
  readonly heapUsedMb: number;
@@ -5,6 +5,40 @@ interface RunnerAddonSpec {
5
5
  readonly addonId: string;
6
6
  readonly addonDir: string;
7
7
  }
8
+ /**
9
+ * Default old-space ceiling for a HEAVY runner, in MB.
10
+ *
11
+ * ## Why a heavy runner needs a ceiling at all
12
+ *
13
+ * `heapProfile: 'heavy'` used to mean "V8 defaults", i.e. no `--max-old-space-size` — which on this
14
+ * host is V8's own memory-derived default (multiple GB). Nothing then bounds a burst, and V8 does
15
+ * not hand committed pages back (the long argument for that is in `kernel/heap-watch.ts`), so a
16
+ * one-off burst becomes the runner's permanent RSS.
17
+ *
18
+ * Measured on the live hub, 2026-08-17: the `recorder` runner reaches **2122 MB RSS** and stays
19
+ * there after the deferred archive walk (99,809 segments in 84s), while the `RecordingIndex` it
20
+ * actually retains is arithmetically ~21 MB (a `SegmentRow` is 5 numbers + 3 strings ≈ 207 B). The
21
+ * same addon on two agent nodes carrying the same code and a smaller archive sits at **58 MB**.
22
+ * The ~2 GB is walk garbage held as high-water in a heap that had no reason to collect it.
23
+ *
24
+ * ## Why 1024
25
+ *
26
+ * From the same live topology snapshot, every heavy runner's FULL RSS — which is a strict
27
+ * over-estimate of its old space, since it also contains native allocations, `external` and
28
+ * `arrayBuffers`, none of which `--max-old-space-size` bounds:
29
+ *
30
+ * detection 471 MB · provider-reolink 507 MB · pipeline-orchestrator 238 MB · ml 218 MB ·
31
+ * snapshot 185 MB · decoder-nodeav 174 MB · audio-analyzer 170 MB
32
+ *
33
+ * 1024 MB clears the largest of those by ~2x while cutting the recorder's observed ratchet by
34
+ * more than half. It is a CEILING, not a target: a runner that fits today keeps behaving exactly
35
+ * as it does now, because V8 only collects harder as it approaches the limit.
36
+ *
37
+ * A runner whose working set genuinely does not fit declares its own number in its manifest
38
+ * (`execution.maxOldSpaceMb`) — the number belongs next to the addon that knows its working set.
39
+ * `stream-broker` (1323 MB measured, frame buffers) is the one shipped addon that does.
40
+ */
41
+ export declare const HEAVY_MAX_OLD_MB_DEFAULT = 1024;
8
42
  interface SpawnedProcess {
9
43
  readonly pid: number;
10
44
  readonly nodeId: string;
@@ -54,11 +54,13 @@ export declare class LogManager {
54
54
  * `perAddonCapacity` bounds EACH addon's bucket, not the total — a chatty addon
55
55
  * evicts only its own lines, so quiet addons keep their sparse history.
56
56
  *
57
- * `options.maxTotalEntries` adds a soft ceiling across all buckets, because the
58
- * per-bucket bound scales with addon count. When it is exceeded, only entries at
59
- * or below `options.pruneLevel` (default `debug`) are discarded `warn` and
60
- * `error` are never evicted to satisfy it. Both default to the previous
61
- * behaviour: no total cap. See {@link PartitionedLogBuffer}.
57
+ * `options.maxTotalEntries` ceilings the total across all buckets, because the
58
+ * per-bucket bound scales with addon count, and `options.maxBuckets` ceilings the
59
+ * bucket count itself. When the total is exceeded, entries at or below
60
+ * `options.pruneLevel` (default `debug`) are discarded first a live addon's
61
+ * `warn`/`error` are never evicted to satisfy it — and only if that cannot reach
62
+ * the target are whole least-recently-written buckets dropped. Both bounds are ON
63
+ * by default; pass an explicit `null` to opt out. See {@link PartitionedLogBuffer}.
62
64
  */
63
65
  constructor(perAddonCapacity?: number, options?: PartitionedLogBufferOptions);
64
66
  /** Entries currently retained across every bucket. */