@camstack/system 1.2.150 → 1.2.153
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/addon-runner.js +14 -15
- package/dist/addon-runner.mjs +14 -14
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +2 -2
- package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +3 -3
- package/dist/builtins/local-network/local-network.addon.mjs +3 -3
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/cgroup-memory.d.ts +193 -0
- package/dist/builtins/native-metrics/container-memory-series.d.ts +226 -0
- package/dist/builtins/native-metrics/native-metrics.addon.d.ts +26 -0
- package/dist/builtins/native-metrics/native-metrics.addon.js +1536 -661
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1536 -660
- package/dist/builtins/platform-probe/index.js +3 -3
- package/dist/builtins/platform-probe/index.mjs +2 -2
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +2 -2
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +2 -2
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-CbRciAbu.mjs → dist-BLhrrxIs.mjs} +63 -4
- package/dist/{dist-CImxMt5h.js → dist-C7uZ_fOb.js} +63 -4
- package/dist/index.d.ts +6 -2
- package/dist/index.js +26 -5
- package/dist/index.mjs +5 -5
- package/dist/kernel/event-plane-report.d.ts +202 -0
- package/dist/kernel/heap-spaces.d.ts +138 -0
- package/dist/kernel/heap-watch.d.ts +213 -5
- package/dist/kernel/index.d.ts +2 -0
- package/dist/kernel/transport/local-child-registry.d.ts +22 -0
- package/dist/{manifest-python-deps-BaVekjRY.js → manifest-python-deps-CRTx_j4k.js} +706 -15
- package/dist/{manifest-python-deps-B5rrxUQv.mjs → manifest-python-deps-QompQxhG.mjs} +580 -15
- package/dist/{retired-settings-keys-C7gLUS3x.js → retired-settings-keys-CBU4-TJO.js} +1 -1
- package/dist/{retired-settings-keys-DhFPXIRD.mjs → retired-settings-keys-q9mt_DLX.mjs} +1 -1
- package/package.json +1 -1
- package/dist/{tls-u8QCJCFE.js → tls-BxQlomxd.js} +1 -1
- package/dist/{tls-CQhPGSJm.mjs → tls-DOTmtLCW.mjs} +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const require_chunk = require("./chunk-Cek0wNdY.js");
|
|
2
|
-
require("./dist-
|
|
2
|
+
require("./dist-C7uZ_fOb.js");
|
|
3
3
|
let node_crypto = require("node:crypto");
|
|
4
4
|
node_crypto = require_chunk.__toESM(node_crypto);
|
|
5
5
|
let _camstack_types_node = require("@camstack/types/node");
|
|
@@ -8,19 +8,361 @@ let node_path = require("node:path");
|
|
|
8
8
|
node_path = require_chunk.__toESM(node_path);
|
|
9
9
|
let node_child_process = require("node:child_process");
|
|
10
10
|
let node_util = require("node:util");
|
|
11
|
-
let node_os = require("node:os");
|
|
12
|
-
node_os = require_chunk.__toESM(node_os);
|
|
13
11
|
let node_fs = require("node:fs");
|
|
14
12
|
node_fs = require_chunk.__toESM(node_fs);
|
|
13
|
+
let node_os = require("node:os");
|
|
14
|
+
node_os = require_chunk.__toESM(node_os);
|
|
15
15
|
let node_http = require("node:http");
|
|
16
|
-
let node_perf_hooks = require("node:perf_hooks");
|
|
17
16
|
let node_v8 = require("node:v8");
|
|
18
17
|
node_v8 = require_chunk.__toESM(node_v8);
|
|
18
|
+
let node_perf_hooks = require("node:perf_hooks");
|
|
19
19
|
let node_vm = require("node:vm");
|
|
20
20
|
node_vm = require_chunk.__toESM(node_vm);
|
|
21
21
|
let _camstack_types_addon = require("@camstack/types/addon");
|
|
22
22
|
let node_net = require("node:net");
|
|
23
23
|
let _trpc_client = require("@trpc/client");
|
|
24
|
+
//#region src/kernel/event-plane-report.ts
|
|
25
|
+
/**
|
|
26
|
+
* Build a reader over the two registries.
|
|
27
|
+
*
|
|
28
|
+
* Returns `undefined` until the child registry exists — the heartbeat starts as
|
|
29
|
+
* the FIRST statement of `bootstrap()` (a process that dies early still leaves
|
|
30
|
+
* samples) and `LocalChildRegistry` is built hundreds of lines later inside
|
|
31
|
+
* `MoleculerService.onModuleInit`. Printing zeros in that window would report a
|
|
32
|
+
* silent plane where there is no plane at all.
|
|
33
|
+
*
|
|
34
|
+
* Wrapped: a registry mid-teardown may throw, and a diagnostic that can crash
|
|
35
|
+
* the process it watches is worse than none.
|
|
36
|
+
*/
|
|
37
|
+
function createEventPlaneReader(sources) {
|
|
38
|
+
return () => {
|
|
39
|
+
try {
|
|
40
|
+
const registry = sources.registry();
|
|
41
|
+
if (registry === null) return void 0;
|
|
42
|
+
const children = [];
|
|
43
|
+
const listed = registry.listChildren();
|
|
44
|
+
for (const child of listed) {
|
|
45
|
+
const stats = registry.getChildEventStats(child.childId);
|
|
46
|
+
if (stats === null) continue;
|
|
47
|
+
children.push({
|
|
48
|
+
childId: child.childId,
|
|
49
|
+
sent: stats.sent,
|
|
50
|
+
suppressed: stats.suppressed
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const crossNode = sources.crossNodeStats();
|
|
54
|
+
return {
|
|
55
|
+
udsMode: registry.eventFanoutMode,
|
|
56
|
+
crossNodeMode: sources.crossNodeMode(),
|
|
57
|
+
children,
|
|
58
|
+
childrenConnected: listed.length,
|
|
59
|
+
childrenUndeclared: registry.undeclaredChildCount(),
|
|
60
|
+
crossNodeDelivered: crossNode?.delivered ?? 0,
|
|
61
|
+
crossNodeSuppressed: crossNode?.suppressed ?? 0
|
|
62
|
+
};
|
|
63
|
+
} catch {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* How many children get named on the line.
|
|
70
|
+
*
|
|
71
|
+
* Five, because the question is which peer DOMINATES and the measured
|
|
72
|
+
* distribution is not flat: one peer accounted for 78–82% of outbound bytes and
|
|
73
|
+
* the runner-up for ~6%. Five names the dominant peer, its challenger and enough
|
|
74
|
+
* of the tail to see the shape change; naming all ~38 would put a kilobyte-long
|
|
75
|
+
* line in the hub's shared log every minute, which is how a log stops being read.
|
|
76
|
+
*/
|
|
77
|
+
var EVENT_PLANE_TOP_N = 5;
|
|
78
|
+
/** A process that has never read a plane. */
|
|
79
|
+
var EMPTY_EVENT_PLANE_BASELINE = {
|
|
80
|
+
children: /* @__PURE__ */ new Map(),
|
|
81
|
+
crossNodeDelivered: 0,
|
|
82
|
+
crossNodeSuppressed: 0
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Subtract the previous cumulative reading from the current one.
|
|
86
|
+
*
|
|
87
|
+
* A child that RESTARTED has counters that went backwards — the registry keys by
|
|
88
|
+
* childId and a respawned runner reconnects under the same id with a fresh
|
|
89
|
+
* entry. Clamping at 0 reports the restart as a quiet window rather than as a
|
|
90
|
+
* negative rate: the window's traffic is genuinely unknown, and a negative
|
|
91
|
+
* number on a rate reads as a broken meter.
|
|
92
|
+
*/
|
|
93
|
+
function diffEventPlane(baseline, current, topN = 5) {
|
|
94
|
+
const deltas = [];
|
|
95
|
+
let fanoutSent = 0;
|
|
96
|
+
let fanoutSuppressed = 0;
|
|
97
|
+
for (const sample of current.children) {
|
|
98
|
+
const before = baseline.children.get(sample.childId);
|
|
99
|
+
const sent = Math.max(0, sample.sent - (before?.sent ?? 0));
|
|
100
|
+
const suppressed = Math.max(0, sample.suppressed - (before?.suppressed ?? 0));
|
|
101
|
+
fanoutSent += sent;
|
|
102
|
+
fanoutSuppressed += suppressed;
|
|
103
|
+
if (sent > 0 || suppressed > 0) deltas.push({
|
|
104
|
+
childId: sample.childId,
|
|
105
|
+
sent,
|
|
106
|
+
suppressed
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
udsMode: current.udsMode,
|
|
111
|
+
crossNodeMode: current.crossNodeMode,
|
|
112
|
+
childrenConnected: current.childrenConnected,
|
|
113
|
+
childrenUndeclared: current.childrenUndeclared,
|
|
114
|
+
fanoutSent,
|
|
115
|
+
fanoutSuppressed,
|
|
116
|
+
crossNodeDelivered: Math.max(0, current.crossNodeDelivered - baseline.crossNodeDelivered),
|
|
117
|
+
crossNodeSuppressed: Math.max(0, current.crossNodeSuppressed - baseline.crossNodeSuppressed),
|
|
118
|
+
top: deltas.toSorted((a, b) => b.sent - a.sent || a.childId.localeCompare(b.childId)).slice(0, topN)
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function createEventPlaneMeter(reader, topN = 5) {
|
|
122
|
+
let baseline = EMPTY_EVENT_PLANE_BASELINE;
|
|
123
|
+
return { read: () => {
|
|
124
|
+
const current = reader();
|
|
125
|
+
if (current === void 0) return void 0;
|
|
126
|
+
const window = diffEventPlane(baseline, current, topN);
|
|
127
|
+
baseline = {
|
|
128
|
+
children: new Map(current.children.map((s) => [s.childId, s])),
|
|
129
|
+
crossNodeDelivered: current.crossNodeDelivered,
|
|
130
|
+
crossNodeSuppressed: current.crossNodeSuppressed
|
|
131
|
+
};
|
|
132
|
+
return window;
|
|
133
|
+
} };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The suffix appended to a `[mem]` line — leading space included, empty when
|
|
137
|
+
* this process has no event plane.
|
|
138
|
+
*
|
|
139
|
+
* Only two processes in the cluster have one (hub-main, and an agent's main), so
|
|
140
|
+
* these fields never appear on the ~38 runner lines that share the same log
|
|
141
|
+
* stream. On the two that do, every field is printed on every line INCLUDING at
|
|
142
|
+
* zero: the modes because a suppressed count without its regime is unreadable,
|
|
143
|
+
* `childrenUndeclared` because one undeclared child silently disables both
|
|
144
|
+
* gates, and the deltas because "the plane went quiet for a minute" is a
|
|
145
|
+
* reading, not a blank.
|
|
146
|
+
*
|
|
147
|
+
* `fanoutTop` is the one conditional field — a ranked list of nothing is noise —
|
|
148
|
+
* and it is a single comma-separated value so the field COUNT does not change
|
|
149
|
+
* from line to line: `childId:sent/suppressed`, largest first.
|
|
150
|
+
*/
|
|
151
|
+
function formatEventPlane(window) {
|
|
152
|
+
if (window === void 0) return "";
|
|
153
|
+
const top = window.top.map((d) => `${d.childId}:${d.sent}/${d.suppressed}`).join(",");
|
|
154
|
+
return ` fanoutMode=uds:${window.udsMode}/cross-node:${window.crossNodeMode} children=${window.childrenConnected} childrenUndeclared=${window.childrenUndeclared} fanoutSent=${window.fanoutSent} fanoutSuppressed=${window.fanoutSuppressed} crossNodeDelivered=${window.crossNodeDelivered} crossNodeSuppressed=${window.crossNodeSuppressed}` + (top.length === 0 ? "" : ` fanoutTop=${top}`);
|
|
155
|
+
}
|
|
156
|
+
//#endregion
|
|
157
|
+
//#region src/kernel/heap-spaces.ts
|
|
158
|
+
/**
|
|
159
|
+
* heap-spaces — WHERE in the V8 heap the bytes are, on the ordinary heartbeat.
|
|
160
|
+
*
|
|
161
|
+
* ## The question this exists to answer, and why it was unobtainable
|
|
162
|
+
*
|
|
163
|
+
* `hub/recorder` holds ~1.4 GB of live, retained JS heap that nobody can name.
|
|
164
|
+
* That is measured, not inferred: a forced compacting
|
|
165
|
+
* `gc({ flavor: 'last-resort' })` returned 507 MB of RSS and left V8 with
|
|
166
|
+
* 1870 MB committed and 1439 MB of it LIVE forty seconds later. Everything the
|
|
167
|
+
* 2026-08-29 pass could price — index rows 60–67 MB, runner shell ~50 MB, boot
|
|
168
|
+
* parse 35–50 MB transient, write path 0 retained — sums to 115–125 MB, under
|
|
169
|
+
* 9%. The same addon build running as `recorder` on an agent with no cameras
|
|
170
|
+
* and no archive sits at 52 MB.
|
|
171
|
+
*
|
|
172
|
+
* The next question is cheap and binary, and it halves the search:
|
|
173
|
+
*
|
|
174
|
+
* - **`old_space` holds it** ⇒ millions of small objects. The hunt becomes
|
|
175
|
+
* "what allocates ~7 M objects", and the row counters the recorder's index
|
|
176
|
+
* already exposes are the next thing to log.
|
|
177
|
+
* - **`large_object_space` holds it** ⇒ a handful of objects over V8's
|
|
178
|
+
* ~128 KB regular-object ceiling. The hunt becomes "who holds one huge array
|
|
179
|
+
* or string", which is a very short list in any one addon.
|
|
180
|
+
*
|
|
181
|
+
* V8 answers it for free, and this repository has been computing the answer
|
|
182
|
+
* since the SIGUSR2 handler was written — and then throwing it away, because
|
|
183
|
+
* `addon-runner.ts` welded the free read to `v8.writeHeapSnapshot`. One signal,
|
|
184
|
+
* both actions, so the cheap answer could not be had without the expensive one
|
|
185
|
+
* an operator has to authorise on a production hub. {@link emitHeapDiagnosticReport}
|
|
186
|
+
* splits them; this module puts the same breakdown on the periodic `[mem]` line
|
|
187
|
+
* so no signal is needed at all.
|
|
188
|
+
*
|
|
189
|
+
* ## Why bytes and not MB in the model
|
|
190
|
+
*
|
|
191
|
+
* The two consumers want different resolution — the heartbeat wants whole MB
|
|
192
|
+
* next to `heapUsed`, the SIGUSR2 report keeps the 0.1 MB it has always
|
|
193
|
+
* printed — so the reading stays raw and rounding happens at the edge. A model
|
|
194
|
+
* that pre-rounds cannot serve both without lying to one of them.
|
|
195
|
+
*/
|
|
196
|
+
/**
|
|
197
|
+
* The two spaces that ANSWER the question, printed always — including at zero.
|
|
198
|
+
*
|
|
199
|
+
* Node 24 reports thirteen spaces (measured on 24.17: `read_only_space`,
|
|
200
|
+
* `new_space`, `old_space`, `code_space`, `shared_space`, `trusted_space`,
|
|
201
|
+
* `shared_trusted_space`, `new_large_object_space`, `large_object_space`,
|
|
202
|
+
* `code_large_object_space`, `shared_large_object_space`,
|
|
203
|
+
* `shared_trusted_large_object_space`, `trusted_large_object_space`). Printing
|
|
204
|
+
* all thirteen once a minute per process would add ~13 fields to a line that
|
|
205
|
+
* already carries eleven, on every runner, forever — and eleven of the thirteen
|
|
206
|
+
* cannot be the answer to a 1.4 GB retained-set question.
|
|
207
|
+
*
|
|
208
|
+
* These two are printed unconditionally because **absence must never be
|
|
209
|
+
* ambiguous**. A threshold-only rule would drop `large_object_space` at 20 MB,
|
|
210
|
+
* and a reader would not be able to tell "small" from "this build does not
|
|
211
|
+
* report it" — which is the same class of mistake as a heartbeat with no
|
|
212
|
+
* destination (D285). `largeObjectSpace=0MB` is a load-bearing reading: it says
|
|
213
|
+
* the retainer is NOT a handful of huge objects.
|
|
214
|
+
*/
|
|
215
|
+
var DECISIVE_HEAP_SPACES = ["old_space", "large_object_space"];
|
|
216
|
+
/**
|
|
217
|
+
* A non-decisive space is printed only once it holds at least this many MB.
|
|
218
|
+
*
|
|
219
|
+
* 32 MB, and the number comes from the smallest heap this line is ever asked to
|
|
220
|
+
* explain: the control in the 2026-08-29 pass is an idle `recorder` runner at
|
|
221
|
+
* **52 MB** of `heapUsed`. A threshold below that means no space that could
|
|
222
|
+
* dominate even the smallest watched process is ever hidden, while a quiet
|
|
223
|
+
* process — measured on a bare Node 24.17: `new_space` 0.7 MB, `code_space`
|
|
224
|
+
* 0.25 MB, `trusted_space` 0.9 MB — prints nothing but the decisive pair.
|
|
225
|
+
*
|
|
226
|
+
* What this drops, and why it is right to drop it: `new_space` is the scavenger's
|
|
227
|
+
* semi-space and is bounded by `--max-semi-space-size`, so it can never be a
|
|
228
|
+
* retained set; `code_space` and `read_only_space` are JIT output and V8's own
|
|
229
|
+
* immutable roots, both bounded by the code that exists rather than by the data
|
|
230
|
+
* it touches; the `shared_*` spaces are empty outside a worker-threads
|
|
231
|
+
* deployment, which this cluster does not run. None of them has ever been the
|
|
232
|
+
* answer to "what is this process holding", and each would cost a field on
|
|
233
|
+
* every `[mem]` line in the fleet.
|
|
234
|
+
*
|
|
235
|
+
* They are not censored, though — a space over the threshold prints, whatever it
|
|
236
|
+
* is. If `code_space` ever reaches 32 MB that is itself the finding.
|
|
237
|
+
*/
|
|
238
|
+
var HEAP_SPACE_REPORT_MIN_MB = 32;
|
|
239
|
+
var BYTES_PER_MB = 1048576;
|
|
240
|
+
/** Whole MB, matching the rest of the `[mem]` line. */
|
|
241
|
+
function mb(bytes) {
|
|
242
|
+
return Math.round(bytes / BYTES_PER_MB);
|
|
243
|
+
}
|
|
244
|
+
/** Tenths of an MB — the resolution the SIGUSR2 report has always printed. */
|
|
245
|
+
function tenthsMb(bytes) {
|
|
246
|
+
return Math.round(bytes / BYTES_PER_MB * 10) / 10;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Read every space from V8.
|
|
250
|
+
*
|
|
251
|
+
* Wrapped: a diagnostic that can crash the process it watches is worse than
|
|
252
|
+
* none, and this is called from a heartbeat whose whole contract is that it
|
|
253
|
+
* survives whatever it is measuring. An empty reading prints no fields, which
|
|
254
|
+
* is exactly what a heartbeat that could not read the heap should say.
|
|
255
|
+
*/
|
|
256
|
+
function readHeapSpaces() {
|
|
257
|
+
try {
|
|
258
|
+
return node_v8.getHeapSpaceStatistics().map((space) => ({
|
|
259
|
+
name: space.space_name,
|
|
260
|
+
usedBytes: space.space_used_size
|
|
261
|
+
}));
|
|
262
|
+
} catch {
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* `old_space` → `oldSpace`. Mechanical and total, so a space V8 adds tomorrow
|
|
268
|
+
* gets a field name without anyone editing a table.
|
|
269
|
+
*
|
|
270
|
+
* camelCase because that is the vocabulary of every other field on this line
|
|
271
|
+
* (`heapUsed`, `heapTotal`, `arrayBuffers`, `rssBudget`, `loopP99`). A line that
|
|
272
|
+
* mixed `heapUsed=` with `old_space=` would be two conventions for one record.
|
|
273
|
+
*/
|
|
274
|
+
function heapSpaceField(v8Name) {
|
|
275
|
+
return v8Name.replace(/_([a-z])/g, (_match, letter) => letter.toUpperCase());
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The spaces worth a field on this line: the decisive pair (in that order,
|
|
279
|
+
* always), then every other space at or above `minMb`, largest first.
|
|
280
|
+
*
|
|
281
|
+
* Largest-first for the tail because the tail only ever appears when something
|
|
282
|
+
* unexpected is holding memory, and the biggest surprise should be the first
|
|
283
|
+
* thing read. The decisive pair keeps a FIXED position so the two numbers a
|
|
284
|
+
* reader is comparing are always in the same place on every line.
|
|
285
|
+
*/
|
|
286
|
+
function selectReportedSpaces(spaces, minMb = 32) {
|
|
287
|
+
const decisive = DECISIVE_HEAP_SPACES.map((name) => spaces.find((space) => space.name === name)).filter((space) => space !== void 0);
|
|
288
|
+
const rest = spaces.filter((space) => !DECISIVE_HEAP_SPACES.includes(space.name)).filter((space) => mb(space.usedBytes) >= minMb).toSorted((a, b) => b.usedBytes - a.usedBytes || a.name.localeCompare(b.name));
|
|
289
|
+
return [...decisive, ...rest];
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* The suffix appended to a `[mem]` line — leading space included, empty when
|
|
293
|
+
* there is nothing to say.
|
|
294
|
+
*
|
|
295
|
+
* APPENDED, never interleaved: every existing field keeps its name AND its
|
|
296
|
+
* position, so a `hl loki-query` pipeline written against the old line still
|
|
297
|
+
* matches. Prefix stability is the only change to a log line that cannot break
|
|
298
|
+
* a query someone already wrote.
|
|
299
|
+
*/
|
|
300
|
+
function formatHeapSpaces(spaces, minMb = 32) {
|
|
301
|
+
const selected = selectReportedSpaces(spaces, minMb);
|
|
302
|
+
if (selected.length === 0) return "";
|
|
303
|
+
return ` ${selected.map((s) => `${heapSpaceField(s.name)}=${mb(s.usedBytes)}MB`).join(" ")}`;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Env var that authorises a heap snapshot on SIGUSR2.
|
|
307
|
+
*
|
|
308
|
+
* A snapshot is an admin mutation on a production hub: `writeHeapSnapshot`
|
|
309
|
+
* streams a file the size of the heap to `/tmp` and stops the world while it
|
|
310
|
+
* walks it — on the recorder that is a multi-GB file and a stall measured in
|
|
311
|
+
* seconds. The per-space breakdown is ~1.2 µs and changes nothing. Welding them
|
|
312
|
+
* to one signal priced the free answer at the cost of the expensive one.
|
|
313
|
+
*/
|
|
314
|
+
var RUNNER_HEAP_SNAPSHOT_ENV = "CAMSTACK_RUNNER_HEAP_SNAPSHOT";
|
|
315
|
+
/**
|
|
316
|
+
* Whether the operator has authorised the snapshot half of SIGUSR2.
|
|
317
|
+
*
|
|
318
|
+
* Explicit `'on'` only. Anything else — unset, empty, `'true'`, `'1'` — is NOT
|
|
319
|
+
* authorisation: a multi-GB stop-the-world write must never be reachable by a
|
|
320
|
+
* value someone set for a different purpose.
|
|
321
|
+
*/
|
|
322
|
+
function heapSnapshotAuthorised(env = process.env) {
|
|
323
|
+
return env[RUNNER_HEAP_SNAPSHOT_ENV] === "on";
|
|
324
|
+
}
|
|
325
|
+
var defaultDeps = {
|
|
326
|
+
readMemory: () => process.memoryUsage(),
|
|
327
|
+
readSpaces: readHeapSpaces,
|
|
328
|
+
writeSnapshot: (path) => {
|
|
329
|
+
node_v8.writeHeapSnapshot(path);
|
|
330
|
+
},
|
|
331
|
+
env: process.env
|
|
332
|
+
};
|
|
333
|
+
/**
|
|
334
|
+
* The SIGUSR2 report — memory + the FULL per-space breakdown, always; the
|
|
335
|
+
* snapshot only when {@link heapSnapshotAuthorised}.
|
|
336
|
+
*
|
|
337
|
+
* The breakdown here keeps every non-empty space at 0.1 MB, unlike the
|
|
338
|
+
* heartbeat's filtered whole-MB pair: this is a hand-fired diagnostic read once
|
|
339
|
+
* by a human who asked for it, so completeness costs nothing. The heartbeat runs
|
|
340
|
+
* unattended on every runner forever, so it pays for every field it prints.
|
|
341
|
+
*
|
|
342
|
+
* A refused snapshot SAYS SO. A branch that drops work silently reads as "it was
|
|
343
|
+
* never requested", and this one is fired by an operator who is standing there
|
|
344
|
+
* waiting for a file.
|
|
345
|
+
*/
|
|
346
|
+
function emitHeapDiagnosticReport(request, deps = defaultDeps) {
|
|
347
|
+
try {
|
|
348
|
+
const mu = deps.readMemory();
|
|
349
|
+
const spaces = deps.readSpaces().filter((space) => space.usedBytes > 0).map((space) => `${space.name}=${tenthsMb(space.usedBytes)}`).join(" ");
|
|
350
|
+
request.sink.info(`[mem] node="${request.label}" addons=[${request.addons.join(",")}] rss=${tenthsMb(mu.rss)} heapUsed=${tenthsMb(mu.heapUsed)} heapTotal=${tenthsMb(mu.heapTotal)} external=${tenthsMb(mu.external)} arrayBuffers=${tenthsMb(mu.arrayBuffers)} | ${spaces}`);
|
|
351
|
+
} catch (error) {
|
|
352
|
+
request.sink.warn(`[mem] node="${request.label}" space report failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
353
|
+
}
|
|
354
|
+
if (!heapSnapshotAuthorised(deps.env)) {
|
|
355
|
+
request.sink.info(`[mem] node="${request.label}" heap snapshot NOT taken — it is an admin mutation (a multi-GB write and a stop-the-world walk of the heap). The per-space breakdown above is the free half and needs no authorisation. Set ${RUNNER_HEAP_SNAPSHOT_ENV}=on for this runner and signal again to take one.`);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
deps.writeSnapshot(request.snapshotPath);
|
|
360
|
+
request.sink.info(`[mem] node="${request.label}" heap snapshot written -> ${request.snapshotPath}`);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
request.sink.warn(`[mem] node="${request.label}" snapshot failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
//#endregion
|
|
24
366
|
//#region src/kernel/heap-watch.ts
|
|
25
367
|
/**
|
|
26
368
|
* heap-watch — hub-main's own memory, on the record, and returned to the OS.
|
|
@@ -45,6 +387,13 @@ let _trpc_client = require("@trpc/client");
|
|
|
45
387
|
* `addon-runner.ts`) — hub-main, the one process that actually OOMs, was the
|
|
46
388
|
* one process with no telemetry at all.
|
|
47
389
|
*
|
|
390
|
+
* **And for 27 days after that it still had none where anyone could read it.**
|
|
391
|
+
* The watch was started correctly and passed NO SINK, so it fell back to
|
|
392
|
+
* `console.log`, which is not an `ILogDestination` — 42 hours of Loki carried a
|
|
393
|
+
* line for every runner and none for hub-main. Read
|
|
394
|
+
* {@link createDeferredHeapWatchSink} before trusting anything below: a
|
|
395
|
+
* heartbeat with no destination is indistinguishable from no heartbeat (D285).
|
|
396
|
+
*
|
|
48
397
|
* ## What it gives
|
|
49
398
|
*
|
|
50
399
|
* A heartbeat carrying the numbers that separate the two possible stories, and
|
|
@@ -55,6 +404,14 @@ let _trpc_client = require("@trpc/client");
|
|
|
55
404
|
* - `heapUsedMb` flat with a sharp jump under load → a burst allocation, and
|
|
56
405
|
* the timestamp says what was being served.
|
|
57
406
|
*
|
|
407
|
+
* Since 2026-08-29 the line also says **WHERE in the V8 heap** the bytes are —
|
|
408
|
+
* `oldSpace` and `largeObjectSpace`, appended after everything already on it.
|
|
409
|
+
* `heapUsed` alone cannot separate "millions of small objects" from "a handful
|
|
410
|
+
* of huge ones", and those lead to opposite searches; the recorder's unnamed
|
|
411
|
+
* ~1.4 GB retained set is the case that proved it. See
|
|
412
|
+
* {@link formatHeapSpaces} for which spaces are printed and why the rest are
|
|
413
|
+
* not.
|
|
414
|
+
*
|
|
58
415
|
* `external`/`arrayBuffers` ride along because they are NOT bounded by
|
|
59
416
|
* `--max-old-space-size` and are most of the gap between a 1.5GB cap and the
|
|
60
417
|
* ~2.05GB RSS observed live: a diagnosis that only looks at the JS heap
|
|
@@ -96,6 +453,30 @@ let _trpc_client = require("@trpc/client");
|
|
|
96
453
|
* `HEAP_RECLAIM_MIN_INTERVAL_MS`. The alternative, measured, is a ratchet to
|
|
97
454
|
* 7.8GB and the OOM that ends it.
|
|
98
455
|
*/
|
|
456
|
+
/**
|
|
457
|
+
* What hub-main calls itself in a `[mem]` line.
|
|
458
|
+
*
|
|
459
|
+
* ## Why not `hub/main`
|
|
460
|
+
*
|
|
461
|
+
* A runner's label is its nodeId, and a nodeId is built in exactly one place as
|
|
462
|
+
* `` `${parentNodeId}/${runnerId}` `` (`kernel/moleculer/addon-runner.ts:222`) —
|
|
463
|
+
* so every runner label contains a `/` and every hub-local one is `hub/<runner>`.
|
|
464
|
+
* A label with NO slash therefore cannot be produced by that rule, which makes
|
|
465
|
+
* `hub-main` unambiguous by construction rather than by convention: no future
|
|
466
|
+
* addon, group or agent can ever claim it.
|
|
467
|
+
*
|
|
468
|
+
* It also sorts where it belongs. `-` (0x2D) precedes `/` (0x2F), so a sorted
|
|
469
|
+
* `[mem]` census puts `hub-main` immediately before `hub/decoder`,
|
|
470
|
+
* `hub/recorder`, `hub/stream-broker` — the composition root first, then the
|
|
471
|
+
* runners it forked. `hub/main` would sort into the middle of its own children
|
|
472
|
+
* and would be one addon id away from a collision.
|
|
473
|
+
*
|
|
474
|
+
* And it is the name the rest of this repository already uses — the docblocks,
|
|
475
|
+
* {@link HUB_MAIN_RSS_BUDGET_MB}, {@link HUB_RSS_BUDGET_ENV}, every ADR. A log
|
|
476
|
+
* label that disagreed with the prose would be a second vocabulary for one
|
|
477
|
+
* process, which is the exact failure this telemetry exists to remove.
|
|
478
|
+
*/
|
|
479
|
+
var HUB_MAIN_HEAP_WATCH_LABEL = "hub-main";
|
|
99
480
|
/** Heartbeat period. Slow enough to be free, fast enough that the sample before
|
|
100
481
|
* a fatal is recent. */
|
|
101
482
|
var HEAP_WATCH_INTERVAL_MS = 6e4;
|
|
@@ -172,6 +553,86 @@ var HEAP_RECLAIM_TRIGGER_MB = 1024;
|
|
|
172
553
|
* router's time, against a ~1.5GiB peak it removes each pass.
|
|
173
554
|
*/
|
|
174
555
|
var HEAP_RECLAIM_MIN_INTERVAL_MS = 12e4;
|
|
556
|
+
/**
|
|
557
|
+
* Floor on the pass once it has PROVEN it is steady state, not a rescue.
|
|
558
|
+
*
|
|
559
|
+
* ## Why `stranded` cannot discriminate on hub-main
|
|
560
|
+
*
|
|
561
|
+
* {@link strandedMb} is `rss − heapUsed − external`: everything the process
|
|
562
|
+
* holds that those two numbers do not name. That lumps together two things with
|
|
563
|
+
* opposite fixes —
|
|
564
|
+
*
|
|
565
|
+
* - V8 pages committed at a sawtooth peak, which `gc(last-resort)` RETURNS;
|
|
566
|
+
* - glibc arena memory already freed and never handed back, which **no V8
|
|
567
|
+
* flavor can reach** (nothing in this repository calls `malloc_trim`).
|
|
568
|
+
*
|
|
569
|
+
* {@link HEAP_RECLAIM_TRIGGER_MB} was calibrated against a hub-main whose strand
|
|
570
|
+
* was mostly the first kind, and a pass then took it back under the trigger. Once
|
|
571
|
+
* the second kind grows past 1024 MB the quantity can never fall back under it,
|
|
572
|
+
* and the gate degenerates. Measured on the live hub 2026-08-29, first 25
|
|
573
|
+
* minutes of hub-main telemetry ever collected:
|
|
574
|
+
*
|
|
575
|
+
* - `stranded` sat at **1031–2313 MB for the entire boot**, i.e.
|
|
576
|
+
* {@link shouldReclaim} never once said no. **What is written as a threshold
|
|
577
|
+
* is, on this process, an unconditional 120-second timer.**
|
|
578
|
+
* - Nine minutes out of nine that contained a reclaim show `loopMax` ≈ the
|
|
579
|
+
* reclaim's own duration (555–869 ms). Every minute without one is
|
|
580
|
+
* 107–435 ms. **The largest recurring event-loop stall on the process that
|
|
581
|
+
* mediates every cross-runner call is the memory patch it applies to itself.**
|
|
582
|
+
* - Each pass freed less than the last (1193 → 462 MB) while the post-pass RSS
|
|
583
|
+
* floor ratcheted **1940 → 3510 MB in fourteen minutes**, with `heapUsed`
|
|
584
|
+
* never moving. It is compacting a heap that is not the problem.
|
|
585
|
+
*
|
|
586
|
+
* ## The fix is not a bigger trigger
|
|
587
|
+
*
|
|
588
|
+
* Raising {@link HEAP_RECLAIM_TRIGGER_MB} would move the number at which the same
|
|
589
|
+
* degeneration happens, and it would silence `hub/recorder` — whose single pass
|
|
590
|
+
* in six hours is the only cheap proof anyone has that the recorder's 1.4 GB is
|
|
591
|
+
* RETAINED (a forced compaction freed 507 MB and V8 still held 1.44 GB live).
|
|
592
|
+
* The discriminator is not the level of the strand; it is **whether the strand
|
|
593
|
+
* ever falls back under the trigger between passes**, which is precisely what
|
|
594
|
+
* {@link RECLAIM_STEADY_STATE_PASSES} already counts and used only to talk about.
|
|
595
|
+
*
|
|
596
|
+
* So the counter becomes load-bearing: once the pass has fired at the floor
|
|
597
|
+
* {@link RECLAIM_STEADY_STATE_PASSES} times running, the trigger has demonstrably
|
|
598
|
+
* stopped discriminating on this process, and the cadence falls back to a period
|
|
599
|
+
* chosen for its COST rather than to a threshold pretending to be a gate.
|
|
600
|
+
*
|
|
601
|
+
* ## Why 600_000, and why that is not a regression
|
|
602
|
+
*
|
|
603
|
+
* It is the value {@link HEAP_RECLAIM_MIN_INTERVAL_MS} held before 2026-08-17,
|
|
604
|
+
* and the reason it came down is recorded there: *"the ten-minute floor let a
|
|
605
|
+
* peak that re-formed in seconds stand for ten minutes"*. That argument is about
|
|
606
|
+
* a BURST — and a burst subsides, which resets the run and restores the 120 s
|
|
607
|
+
* floor. It never applied to a process in proven steady state. So the burst
|
|
608
|
+
* bound is untouched and the steady state gets the period the burst argument was
|
|
609
|
+
* never about.
|
|
610
|
+
*
|
|
611
|
+
* ## What each process does after this change
|
|
612
|
+
*
|
|
613
|
+
* - **hub-main**: first six passes at 120 s, unchanged. From the seventh
|
|
614
|
+
* consecutive floor-to-floor pass, every 600 s. Stop-the-world cost on the
|
|
615
|
+
* router falls from ~0.58% of wall clock to ~0.12%. The ArrayBuffer probe
|
|
616
|
+
* survives at six passes an hour — the finding it produced needed ten passes
|
|
617
|
+
* total, not ten in twenty-five minutes.
|
|
618
|
+
* - **`hub/recorder`**: zero change, by construction. `passesAtFloor` only
|
|
619
|
+
* increments when a pass fires within `minIntervalMs * 2` of the previous
|
|
620
|
+
* one; the recorder's strand takes six hours to re-form, so the counter is
|
|
621
|
+
* reset to 1 on every pass and can never reach six. The 507 MB probe is
|
|
622
|
+
* intact.
|
|
623
|
+
* - **Anything that recovers**: the run resets the moment the strand falls back
|
|
624
|
+
* under the trigger, and the 120 s floor returns with it.
|
|
625
|
+
*/
|
|
626
|
+
var HEAP_RECLAIM_STEADY_STATE_INTERVAL_MS = 6e5;
|
|
627
|
+
/**
|
|
628
|
+
* The floor in force for the NEXT pass, given how many have fired at the floor.
|
|
629
|
+
*
|
|
630
|
+
* Pure, so the cadence change is testable without stranding a gigabyte to reach
|
|
631
|
+
* it — and so the one place that decides it can be read on its own.
|
|
632
|
+
*/
|
|
633
|
+
function reclaimIntervalMs(passesAtFloor, minIntervalMs = HEAP_RECLAIM_MIN_INTERVAL_MS, steadyStateIntervalMs = HEAP_RECLAIM_STEADY_STATE_INTERVAL_MS, steadyStatePasses = 6) {
|
|
634
|
+
return passesAtFloor >= steadyStatePasses ? Math.max(minIntervalMs, steadyStateIntervalMs) : minIntervalMs;
|
|
635
|
+
}
|
|
175
636
|
/** What a loop with nothing recorded yet reports. */
|
|
176
637
|
var ZERO_LOOP_DELAY = {
|
|
177
638
|
p50Ms: 0,
|
|
@@ -191,6 +652,37 @@ var ZERO_LOOP_DELAY = {
|
|
|
191
652
|
* naively would print `loopMax=9223372036854ms` and be read as a catastrophe on
|
|
192
653
|
* every boot. `count === 0` short-circuits to {@link ZERO_LOOP_DELAY}.
|
|
193
654
|
*
|
|
655
|
+
* ## `loopP50=20ms` was the SAMPLER, not the loop (fixed 2026-08-29)
|
|
656
|
+
*
|
|
657
|
+
* `monitorEventLoopDelay` records the whole INTERVAL between its own timer
|
|
658
|
+
* firings, not the excess over the interval it asked for. So on a loop with
|
|
659
|
+
* nothing wrong the histogram reads `resolution`, and every `[mem]` line this
|
|
660
|
+
* process has ever emitted said `loopP50=20ms` — which was read, repeatedly in
|
|
661
|
+
* one session, as "hub-main is chronically 20 ms behind". It is not; 20 was the
|
|
662
|
+
* default `resolutionMs` of this very function.
|
|
663
|
+
*
|
|
664
|
+
* The delay is `interval − resolution`, and that is not a fudge factor — it is
|
|
665
|
+
* the only reading the four resolutions agree on. Measured here, one 120 ms
|
|
666
|
+
* block observed by four histograms at once:
|
|
667
|
+
*
|
|
668
|
+
* | resolution | raw p50 | raw max | max − resolution |
|
|
669
|
+
* | --- | --- | --- | --- |
|
|
670
|
+
* | 20 ms | 20.40 ms | 137.63 ms | 117.6 ms |
|
|
671
|
+
* | 10 ms | 10.22 ms | 123.60 ms | 113.6 ms |
|
|
672
|
+
* | 5 ms | 5.11 ms | 123.60 ms | 118.6 ms |
|
|
673
|
+
* | 1 ms | 1.27 ms | 121.04 ms | 120.0 ms |
|
|
674
|
+
*
|
|
675
|
+
* Raw, the four disagree by 16 ms about the same event and none of them reports
|
|
676
|
+
* the block. Corrected, they agree within 7 ms and all four name it. The
|
|
677
|
+
* subtraction costs nothing and needs no faster sampler — a 1 ms resolution
|
|
678
|
+
* would be 1 000 extra timer wakeups per second on the process that already
|
|
679
|
+
* serves ~44 000 socket operations per second, to buy 2 ms of accuracy on a
|
|
680
|
+
* quantity nobody reads below 100 ms.
|
|
681
|
+
*
|
|
682
|
+
* **Readings from before this change are 20 ms high.** `loopP99=21–28ms` in the
|
|
683
|
+
* 2026-08-29 census is really 1–8 ms; `loopMax=2804ms` is really 2784 ms. Do not
|
|
684
|
+
* compare a pre-fix series with a post-fix one without shifting it.
|
|
685
|
+
*
|
|
194
686
|
* Returns undefined if the platform declines, so the caller keeps a heartbeat
|
|
195
687
|
* either way.
|
|
196
688
|
*/
|
|
@@ -198,14 +690,15 @@ function createLoopDelayMeter(resolutionMs = 20) {
|
|
|
198
690
|
try {
|
|
199
691
|
const histogram = (0, node_perf_hooks.monitorEventLoopDelay)({ resolution: resolutionMs });
|
|
200
692
|
histogram.enable();
|
|
693
|
+
const delay = (nanoseconds) => Math.max(0, Math.round(nanoseconds / 1e6) - resolutionMs);
|
|
201
694
|
return {
|
|
202
695
|
read: () => {
|
|
203
696
|
try {
|
|
204
697
|
if (histogram.count === 0) return ZERO_LOOP_DELAY;
|
|
205
698
|
const sample = {
|
|
206
|
-
p50Ms:
|
|
207
|
-
p99Ms:
|
|
208
|
-
maxMs:
|
|
699
|
+
p50Ms: delay(histogram.percentile(50)),
|
|
700
|
+
p99Ms: delay(histogram.percentile(99)),
|
|
701
|
+
maxMs: delay(histogram.max)
|
|
209
702
|
};
|
|
210
703
|
histogram.reset();
|
|
211
704
|
return sample;
|
|
@@ -261,6 +754,33 @@ var consoleSink = {
|
|
|
261
754
|
warn: (line) => console.warn(line)
|
|
262
755
|
};
|
|
263
756
|
/**
|
|
757
|
+
* Build a {@link DeferredHeapWatchSink}.
|
|
758
|
+
*
|
|
759
|
+
* The handover announces itself, once, through the NEW target: that line is the
|
|
760
|
+
* only in-band evidence that hub-main's telemetry actually reached the log
|
|
761
|
+
* manager on a given boot. Its absence from Loki means the wiring is broken —
|
|
762
|
+
* which is precisely the state this module was in, undetected, for 42 hours.
|
|
763
|
+
*/
|
|
764
|
+
function createDeferredHeapWatchSink(label = HUB_MAIN_HEAP_WATCH_LABEL, fallback = consoleSink) {
|
|
765
|
+
let target = fallback;
|
|
766
|
+
const sink = {
|
|
767
|
+
info: (line) => {
|
|
768
|
+
target.info(line);
|
|
769
|
+
},
|
|
770
|
+
warn: (line) => {
|
|
771
|
+
target.warn(line);
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
const attach = (next) => {
|
|
775
|
+
target = next;
|
|
776
|
+
next.info(`[mem] ${label} heartbeat attached to the log manager — earlier lines went to stdout only and are not in the log buffer, the rotated file or Loki.`);
|
|
777
|
+
};
|
|
778
|
+
return {
|
|
779
|
+
sink,
|
|
780
|
+
attach
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
264
784
|
* RSS the process is holding but not using.
|
|
265
785
|
*
|
|
266
786
|
* The subtrahend is heapUSED, not heapTotal, deliberately: the pages V8
|
|
@@ -389,10 +909,22 @@ function createV8Reclaimer() {
|
|
|
389
909
|
function formatOverBudget(label, b, source) {
|
|
390
910
|
return `[mem] ${label} OVER RSS BUDGET rss=${b.rssMb}MB budget=${b.budgetMb}MB over=+${b.overMb}MB shape=${b.shape} heapUsed=${b.heapUsedMb}MB(${share(b.heapUsedMb, b.rssMb)}%) external=${b.externalMb}MB(${share(b.externalMb, b.rssMb)}%) nativeResidue=${b.nativeResidueMb}MB(${share(b.nativeResidueMb, b.rssMb)}%) arrayBuffers=${b.arrayBuffersMb}MB budgetFrom="${source}" — ${SHAPE_ADVICE[b.shape]}`;
|
|
391
911
|
}
|
|
392
|
-
|
|
912
|
+
/**
|
|
913
|
+
* The heartbeat line.
|
|
914
|
+
*
|
|
915
|
+
* ## Nothing here may be renamed or moved
|
|
916
|
+
*
|
|
917
|
+
* `heapUsed` says HOW MUCH; the per-space suffix says WHERE, which is the half
|
|
918
|
+
* that names a retained set. The suffix is APPENDED after the loop fields for
|
|
919
|
+
* one reason: every existing field then keeps its name and its position, so the
|
|
920
|
+
* `hl loki-query` pipelines written against this line keep matching. A rename or
|
|
921
|
+
* a reorder is the one change to a log format that silently breaks a query
|
|
922
|
+
* nobody will re-run until they need it.
|
|
923
|
+
*/
|
|
924
|
+
function format(label, s, loop, budgetMb, suffix) {
|
|
393
925
|
const line = `[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` + (budgetMb === void 0 ? "" : ` rssBudget=${budgetMb}MB`);
|
|
394
|
-
if (loop === void 0) return line
|
|
395
|
-
return `${line} loopP50=${loop.p50Ms}ms loopP99=${loop.p99Ms}ms loopMax=${loop.maxMs}ms`;
|
|
926
|
+
if (loop === void 0) return `${line}${suffix}`;
|
|
927
|
+
return `${line} loopP50=${loop.p50Ms}ms loopP99=${loop.p99Ms}ms loopMax=${loop.maxMs}ms${suffix}`;
|
|
396
928
|
}
|
|
397
929
|
/**
|
|
398
930
|
* Start the heartbeat. Returns a stop function.
|
|
@@ -400,11 +932,14 @@ function format(label, s, loop, budgetMb) {
|
|
|
400
932
|
* `unref`ed so it can never hold the process open, and every read is wrapped:
|
|
401
933
|
* a diagnostic that can crash the process it watches is worse than none.
|
|
402
934
|
*/
|
|
403
|
-
function startHeapWatch(label =
|
|
935
|
+
function startHeapWatch(label = HUB_MAIN_HEAP_WATCH_LABEL, sink = consoleSink, intervalMs = HEAP_WATCH_INTERVAL_MS, reclaimOptions, loopDelay = createLoopDelayMeter(), execArgv = process.execArgv, announceCeilingOrigin = true, rssBudget, probes) {
|
|
936
|
+
const readSpaces = probes?.readSpaces ?? readHeapSpaces;
|
|
937
|
+
const eventPlane = probes?.eventPlane === void 0 ? void 0 : createEventPlaneMeter(probes.eventPlane);
|
|
404
938
|
const readMemory = reclaimOptions?.readMemory ?? (() => process.memoryUsage());
|
|
405
939
|
const now = reclaimOptions?.now ?? (() => Date.now());
|
|
406
940
|
const triggerMb = reclaimOptions?.triggerMb ?? 1024;
|
|
407
941
|
const minIntervalMs = reclaimOptions?.minIntervalMs ?? 12e4;
|
|
942
|
+
const steadyStateIntervalMs = reclaimOptions?.steadyStateIntervalMs ?? 6e5;
|
|
408
943
|
const fastIntervalMs = reclaimOptions?.fastIntervalMs ?? 2e3;
|
|
409
944
|
const escalateRatio = reclaimOptions?.escalateRatio ?? .7;
|
|
410
945
|
const deescalateRatio = reclaimOptions?.deescalateRatio ?? .6;
|
|
@@ -424,9 +959,10 @@ function startHeapWatch(label = "hub-main", sink = consoleSink, intervalMs = HEA
|
|
|
424
959
|
steadyStateAnnounced = false;
|
|
425
960
|
return;
|
|
426
961
|
}
|
|
962
|
+
const floorMs = reclaimIntervalMs(passesAtFloor, minIntervalMs, steadyStateIntervalMs);
|
|
427
963
|
const sinceLast = now() - lastReclaimAt;
|
|
428
|
-
if (sinceLast <
|
|
429
|
-
passesAtFloor = sinceLast <=
|
|
964
|
+
if (sinceLast < floorMs) return;
|
|
965
|
+
passesAtFloor = sinceLast <= floorMs * 2 ? passesAtFloor + 1 : 1;
|
|
430
966
|
lastReclaimAt = now();
|
|
431
967
|
const startedAt = now();
|
|
432
968
|
try {
|
|
@@ -439,7 +975,8 @@ function startHeapWatch(label = "hub-main", sink = consoleSink, intervalMs = HEA
|
|
|
439
975
|
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`);
|
|
440
976
|
if (passesAtFloor >= 6 && !steadyStateAnnounced) {
|
|
441
977
|
steadyStateAnnounced = true;
|
|
442
|
-
|
|
978
|
+
const nextFloorMs = reclaimIntervalMs(passesAtFloor, minIntervalMs, steadyStateIntervalMs);
|
|
979
|
+
sink.warn(`[mem] reclaim ${label} has run at the floor for ${passesAtFloor} consecutive passes (every ${minIntervalMs}ms) — the stop-the-world is now STEADY STATE, not a rescue: stranded never fell back under ${triggerMb}MB between passes, so the trigger is not discriminating on this process and what is left deciding is the timer. BACKING OFF to every ${nextFloorMs}ms — the pass keeps running (it is still the only measurement of this process's live ArrayBuffer set) at a cost the router can pay. The allocation source is what needs the fix; this pass only bounds its peak.`);
|
|
443
980
|
}
|
|
444
981
|
};
|
|
445
982
|
let mode = "steady";
|
|
@@ -502,7 +1039,7 @@ function startHeapWatch(label = "hub-main", sink = consoleSink, intervalMs = HEA
|
|
|
502
1039
|
const due = at - lastLoggedAt >= intervalMs;
|
|
503
1040
|
if (mode === "escalated" || due) {
|
|
504
1041
|
lastLoggedAt = at;
|
|
505
|
-
const line = format(label, sample, loopDelay?.read(), budgetMb);
|
|
1042
|
+
const line = format(label, sample, loopDelay?.read(), budgetMb, `${formatHeapSpaces(readSpaces())}${formatEventPlane(eventPlane?.read())}`);
|
|
506
1043
|
if (sample.nearLimit) sink.warn(`${line} — APPROACHING HEAP LIMIT`);
|
|
507
1044
|
else if (mode === "escalated") sink.warn(`${line} — heap elevated, sampling every ${probeIntervalMs}ms`);
|
|
508
1045
|
else sink.info(line);
|
|
@@ -5421,6 +5958,34 @@ var LocalChildRegistry = class {
|
|
|
5421
5958
|
};
|
|
5422
5959
|
}
|
|
5423
5960
|
/**
|
|
5961
|
+
* The regime the counters above were produced under, read once from
|
|
5962
|
+
* `CAMSTACK_UDS_EVENT_FANOUT` at construction.
|
|
5963
|
+
*
|
|
5964
|
+
* A suppressed count means "nothing WAS dropped" under `filter` and "nothing
|
|
5965
|
+
* WOULD have been dropped" under `shadow`. Those are different claims, and
|
|
5966
|
+
* until this getter existed the mode was invisible at runtime — the counters
|
|
5967
|
+
* were readable and the regime that produced them was not.
|
|
5968
|
+
*/
|
|
5969
|
+
get eventFanoutMode() {
|
|
5970
|
+
return this.fanoutMode;
|
|
5971
|
+
}
|
|
5972
|
+
/**
|
|
5973
|
+
* Connected children that never declared a subscription pattern set.
|
|
5974
|
+
*
|
|
5975
|
+
* Each one is a fail-open on BOTH planes: `childWantsEvent` returns true for
|
|
5976
|
+
* an undeclared child ({@link ChildEntry.eventPatterns} `=== null`), and
|
|
5977
|
+
* `aggregateEventInterest` returns `null` — accept everything — if ANY
|
|
5978
|
+
* connected child is undeclared, which disables the node's inbound cross-node
|
|
5979
|
+
* gate as well. **One legacy runner silently turns off the filtering for the
|
|
5980
|
+
* whole node**, and nothing reported it, so a zero here is as load-bearing as
|
|
5981
|
+
* a non-zero one.
|
|
5982
|
+
*/
|
|
5983
|
+
undeclaredChildCount() {
|
|
5984
|
+
let count = 0;
|
|
5985
|
+
for (const entry of this.children.values()) if (entry.eventPatterns === null) count += 1;
|
|
5986
|
+
return count;
|
|
5987
|
+
}
|
|
5988
|
+
/**
|
|
5424
5989
|
* Does `entry` want to receive an event in `category`? Honours the fan-out
|
|
5425
5990
|
* mode: `broadcast` → always; undeclared patterns (`null`) → always
|
|
5426
5991
|
* (fail-open); else the child's declared set must match via `matchesPattern`
|
|
@@ -8634,12 +9199,24 @@ Object.defineProperty(exports, "CapabilityUnavailableError", {
|
|
|
8634
9199
|
return CapabilityUnavailableError;
|
|
8635
9200
|
}
|
|
8636
9201
|
});
|
|
9202
|
+
Object.defineProperty(exports, "DECISIVE_HEAP_SPACES", {
|
|
9203
|
+
enumerable: true,
|
|
9204
|
+
get: function() {
|
|
9205
|
+
return DECISIVE_HEAP_SPACES;
|
|
9206
|
+
}
|
|
9207
|
+
});
|
|
8637
9208
|
Object.defineProperty(exports, "DeviceRegistry", {
|
|
8638
9209
|
enumerable: true,
|
|
8639
9210
|
get: function() {
|
|
8640
9211
|
return DeviceRegistry;
|
|
8641
9212
|
}
|
|
8642
9213
|
});
|
|
9214
|
+
Object.defineProperty(exports, "EVENT_PLANE_TOP_N", {
|
|
9215
|
+
enumerable: true,
|
|
9216
|
+
get: function() {
|
|
9217
|
+
return EVENT_PLANE_TOP_N;
|
|
9218
|
+
}
|
|
9219
|
+
});
|
|
8643
9220
|
Object.defineProperty(exports, "EVENT_TOPIC_PREFIX", {
|
|
8644
9221
|
enumerable: true,
|
|
8645
9222
|
get: function() {
|
|
@@ -8658,12 +9235,24 @@ Object.defineProperty(exports, "HEAP_RECLAIM_MIN_INTERVAL_MS", {
|
|
|
8658
9235
|
return HEAP_RECLAIM_MIN_INTERVAL_MS;
|
|
8659
9236
|
}
|
|
8660
9237
|
});
|
|
9238
|
+
Object.defineProperty(exports, "HEAP_RECLAIM_STEADY_STATE_INTERVAL_MS", {
|
|
9239
|
+
enumerable: true,
|
|
9240
|
+
get: function() {
|
|
9241
|
+
return HEAP_RECLAIM_STEADY_STATE_INTERVAL_MS;
|
|
9242
|
+
}
|
|
9243
|
+
});
|
|
8661
9244
|
Object.defineProperty(exports, "HEAP_RECLAIM_TRIGGER_MB", {
|
|
8662
9245
|
enumerable: true,
|
|
8663
9246
|
get: function() {
|
|
8664
9247
|
return HEAP_RECLAIM_TRIGGER_MB;
|
|
8665
9248
|
}
|
|
8666
9249
|
});
|
|
9250
|
+
Object.defineProperty(exports, "HEAP_SPACE_REPORT_MIN_MB", {
|
|
9251
|
+
enumerable: true,
|
|
9252
|
+
get: function() {
|
|
9253
|
+
return HEAP_SPACE_REPORT_MIN_MB;
|
|
9254
|
+
}
|
|
9255
|
+
});
|
|
8667
9256
|
Object.defineProperty(exports, "HEAP_WATCH_INTERVAL_MS", {
|
|
8668
9257
|
enumerable: true,
|
|
8669
9258
|
get: function() {
|
|
@@ -8688,6 +9277,12 @@ Object.defineProperty(exports, "HUB_CAP_FWD_SERVICE", {
|
|
|
8688
9277
|
return HUB_CAP_FWD_SERVICE;
|
|
8689
9278
|
}
|
|
8690
9279
|
});
|
|
9280
|
+
Object.defineProperty(exports, "HUB_MAIN_HEAP_WATCH_LABEL", {
|
|
9281
|
+
enumerable: true,
|
|
9282
|
+
get: function() {
|
|
9283
|
+
return HUB_MAIN_HEAP_WATCH_LABEL;
|
|
9284
|
+
}
|
|
9285
|
+
});
|
|
8691
9286
|
Object.defineProperty(exports, "HUB_MAIN_RSS_BUDGET_MB", {
|
|
8692
9287
|
enumerable: true,
|
|
8693
9288
|
get: function() {
|
|
@@ -8730,6 +9325,12 @@ Object.defineProperty(exports, "RSS_BUDGET_RELEASE_RATIO", {
|
|
|
8730
9325
|
return RSS_BUDGET_RELEASE_RATIO;
|
|
8731
9326
|
}
|
|
8732
9327
|
});
|
|
9328
|
+
Object.defineProperty(exports, "RUNNER_HEAP_SNAPSHOT_ENV", {
|
|
9329
|
+
enumerable: true,
|
|
9330
|
+
get: function() {
|
|
9331
|
+
return RUNNER_HEAP_SNAPSHOT_ENV;
|
|
9332
|
+
}
|
|
9333
|
+
});
|
|
8733
9334
|
Object.defineProperty(exports, "RUNNER_HEAP_WATCH_INTERVAL_MS", {
|
|
8734
9335
|
enumerable: true,
|
|
8735
9336
|
get: function() {
|
|
@@ -8766,6 +9367,12 @@ Object.defineProperty(exports, "UdsLocalTransportServer", {
|
|
|
8766
9367
|
return UdsLocalTransportServer;
|
|
8767
9368
|
}
|
|
8768
9369
|
});
|
|
9370
|
+
Object.defineProperty(exports, "ZERO_LOOP_DELAY", {
|
|
9371
|
+
enumerable: true,
|
|
9372
|
+
get: function() {
|
|
9373
|
+
return ZERO_LOOP_DELAY;
|
|
9374
|
+
}
|
|
9375
|
+
});
|
|
8769
9376
|
Object.defineProperty(exports, "__resetCapUsageRegistryForTests", {
|
|
8770
9377
|
enumerable: true,
|
|
8771
9378
|
get: function() {
|
|
@@ -8886,6 +9493,24 @@ Object.defineProperty(exports, "createBrokerDeviceManagerApi", {
|
|
|
8886
9493
|
return createBrokerDeviceManagerApi;
|
|
8887
9494
|
}
|
|
8888
9495
|
});
|
|
9496
|
+
Object.defineProperty(exports, "createDeferredHeapWatchSink", {
|
|
9497
|
+
enumerable: true,
|
|
9498
|
+
get: function() {
|
|
9499
|
+
return createDeferredHeapWatchSink;
|
|
9500
|
+
}
|
|
9501
|
+
});
|
|
9502
|
+
Object.defineProperty(exports, "createEventPlaneMeter", {
|
|
9503
|
+
enumerable: true,
|
|
9504
|
+
get: function() {
|
|
9505
|
+
return createEventPlaneMeter;
|
|
9506
|
+
}
|
|
9507
|
+
});
|
|
9508
|
+
Object.defineProperty(exports, "createEventPlaneReader", {
|
|
9509
|
+
enumerable: true,
|
|
9510
|
+
get: function() {
|
|
9511
|
+
return createEventPlaneReader;
|
|
9512
|
+
}
|
|
9513
|
+
});
|
|
8889
9514
|
Object.defineProperty(exports, "createHubCapForwardService", {
|
|
8890
9515
|
enumerable: true,
|
|
8891
9516
|
get: function() {
|
|
@@ -8904,6 +9529,12 @@ Object.defineProperty(exports, "createLocalTransport", {
|
|
|
8904
9529
|
return createLocalTransport;
|
|
8905
9530
|
}
|
|
8906
9531
|
});
|
|
9532
|
+
Object.defineProperty(exports, "createLoopDelayMeter", {
|
|
9533
|
+
enumerable: true,
|
|
9534
|
+
get: function() {
|
|
9535
|
+
return createLoopDelayMeter;
|
|
9536
|
+
}
|
|
9537
|
+
});
|
|
8907
9538
|
Object.defineProperty(exports, "createParentUnownedCallHandler", {
|
|
8908
9539
|
enumerable: true,
|
|
8909
9540
|
get: function() {
|
|
@@ -8958,12 +9589,36 @@ Object.defineProperty(exports, "deserializeTypedArrays", {
|
|
|
8958
9589
|
return deserializeTypedArrays;
|
|
8959
9590
|
}
|
|
8960
9591
|
});
|
|
9592
|
+
Object.defineProperty(exports, "diffEventPlane", {
|
|
9593
|
+
enumerable: true,
|
|
9594
|
+
get: function() {
|
|
9595
|
+
return diffEventPlane;
|
|
9596
|
+
}
|
|
9597
|
+
});
|
|
9598
|
+
Object.defineProperty(exports, "emitHeapDiagnosticReport", {
|
|
9599
|
+
enumerable: true,
|
|
9600
|
+
get: function() {
|
|
9601
|
+
return emitHeapDiagnosticReport;
|
|
9602
|
+
}
|
|
9603
|
+
});
|
|
8961
9604
|
Object.defineProperty(exports, "encodeFrame", {
|
|
8962
9605
|
enumerable: true,
|
|
8963
9606
|
get: function() {
|
|
8964
9607
|
return encodeFrame;
|
|
8965
9608
|
}
|
|
8966
9609
|
});
|
|
9610
|
+
Object.defineProperty(exports, "formatEventPlane", {
|
|
9611
|
+
enumerable: true,
|
|
9612
|
+
get: function() {
|
|
9613
|
+
return formatEventPlane;
|
|
9614
|
+
}
|
|
9615
|
+
});
|
|
9616
|
+
Object.defineProperty(exports, "formatHeapSpaces", {
|
|
9617
|
+
enumerable: true,
|
|
9618
|
+
get: function() {
|
|
9619
|
+
return formatHeapSpaces;
|
|
9620
|
+
}
|
|
9621
|
+
});
|
|
8967
9622
|
Object.defineProperty(exports, "getBrokerEventBus", {
|
|
8968
9623
|
enumerable: true,
|
|
8969
9624
|
get: function() {
|
|
@@ -9012,6 +9667,18 @@ Object.defineProperty(exports, "getWorkerNativeCapSnapshot", {
|
|
|
9012
9667
|
return getWorkerNativeCapSnapshot;
|
|
9013
9668
|
}
|
|
9014
9669
|
});
|
|
9670
|
+
Object.defineProperty(exports, "heapSnapshotAuthorised", {
|
|
9671
|
+
enumerable: true,
|
|
9672
|
+
get: function() {
|
|
9673
|
+
return heapSnapshotAuthorised;
|
|
9674
|
+
}
|
|
9675
|
+
});
|
|
9676
|
+
Object.defineProperty(exports, "heapSpaceField", {
|
|
9677
|
+
enumerable: true,
|
|
9678
|
+
get: function() {
|
|
9679
|
+
return heapSpaceField;
|
|
9680
|
+
}
|
|
9681
|
+
});
|
|
9015
9682
|
Object.defineProperty(exports, "hubMainRssBudget", {
|
|
9016
9683
|
enumerable: true,
|
|
9017
9684
|
get: function() {
|
|
@@ -9072,6 +9739,24 @@ Object.defineProperty(exports, "parseRssBudgetMb", {
|
|
|
9072
9739
|
return parseRssBudgetMb;
|
|
9073
9740
|
}
|
|
9074
9741
|
});
|
|
9742
|
+
Object.defineProperty(exports, "readHeapSpaces", {
|
|
9743
|
+
enumerable: true,
|
|
9744
|
+
get: function() {
|
|
9745
|
+
return readHeapSpaces;
|
|
9746
|
+
}
|
|
9747
|
+
});
|
|
9748
|
+
Object.defineProperty(exports, "readMoleculerFanoutMode", {
|
|
9749
|
+
enumerable: true,
|
|
9750
|
+
get: function() {
|
|
9751
|
+
return readMoleculerFanoutMode;
|
|
9752
|
+
}
|
|
9753
|
+
});
|
|
9754
|
+
Object.defineProperty(exports, "reclaimIntervalMs", {
|
|
9755
|
+
enumerable: true,
|
|
9756
|
+
get: function() {
|
|
9757
|
+
return reclaimIntervalMs;
|
|
9758
|
+
}
|
|
9759
|
+
});
|
|
9075
9760
|
Object.defineProperty(exports, "registerEventBusService", {
|
|
9076
9761
|
enumerable: true,
|
|
9077
9762
|
get: function() {
|
|
@@ -9102,6 +9787,12 @@ Object.defineProperty(exports, "runNpm", {
|
|
|
9102
9787
|
return runNpm;
|
|
9103
9788
|
}
|
|
9104
9789
|
});
|
|
9790
|
+
Object.defineProperty(exports, "selectReportedSpaces", {
|
|
9791
|
+
enumerable: true,
|
|
9792
|
+
get: function() {
|
|
9793
|
+
return selectReportedSpaces;
|
|
9794
|
+
}
|
|
9795
|
+
});
|
|
9105
9796
|
Object.defineProperty(exports, "serializeTypedArrays", {
|
|
9106
9797
|
enumerable: true,
|
|
9107
9798
|
get: function() {
|