@camstack/system 1.2.217 → 1.2.219

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 (95) hide show
  1. package/dist/addon-runner.js +9 -6
  2. package/dist/addon-runner.mjs +9 -6
  3. package/dist/addon-utils.js +2 -2
  4. package/dist/addon-utils.mjs +2 -2
  5. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  8. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  9. package/dist/builtins/alerts/alerts.addon.js +1 -1
  10. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  13. package/dist/builtins/console-logging/index.js +1 -1
  14. package/dist/builtins/console-logging/index.mjs +1 -1
  15. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  16. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  17. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  18. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  19. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  20. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  21. package/dist/builtins/hub-forwarder/index.js +1 -1
  22. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  23. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  24. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  25. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  26. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  27. package/dist/builtins/local-network/local-network.addon.js +2 -2
  28. package/dist/builtins/local-network/local-network.addon.mjs +2 -2
  29. package/dist/builtins/loki-logging/index.js +1 -1
  30. package/dist/builtins/loki-logging/index.mjs +1 -1
  31. package/dist/builtins/native-metrics/gpu-probe.d.ts +18 -0
  32. package/dist/builtins/native-metrics/native-metrics-provider.d.ts +1 -1
  33. package/dist/builtins/native-metrics/native-metrics.addon.d.ts +24 -8
  34. package/dist/builtins/native-metrics/native-metrics.addon.js +1318 -1098
  35. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1318 -1101
  36. package/dist/builtins/native-metrics/proc-process-table.d.ts +25 -0
  37. package/dist/builtins/platform-probe/index.js +3 -3
  38. package/dist/builtins/platform-probe/index.mjs +3 -3
  39. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  40. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  41. package/dist/builtins/snapshot/index.js +1 -1
  42. package/dist/builtins/snapshot/index.mjs +1 -1
  43. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  44. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  45. package/dist/builtins/sqlite-storage/sqlite-pragmas.d.ts +23 -0
  46. package/dist/builtins/sqlite-storage/sqlite-settings-backend.d.ts +1 -1
  47. package/dist/builtins/sqlite-storage/sqlite-settings.addon.d.ts +1 -0
  48. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  49. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  50. package/dist/builtins/sqlite-storage/wal-checkpoint-policy.d.ts +115 -0
  51. package/dist/builtins/sqlite-storage/wal-checkpoint-worker.d.ts +1 -0
  52. package/dist/builtins/sqlite-storage/wal-checkpointer.d.ts +70 -0
  53. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  54. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  55. package/dist/builtins/system-config/system-config.addon.js +1 -1
  56. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  57. package/dist/builtins/winston-logging/index.js +1 -1
  58. package/dist/builtins/winston-logging/index.mjs +1 -1
  59. package/dist/{custom-action-registry-F__gp_VX.mjs → custom-action-registry-B6gOtUWA.mjs} +19 -0
  60. package/dist/{custom-action-registry-jY0NOZK8.js → custom-action-registry-DKWhaWL1.js} +19 -0
  61. package/dist/{dist-C85OOASj.js → dist-B17W9ngu.js} +118 -3
  62. package/dist/{dist-DtDKMSeG.mjs → dist-Cyz5_i7z.mjs} +113 -4
  63. package/dist/index.d.ts +9 -2
  64. package/dist/index.js +39 -36
  65. package/dist/index.mjs +23 -38
  66. package/dist/kernel/array-buffer-census.d.ts +122 -0
  67. package/dist/kernel/custom-action-registry.d.ts +10 -1
  68. package/dist/kernel/heap-watch.d.ts +39 -1
  69. package/dist/kernel/moleculer/mesh-queue-report.d.ts +51 -0
  70. package/dist/kernel/outbound-queue-watch.d.ts +94 -0
  71. package/dist/kernel/socket-plane-report.d.ts +75 -4
  72. package/dist/kernel/transport/cap-routing-hints.d.ts +21 -0
  73. package/dist/kernel/transport/child-cap-protocol.d.ts +43 -3
  74. package/dist/kernel/transport/frame-codec.d.ts +49 -0
  75. package/dist/kernel/transport/local-child-client.d.ts +25 -2
  76. package/dist/kernel/transport/local-child-registry.d.ts +36 -1
  77. package/dist/kernel/transport/local-transport.d.ts +45 -2
  78. package/dist/kernel/transport/socket-channel.d.ts +19 -1
  79. package/dist/{manifest-system-deps-726RBOX7.mjs → manifest-system-deps-C7dihwWT.mjs} +1604 -788
  80. package/dist/{manifest-system-deps-DvY1bY_n.js → manifest-system-deps-g_nK0xsF.js} +1607 -683
  81. package/dist/process/proc-stat.d.ts +75 -0
  82. package/dist/process/resource-monitor.d.ts +9 -1
  83. package/dist/{resource-monitor-CdnzxBLP.js → resource-monitor-LVE1BoLs.js} +42 -23
  84. package/dist/{resource-monitor-BWmQ5i-o.mjs → resource-monitor-Vp0bPV3U.mjs} +42 -22
  85. package/dist/{retired-settings-keys-Cx-lYIbU.mjs → retired-settings-keys-BCYUrdsA.mjs} +1 -1
  86. package/dist/{retired-settings-keys-Dfj9LA-i.js → retired-settings-keys-CjvB7Etx.js} +1 -1
  87. package/dist/wal-checkpoint-policy-CAcg63o-.mjs +92 -0
  88. package/dist/wal-checkpoint-policy-CCJZngds.js +127 -0
  89. package/dist/wal-checkpoint-worker.js +138 -0
  90. package/dist/wal-checkpoint-worker.mjs +137 -0
  91. package/package.json +1 -1
  92. package/dist/{event-loop-stall-monitor-DM9OAzy8.js → event-loop-stall-monitor-CAsHdMwP.js} +1 -1
  93. package/dist/{event-loop-stall-monitor-B3CFd9tM.mjs → event-loop-stall-monitor-GPrVvx14.mjs} +1 -1
  94. package/dist/{tls-DOTmtLCW.mjs → tls-2xbgg78V.mjs} +2 -2
  95. package/dist/{tls-BxQlomxd.js → tls-BFs4PzoW.js} +2 -2
@@ -5,14 +5,15 @@ import { ensureBinary, ensureFfmpeg, ensurePython, installPythonPackages, instal
5
5
  import { unlink } from "node:fs/promises";
6
6
  import * as path$1 from "node:path";
7
7
  import { isAbsolute, join } from "node:path";
8
- import { execFile } from "node:child_process";
9
- import { promisify } from "node:util";
10
8
  import * as fs from "node:fs";
11
9
  import { existsSync } from "node:fs";
10
+ import { execFile } from "node:child_process";
12
11
  import * as os from "node:os";
13
12
  import { tmpdir } from "node:os";
13
+ import { promisify } from "node:util";
14
14
  import { createServer } from "node:http";
15
15
  import * as v8 from "node:v8";
16
+ import { Session } from "node:inspector/promises";
16
17
  import { monitorEventLoopDelay } from "node:perf_hooks";
17
18
  import * as vm from "node:vm";
18
19
  import { BOOT_RECOVERY_BACKOFF_MS, DATAPLANE_SECRET_HEADER, DEVICE_CHILDREN_BATCH_MAX, DeviceType, DisposerChain, EventCategory, ReadinessRegistry, asJsonObject, asString, createDeviceProxy, createEventBusSliceSource, deviceOpsCapability, emitReadiness, errMsg, expandCapMethods, readNodePin, scopeKey, sleep, withCapInputDefaults } from "@camstack/types/addon";
@@ -307,12 +308,15 @@ function createSocketPlaneReader(sources) {
307
308
  peers.push({
308
309
  peerId: child.childId,
309
310
  tx: traffic.tx,
310
- rx: traffic.rx
311
+ rx: traffic.rx,
312
+ queuedBytes: registry.getChildQueuedBytes(child.childId)
311
313
  });
312
314
  }
315
+ const forward = registry.readForwardCensus?.();
313
316
  return {
314
317
  peers,
315
- peersConnected: listed.length
318
+ peersConnected: listed.length,
319
+ ...forward === void 0 ? {} : { forward }
316
320
  };
317
321
  } catch {
318
322
  return;
@@ -330,8 +334,41 @@ function createSocketPlaneReader(sources) {
330
334
  * stops being read.
331
335
  */
332
336
  var SOCKET_PLANE_TOP_N = 5;
337
+ /** How many queue-holding peers get named. Three: a queue is an exception,
338
+ * and the line already carries five names for traffic. */
339
+ var SOCKET_QUEUE_TOP_N = 3;
340
+ /** The queue reading over the current counters — a level, so no baseline. */
341
+ function readSocketQueues(current) {
342
+ let totalBytes = 0;
343
+ let unmeasured = 0;
344
+ const holding = [];
345
+ for (const peer of current.peers) {
346
+ if (peer.queuedBytes === null) {
347
+ unmeasured += 1;
348
+ continue;
349
+ }
350
+ totalBytes += peer.queuedBytes;
351
+ if (peer.queuedBytes > 0) holding.push({
352
+ peerId: peer.peerId,
353
+ bytes: peer.queuedBytes
354
+ });
355
+ }
356
+ return {
357
+ totalBytes,
358
+ unmeasured,
359
+ top: holding.toSorted((a, b) => b.bytes - a.bytes || a.peerId.localeCompare(b.peerId)).slice(0, 3)
360
+ };
361
+ }
333
362
  /** A process that has never read the plane. */
334
363
  var EMPTY_SOCKET_PLANE_BASELINE = { peers: /* @__PURE__ */ new Map() };
364
+ /** The census over the window; a counter that went backwards reads as quiet. */
365
+ function diffForward(current, before) {
366
+ if (current === void 0) return void 0;
367
+ return {
368
+ raw: Math.max(0, current.raw - (before?.raw ?? 0)),
369
+ decoded: Math.max(0, current.decoded - (before?.decoded ?? 0))
370
+ };
371
+ }
335
372
  function diffDirection(current, before) {
336
373
  const previous = before ?? EMPTY_SOCKET_DIRECTION;
337
374
  return {
@@ -357,6 +394,14 @@ function addDirection(into, add) {
357
394
  function peerMessages(delta) {
358
395
  return socketDirectionMessages(delta.tx) + socketDirectionMessages(delta.rx);
359
396
  }
397
+ /**
398
+ * Total bytes a peer moved in the window, both directions — the OTHER
399
+ * ranking, and the one that names a peer the message ranking structurally
400
+ * cannot see (D454).
401
+ */
402
+ function peerBytes(delta) {
403
+ return socketDirectionBytes(delta.tx) + socketDirectionBytes(delta.rx);
404
+ }
360
405
  /** Subtract the previous cumulative reading from the current one. */
361
406
  function diffSocketPlane(baseline, current, topN = 5) {
362
407
  const deltas = [];
@@ -373,12 +418,16 @@ function diffSocketPlane(baseline, current, topN = 5) {
373
418
  rx = addDirection(rx, delta.rx);
374
419
  if (peerMessages(delta) > 0) deltas.push(delta);
375
420
  }
421
+ const forward = diffForward(current.forward, baseline.forward);
376
422
  return {
377
423
  peersConnected: current.peersConnected,
378
424
  peersMeasured: current.peers.length,
379
425
  tx,
380
426
  rx,
381
- top: deltas.toSorted((a, b) => peerMessages(b) - peerMessages(a) || a.peerId.localeCompare(b.peerId)).slice(0, topN)
427
+ top: deltas.toSorted((a, b) => peerMessages(b) - peerMessages(a) || a.peerId.localeCompare(b.peerId)).slice(0, topN),
428
+ topBytes: deltas.toSorted((a, b) => peerBytes(b) - peerBytes(a) || a.peerId.localeCompare(b.peerId)).slice(0, topN),
429
+ queued: readSocketQueues(current),
430
+ ...forward === void 0 ? {} : { forward }
382
431
  };
383
432
  }
384
433
  function createSocketPlaneMeter(reader, topN = 5) {
@@ -387,24 +436,43 @@ function createSocketPlaneMeter(reader, topN = 5) {
387
436
  const current = reader();
388
437
  if (current === void 0) return void 0;
389
438
  const window = diffSocketPlane(baseline, current, topN);
390
- baseline = { peers: new Map(current.peers.map((p) => [p.peerId, p])) };
439
+ baseline = {
440
+ peers: new Map(current.peers.map((p) => [p.peerId, p])),
441
+ ...current.forward === void 0 ? {} : { forward: current.forward }
442
+ };
391
443
  return window;
392
444
  } };
393
445
  }
394
446
  /** Bytes → whole kB. The plane moves 180–330 MB/min; raw bytes would put
395
447
  * nine-digit numbers on a line that has to stay readable. */
396
- function kb(bytes) {
448
+ function kb$1(bytes) {
397
449
  return Math.round(bytes / 1024);
398
450
  }
399
451
  /** `req:<msgs>/<kB>,res:<msgs>/<kB>,evt:<msgs>/<kB>` — every kind, always,
400
452
  * including at zero: a kind that went quiet is a reading, not a blank. */
401
453
  function formatDirection(sample) {
402
- return `req:${sample.reqMessages}/${kb(sample.reqBytes)}kB,res:${sample.resMessages}/${kb(sample.resBytes)}kB,evt:${sample.evtMessages}/${kb(sample.evtBytes)}kB`;
454
+ return `req:${sample.reqMessages}/${kb$1(sample.reqBytes)}kB,res:${sample.resMessages}/${kb$1(sample.resBytes)}kB,evt:${sample.evtMessages}/${kb$1(sample.evtBytes)}kB`;
403
455
  }
404
- /** `<peer>:tx=<req>/<res>/<evt>:rx=<req>/<res>/<evt>:kB=<tx>/<rx>` — the whole
405
- * peer×kind cross for one peer, which is the question in one token. */
456
+ /** `<req>/<res>/<evt>` in kB — the byte triple, in the same order as the
457
+ * message triple next to it, so the two read as one table. */
458
+ function formatPeerBytes(sample) {
459
+ return `${kb$1(sample.reqBytes)}/${kb$1(sample.resBytes)}/${kb$1(sample.evtBytes)}`;
460
+ }
461
+ /**
462
+ * `<peer>:tx=<req>/<res>/<evt>:rx=…:txkB=<req>/<res>/<evt>:rxkB=…` — the whole
463
+ * peer×kind cross for one peer, messages and bytes, which is the question in
464
+ * one token.
465
+ *
466
+ * The bytes were a per-direction TOTAL until D454, and the total could not
467
+ * answer the question `socket-traffic.ts` says this instrument exists to
468
+ * settle: whether a peer's bytes are RESPONSES (media, and D9/D18 re-opens) or
469
+ * fan-out (`evt`, which scales with runner count and nothing an operator
470
+ * asked for). On 2026-09-11 hub-main wrote 118 MB in one minute to
471
+ * `hub/pipeline-analytics` and the line could only say "118 MB" — separating
472
+ * `res` from `evt` took an hour of arithmetic against the plane-wide totals.
473
+ */
406
474
  function formatPeer(delta) {
407
- return `${delta.peerId}:tx=${delta.tx.reqMessages}/${delta.tx.resMessages}/${delta.tx.evtMessages}:rx=${delta.rx.reqMessages}/${delta.rx.resMessages}/${delta.rx.evtMessages}:kB=${kb(socketDirectionBytes(delta.tx))}/${kb(socketDirectionBytes(delta.rx))}`;
475
+ return `${delta.peerId}:tx=${delta.tx.reqMessages}/${delta.tx.resMessages}/${delta.tx.evtMessages}:rx=${delta.rx.reqMessages}/${delta.rx.resMessages}/${delta.rx.evtMessages}:txkB=${formatPeerBytes(delta.tx)}:rxkB=${formatPeerBytes(delta.rx)}`;
408
476
  }
409
477
  /**
410
478
  * The suffix appended to a `[mem]` line — leading space included, empty when
@@ -417,14 +485,234 @@ function formatPeer(delta) {
417
485
  * is the only way to see an unmeasured channel, and the per-kind splits because
418
486
  * "the plane went quiet for a minute" is a reading.
419
487
  *
420
- * `socketTop` is the one conditional field — a ranked list of nothing is noise —
421
- * and it is a single comma-separated value so the field COUNT does not change
422
- * from line to line.
488
+ * `socketTop` is a conditional field — a ranked list of nothing is noise — and
489
+ * it is a single comma-separated value so the field COUNT does not change from
490
+ * line to line.
491
+ *
492
+ * `socketTopBytes` is the other one, and its PRESENCE is the reading: it is
493
+ * printed only when ranking by bytes names a peer that ranking by messages
494
+ * does not. A line without it says the two rankings agree; a line with it says
495
+ * a peer is moving few, heavy frames and names it (D454).
423
496
  */
424
497
  function formatSocketPlane(window) {
425
498
  if (window === void 0) return "";
426
499
  const top = window.top.map(formatPeer).join(",");
427
- return ` socketPeers=${window.peersMeasured}/${window.peersConnected} socketTx=${formatDirection(window.tx)} socketRx=${formatDirection(window.rx)}` + (top.length === 0 ? "" : ` socketTop=${top}`);
500
+ const named = new Set(window.top.map((p) => p.peerId));
501
+ const topBytes = window.topBytes.filter((p) => !named.has(p.peerId)).map(formatPeer).join(",");
502
+ const queuedTop = window.queued.top.map((p) => `${p.peerId}:${kb$1(p.bytes)}`).join(",");
503
+ return ` socketPeers=${window.peersMeasured}/${window.peersConnected} socketTx=${formatDirection(window.tx)} socketRx=${formatDirection(window.rx)}` + (top.length === 0 ? "" : ` socketTop=${top}`) + (topBytes.length === 0 ? "" : ` socketTopBytes=${topBytes}`) + ` socketQueued=${kb$1(window.queued.totalBytes)}kB` + (window.queued.unmeasured === 0 ? "" : ` socketQueuedUnmeasured=${window.queued.unmeasured}`) + (queuedTop.length === 0 ? "" : ` socketQueuedTop=${queuedTop}`) + (window.forward === void 0 ? "" : ` socketFwd=${window.forward.raw}/${window.forward.decoded}`);
504
+ }
505
+ //#endregion
506
+ //#region src/kernel/moleculer/mesh-queue-report.ts
507
+ function isRecord$1(value) {
508
+ return typeof value === "object" && value !== null;
509
+ }
510
+ /** The shape `TcpWriter` keeps its sockets in. Anything else is not a mesh. */
511
+ function writerSocketsOf(broker) {
512
+ if (!isRecord$1(broker)) return void 0;
513
+ const transit = broker["transit"];
514
+ if (!isRecord$1(transit)) return void 0;
515
+ const transporter = transit["tx"];
516
+ if (!isRecord$1(transporter)) return void 0;
517
+ const writer = transporter["writer"];
518
+ if (!isRecord$1(writer)) return void 0;
519
+ const sockets = writer["sockets"];
520
+ return sockets instanceof Map ? sockets : void 0;
521
+ }
522
+ function readSocket(nodeId, socket) {
523
+ if (typeof nodeId !== "string" || !isRecord$1(socket)) return void 0;
524
+ const queuedBytes = socket["writableLength"];
525
+ const bytesWritten = socket["bytesWritten"];
526
+ if (typeof queuedBytes !== "number" || typeof bytesWritten !== "number") return void 0;
527
+ return {
528
+ nodeId,
529
+ queuedBytes,
530
+ bytesWritten
531
+ };
532
+ }
533
+ /**
534
+ * Build a reader over a lazily-resolved broker. Wrapped: a broker mid-teardown
535
+ * may throw, and a diagnostic that can crash the process it watches is worse
536
+ * than none.
537
+ */
538
+ function createMeshQueueReader(getBroker) {
539
+ return () => {
540
+ try {
541
+ const sockets = writerSocketsOf(getBroker());
542
+ if (sockets === void 0) return void 0;
543
+ const peers = [];
544
+ for (const [nodeId, socket] of sockets) {
545
+ const peer = readSocket(nodeId, socket);
546
+ if (peer !== void 0) peers.push(peer);
547
+ }
548
+ return peers;
549
+ } catch {
550
+ return;
551
+ }
552
+ };
553
+ }
554
+ /** At most this many nodes named. A cluster has a handful. */
555
+ var MESH_QUEUE_TOP_N = 3;
556
+ function kb(bytes) {
557
+ return Math.round(bytes / 1024);
558
+ }
559
+ /**
560
+ * The suffix appended to a `[mem]` line — leading space included, empty when
561
+ * this process has no mesh writer. `meshQueued` is printed always, at zero
562
+ * included: a quiet mesh is a reading. `meshQueuedTop` names only nodes with
563
+ * a queue, largest first — a ranked list of zeros is noise.
564
+ */
565
+ function formatMeshQueue(peers) {
566
+ if (peers === void 0) return "";
567
+ const total = peers.reduce((sum, p) => sum + p.queuedBytes, 0);
568
+ const top = peers.filter((p) => p.queuedBytes > 0).toSorted((a, b) => b.queuedBytes - a.queuedBytes || a.nodeId.localeCompare(b.nodeId)).slice(0, 3).map((p) => `${p.nodeId}:${kb(p.queuedBytes)}`).join(",");
569
+ return ` meshQueued=${kb(total)}kB` + (top.length === 0 ? "" : ` meshQueuedTop=${top}`);
570
+ }
571
+ //#endregion
572
+ //#region src/kernel/outbound-queue-watch.ts
573
+ /**
574
+ * outbound-queue-watch — an outbound socket queue that HOLDS is named, and its
575
+ * release is timestamped (D446).
576
+ *
577
+ * ## The blind spot this closes
578
+ *
579
+ * On 2026-09-10 hub-main's `arrayBuffers` climbed twice at a constant rate
580
+ * (20 MB/min to 1 264 MB; 63 MB/min for 77 min to 4 968 MB), LIVE across a
581
+ * forced compaction, with ~4 % of JS heap alongside, and each time released in
582
+ * one instant with no line from any owner. The investigation excluded "every
583
+ * socket write queue" by summing `writableLength` over
584
+ * `process._getActiveHandles()` — 84 MB, all of it the phone's WebSocket.
585
+ *
586
+ * `_getActiveHandles()` lists only handles that keep the loop alive. An
587
+ * UNREF'D socket is not one of them, and Moleculer's `TcpWriter.connect`
588
+ * unrefs every socket it opens to a peer node (`transporters/tcp/tcp-writer.js`,
589
+ * `socket.unref()`). So the one outbound plane that writes to another MACHINE
590
+ * — where a reader that stops draining is the ordinary failure — was never in
591
+ * the sum. The UDS children were in it, but only as a total, at one instant,
592
+ * by hand.
593
+ *
594
+ * ## What this is
595
+ *
596
+ * A pure gate over per-peer queue depths the heartbeat reads on every probe:
597
+ * the UDS children's `socket.writableLength` (`SocketChannel.queuedBytes`) and
598
+ * the mesh writer sockets' (`mesh-queue-report.ts`). It says three things and
599
+ * nothing else:
600
+ *
601
+ * - a peer whose queue has been at or above {@link OUTBOUND_QUEUE_THRESHOLD_BYTES}
602
+ * for {@link OUTBOUND_QUEUE_SUSTAIN_MS} is NAMED, with its current and peak
603
+ * depth and how long it has held — at most once per
604
+ * {@link OUTBOUND_QUEUE_COOLDOWN_MS};
605
+ * - a named peer whose queue then falls under half the threshold, or that
606
+ * vanishes from the readings (its socket died — which is how a queue is
607
+ * dropped in one instant), is reported as RELEASED with its peak and the
608
+ * hold, so the release the episodes never logged has a line and a peer;
609
+ * - a burst that drains inside the sustain window says nothing. Backpressure
610
+ * measures LIVENESS, not identity (D444): a peer momentarily behind is
611
+ * coming back.
612
+ *
613
+ * ## What it is NOT
614
+ *
615
+ * Not a bound. It terminates nothing: a UDS child whose channel is destroyed
616
+ * loses every in-flight call, and a mesh socket Moleculer reconnects on its
617
+ * own terms. The WS guard (`ws-slow-consumer-guard.ts`) bounds the one plane
618
+ * whose client is expendable. Here the line is the deliverable; what to do
619
+ * about a peer that holds is decided with the peer named, not before.
620
+ */
621
+ /** 64 MiB. Below this a queue is a burst on a busy peer (the orchestrator's
622
+ * channel moves ~1.5 MB/s of audio responses); above it, and held, it is
623
+ * retention with a name. */
624
+ var OUTBOUND_QUEUE_THRESHOLD_BYTES = 64 * 1024 * 1024;
625
+ /** Matches the ArrayBuffer census sustain: a minute above the bound. */
626
+ var OUTBOUND_QUEUE_SUSTAIN_MS = 6e4;
627
+ /** A held queue is re-named every ten minutes, not every probe. */
628
+ var OUTBOUND_QUEUE_COOLDOWN_MS = 10 * 6e4;
629
+ /** A release is a fall under this fraction of the threshold. */
630
+ var RELEASE_RATIO = .5;
631
+ /**
632
+ * The pure decision — threshold, sustain, cooldown, release, gone — testable
633
+ * without a socket.
634
+ */
635
+ function createOutboundQueueGate(options = {}) {
636
+ const thresholdBytes = options.thresholdBytes ?? 67108864;
637
+ const sustainMs = options.sustainMs ?? 6e4;
638
+ const cooldownMs = options.cooldownMs ?? 6e5;
639
+ const tracked = /* @__PURE__ */ new Map();
640
+ const judgeOne = (peerId, bytes, at) => {
641
+ const state = tracked.get(peerId);
642
+ if (bytes >= thresholdBytes) {
643
+ const next = state === void 0 ? {
644
+ aboveSince: at,
645
+ peakBytes: bytes,
646
+ lastNamedAt: null
647
+ } : {
648
+ ...state,
649
+ peakBytes: Math.max(state.peakBytes, bytes)
650
+ };
651
+ const heldForMs = at - next.aboveSince;
652
+ const due = heldForMs >= sustainMs && (next.lastNamedAt === null || at - next.lastNamedAt >= cooldownMs);
653
+ tracked.set(peerId, due ? {
654
+ ...next,
655
+ lastNamedAt: at
656
+ } : next);
657
+ return due ? {
658
+ kind: "sustained",
659
+ peerId,
660
+ bytes,
661
+ peakBytes: next.peakBytes,
662
+ heldForMs
663
+ } : void 0;
664
+ }
665
+ if (state === void 0) return void 0;
666
+ if (bytes >= thresholdBytes * RELEASE_RATIO) return void 0;
667
+ tracked.delete(peerId);
668
+ if (state.lastNamedAt === null) return void 0;
669
+ return {
670
+ kind: "released",
671
+ peerId,
672
+ peakBytes: state.peakBytes,
673
+ toBytes: bytes,
674
+ heldForMs: at - state.aboveSince,
675
+ gone: false
676
+ };
677
+ };
678
+ return { observe(readings, at) {
679
+ const verdicts = [];
680
+ const seen = /* @__PURE__ */ new Set();
681
+ for (const reading of readings) {
682
+ seen.add(reading.peerId);
683
+ const verdict = judgeOne(reading.peerId, reading.bytes, at);
684
+ if (verdict !== void 0) verdicts.push(verdict);
685
+ }
686
+ for (const [peerId, state] of tracked) {
687
+ if (seen.has(peerId)) continue;
688
+ tracked.delete(peerId);
689
+ if (state.lastNamedAt === null) continue;
690
+ verdicts.push({
691
+ kind: "released",
692
+ peerId,
693
+ peakBytes: state.peakBytes,
694
+ toBytes: 0,
695
+ heldForMs: at - state.aboveSince,
696
+ gone: true
697
+ });
698
+ }
699
+ return verdicts;
700
+ } };
701
+ }
702
+ var BYTES_PER_MB$1 = 1048576;
703
+ function mb$2(bytes) {
704
+ return Math.round(bytes / BYTES_PER_MB$1);
705
+ }
706
+ /** `60s` under two minutes, `77m` from there — the hold is read at a glance. */
707
+ function duration(ms) {
708
+ const seconds = Math.round(ms / 1e3);
709
+ return seconds < 120 ? `${seconds}s` : `${Math.round(seconds / 60)}m`;
710
+ }
711
+ /** The line for one verdict. Both carry the peer, the bytes and the hold;
712
+ * the sustained one also says why the older census could not see it. */
713
+ function formatOutboundQueueVerdict(label, verdict) {
714
+ if (verdict.kind === "sustained") return `[mem] ${label} outbound queue to ${verdict.peerId} has held ${mb$2(verdict.bytes)}MB (peak ${mb$2(verdict.peakBytes)}MB) for ${duration(verdict.heldForMs)} — the peer is not draining what this process writes to it. This is socket.writableLength on the channel itself; a sum over _getActiveHandles() does not list an unref'd socket and would miss it.`;
715
+ return `[mem] ${label} outbound queue to ${verdict.peerId} RELEASED — ${mb$2(verdict.peakBytes)}MB at peak, ${mb$2(verdict.toBytes)}MB now, held ${duration(verdict.heldForMs)}` + (verdict.gone ? " (peer gone: the socket closed and dropped its queue)" : " (drained)");
428
716
  }
429
717
  //#endregion
430
718
  //#region src/kernel/heap-spaces.ts
@@ -511,7 +799,7 @@ var DECISIVE_HEAP_SPACES = ["old_space", "large_object_space"];
511
799
  var HEAP_SPACE_REPORT_MIN_MB = 32;
512
800
  var BYTES_PER_MB = 1048576;
513
801
  /** Whole MB, matching the rest of the `[mem]` line. */
514
- function mb(bytes) {
802
+ function mb$1(bytes) {
515
803
  return Math.round(bytes / BYTES_PER_MB);
516
804
  }
517
805
  /** Tenths of an MB — the resolution the SIGUSR2 report has always printed. */
@@ -558,7 +846,7 @@ function heapSpaceField(v8Name) {
558
846
  */
559
847
  function selectReportedSpaces(spaces, minMb = 32) {
560
848
  const decisive = DECISIVE_HEAP_SPACES.map((name) => spaces.find((space) => space.name === name)).filter((space) => space !== void 0);
561
- 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));
849
+ const rest = spaces.filter((space) => !DECISIVE_HEAP_SPACES.includes(space.name)).filter((space) => mb$1(space.usedBytes) >= minMb).toSorted((a, b) => b.usedBytes - a.usedBytes || a.name.localeCompare(b.name));
562
850
  return [...decisive, ...rest];
563
851
  }
564
852
  /**
@@ -573,7 +861,7 @@ function selectReportedSpaces(spaces, minMb = 32) {
573
861
  function formatHeapSpaces(spaces, minMb = 32) {
574
862
  const selected = selectReportedSpaces(spaces, minMb);
575
863
  if (selected.length === 0) return "";
576
- return ` ${selected.map((s) => `${heapSpaceField(s.name)}=${mb(s.usedBytes)}MB`).join(" ")}`;
864
+ return ` ${selected.map((s) => `${heapSpaceField(s.name)}=${mb$1(s.usedBytes)}MB`).join(" ")}`;
577
865
  }
578
866
  var defaultDeps = {
579
867
  readMemory: () => process.memoryUsage(),
@@ -603,6 +891,251 @@ function emitHeapDiagnosticReport(request, deps = defaultDeps) {
603
891
  }
604
892
  }
605
893
  //#endregion
894
+ //#region src/kernel/array-buffer-census.ts
895
+ /**
896
+ * array-buffer-census — WHAT SHAPE the live ArrayBuffers have, taken by the
897
+ * heartbeat itself when they grow, because nobody can be awake for it.
898
+ *
899
+ * ## The episodes this exists for
900
+ *
901
+ * hub-main, 2026-09-10 (CEST): `arrayBuffers` climbed linearly and released in
902
+ * one instant, twice, with not one line from the process at either boundary —
903
+ * 17:26→18:31 at ~20 MB/min to 1 264 MB, released in one heartbeat; 22:20→23:36
904
+ * at 60–85 MB/min to **4 968 MB**, released between two probes at 23:38. Both
905
+ * were LIVE: a forced compacting GC at 23:36:40 returned 4 968→4 954 MB. The
906
+ * measurement that names their shape had been prepared (D443) and was never
907
+ * run, because it needed a human to run it during the episode, and the
908
+ * episode does not wait. Two hours after the second release the process died
909
+ * on `Ineffective mark-compacts near heap limit`.
910
+ *
911
+ * ## What this is, and what it is NOT
912
+ *
913
+ * `Runtime.queryObjects(ArrayBuffer.prototype)` over an IN-PROCESS inspector
914
+ * session, followed by `Runtime.callFunctionOn` computing a byteLength
915
+ * histogram over the result array, returned BY VALUE. It walks the heap in
916
+ * memory and returns a few hundred bytes of JSON. Nothing is serialised,
917
+ * nothing is written, no file exists afterwards.
918
+ *
919
+ * It is NOT a heap snapshot. `scripts/check-no-heap-dump-command.ts` forbids
920
+ * `writeHeapSnapshot` because a multi-GB serialisation of the heap took the
921
+ * whole host down twice; this module contains no such call and must never
922
+ * grow one. The two look alike only in that both need a GC first.
923
+ *
924
+ * ## What it costs, and why the line says so
925
+ *
926
+ * `queryObjects` forces ONE full garbage collection before it walks — the same
927
+ * stop-the-world the reclaim pass already pays every 10 minutes on this
928
+ * process (~1 s at a 2.5 GB heap). The cost is printed on the line it
929
+ * produces, because a pause with no owner in the log is exactly the kind of
930
+ * stall D181 warns about on the process that mediates every cross-runner call.
931
+ * The gate ({@link createArrayBufferCensusGate}) bounds it: a threshold, held
932
+ * for a sustain window so a burst that dies in the next scavenge never pays,
933
+ * and a generous cooldown so an hour-long episode buys a handful of readings,
934
+ * not one per probe.
935
+ *
936
+ * ## Why the byteLength histogram, and not "who holds them"
937
+ *
938
+ * `queryObjects` returns the objects, not their retainers; a retainer walk is
939
+ * a heap snapshot by another name. But the SIZE distribution already
940
+ * discriminates the candidates that were argued about for a night: 65 536-byte
941
+ * buffers are socket chunks, 8 192-byte ones are Buffer-pool slabs, a few
942
+ * hundred equal multi-megabyte ones are frames or JPEGs, and the count says
943
+ * whether it is thousands of small or dozens of huge. The exact top sizes are
944
+ * printed as well as power-of-two buckets, because "1 462 272 B × 880" names
945
+ * a producer where "≤2 MB: 1.2 GB" does not.
946
+ */
947
+ /**
948
+ * Take the census once `arrayBuffers` has been at or above this for
949
+ * {@link ARRAY_BUFFER_CENSUS_SUSTAIN_MS}.
950
+ *
951
+ * 512 MB. The two 2026-09-10 episodes crossed it 25 and 8 minutes in; the
952
+ * routine reading in six hours of 2-second samples around them was 18–100 MB
953
+ * with single-sample spikes to 479 MB, and the process's floor after every
954
+ * compaction is 12–18 MB. A dead-buffer sawtooth between major GCs (measured
955
+ * up to 3.65 GB on 2026-08-08 when the heap had 4 GB of headroom) cannot reach
956
+ * it while GC runs every few seconds — and if one did, the census would SAY
957
+ * so: it reports the live total after its own GC next to the raw reading.
958
+ */
959
+ var ARRAY_BUFFER_CENSUS_THRESHOLD_MB = 512;
960
+ /**
961
+ * How long the reading must stay above the threshold before a census is taken.
962
+ *
963
+ * 60 s = 30 probes at the 2 s cadence. A burst that a scavenge or the next
964
+ * major GC returns never pays a forced GC; an episode climbing at the slowest
965
+ * rate measured (20 MB/min) is still 20 MB higher by the time it is measured,
966
+ * which is nothing against the threshold.
967
+ */
968
+ var ARRAY_BUFFER_CENSUS_SUSTAIN_MS = 6e4;
969
+ /**
970
+ * Minimum spacing between two censuses.
971
+ *
972
+ * 30 minutes: the second episode ran 76 minutes, so it would have produced
973
+ * three readings — enough to see the shape change (or not) as it grew — at a
974
+ * cost the router already pays six times an hour for the reclaim pass.
975
+ */
976
+ var ARRAY_BUFFER_CENSUS_COOLDOWN_MS = 30 * 6e4;
977
+ /**
978
+ * A drop of at least this share of the threshold between two consecutive
979
+ * probes, from a reading that had been SUSTAINED above the threshold, is a
980
+ * RELEASE — the boundary both episodes crossed with no line, and the one line
981
+ * that lets the release be correlated with whatever else the process logged
982
+ * that second. Sustained, not merely above: a one-probe spike that the next
983
+ * scavenge sweeps (a 1.3 GB burst in 11 s was measured on 2026-08-29) is not
984
+ * an episode ending, and a WARN for every one of those would bury the line
985
+ * this exists to produce.
986
+ */
987
+ var ARRAY_BUFFER_RELEASE_MIN_DROP_RATIO = .5;
988
+ /**
989
+ * The pure decision: threshold, sustain, cooldown, release — testable without
990
+ * allocating a gigabyte or forcing a GC.
991
+ */
992
+ function createArrayBufferCensusGate(options = {}) {
993
+ const thresholdMb = options.thresholdMb ?? 512;
994
+ const sustainMs = options.sustainMs ?? 6e4;
995
+ const cooldownMs = options.cooldownMs ?? 18e5;
996
+ let aboveSince = null;
997
+ let lastTakenAt = Number.NEGATIVE_INFINITY;
998
+ let previousMb = null;
999
+ let previousAt = null;
1000
+ return { observe(arrayBuffersMb, at) {
1001
+ const previous = previousMb;
1002
+ const previousHeldForMs = previous !== null && previousAt !== null && aboveSince !== null ? previousAt - aboveSince : null;
1003
+ previousMb = arrayBuffersMb;
1004
+ previousAt = at;
1005
+ if (previous !== null && previousHeldForMs !== null && previousHeldForMs >= sustainMs && previous - arrayBuffersMb >= thresholdMb * .5) {
1006
+ aboveSince = null;
1007
+ return {
1008
+ kind: "released",
1009
+ fromMb: previous,
1010
+ toMb: arrayBuffersMb
1011
+ };
1012
+ }
1013
+ if (arrayBuffersMb < thresholdMb) {
1014
+ aboveSince = null;
1015
+ return { kind: "hold" };
1016
+ }
1017
+ aboveSince ??= at;
1018
+ const heldForMs = at - aboveSince;
1019
+ if (heldForMs < sustainMs) return { kind: "hold" };
1020
+ if (at - lastTakenAt < cooldownMs) return { kind: "hold" };
1021
+ lastTakenAt = at;
1022
+ return {
1023
+ kind: "take",
1024
+ reason: `arrayBuffers=${arrayBuffersMb}MB has been >=${thresholdMb}MB for ${heldForMs}ms`
1025
+ };
1026
+ } };
1027
+ }
1028
+ /**
1029
+ * The function evaluated INSIDE the inspector against the `queryObjects`
1030
+ * result array (`this`). Kept as source text because that is what the
1031
+ * protocol takes; kept small because it runs with the world stopped.
1032
+ */
1033
+ var HISTOGRAM_FN = `function () {
1034
+ var byBucket = new Map(), bySize = new Map()
1035
+ var count = 0, bytes = 0, detached = 0
1036
+ for (var i = 0; i < this.length; i++) {
1037
+ var b = this[i].byteLength
1038
+ count++
1039
+ bytes += b
1040
+ if (b === 0) { detached++; continue }
1041
+ var bucket = 1
1042
+ while (bucket < b) bucket *= 2
1043
+ var e = byBucket.get(bucket); if (!e) { e = [0, 0]; byBucket.set(bucket, e) }
1044
+ e[0]++; e[1] += b
1045
+ var s = bySize.get(b); if (!s) { s = [0, 0]; bySize.set(b, s) }
1046
+ s[0]++; s[1] += b
1047
+ }
1048
+ var buckets = []
1049
+ byBucket.forEach(function (v, k) { buckets.push({ upToBytes: k, count: v[0], bytes: v[1] }) })
1050
+ buckets.sort(function (a, b) { return b.bytes - a.bytes })
1051
+ var topSizes = []
1052
+ bySize.forEach(function (v, k) { topSizes.push({ byteLength: k, count: v[0], bytes: v[1] }) })
1053
+ topSizes.sort(function (a, b) { return b.bytes - a.bytes })
1054
+ return { count: count, bytes: bytes, detached: detached,
1055
+ buckets: buckets.slice(0, 6), topSizes: topSizes.slice(0, 5) }
1056
+ }`;
1057
+ function isFiniteNumber(value) {
1058
+ return typeof value === "number" && Number.isFinite(value);
1059
+ }
1060
+ function isBucket(value) {
1061
+ return typeof value === "object" && value !== null && isFiniteNumber(Reflect.get(value, "upToBytes")) && isFiniteNumber(Reflect.get(value, "count")) && isFiniteNumber(Reflect.get(value, "bytes"));
1062
+ }
1063
+ function isExactSize(value) {
1064
+ return typeof value === "object" && value !== null && isFiniteNumber(Reflect.get(value, "byteLength")) && isFiniteNumber(Reflect.get(value, "count")) && isFiniteNumber(Reflect.get(value, "bytes"));
1065
+ }
1066
+ /** The inspector answers `unknown`; the report is trusted only once it has this shape. */
1067
+ function isArrayBufferCensusReport(value) {
1068
+ if (typeof value !== "object" || value === null) return false;
1069
+ const buckets = Reflect.get(value, "buckets");
1070
+ const topSizes = Reflect.get(value, "topSizes");
1071
+ return isFiniteNumber(Reflect.get(value, "count")) && isFiniteNumber(Reflect.get(value, "bytes")) && isFiniteNumber(Reflect.get(value, "detached")) && Array.isArray(buckets) && buckets.every(isBucket) && Array.isArray(topSizes) && topSizes.every(isExactSize);
1072
+ }
1073
+ function readObjectId(result, path) {
1074
+ let cursor = result;
1075
+ for (const key of path) {
1076
+ if (typeof cursor !== "object" || cursor === null) throw new Error(`inspector reply has no ${path.join(".")}`);
1077
+ cursor = Reflect.get(cursor, key);
1078
+ }
1079
+ if (typeof cursor !== "string") throw new Error(`inspector reply has no ${path.join(".")}`);
1080
+ return cursor;
1081
+ }
1082
+ /**
1083
+ * The real census: an in-process inspector session, opened for the call and
1084
+ * closed after it, so no session is left listening between censuses.
1085
+ *
1086
+ * Every remote object it creates is released before the session closes; a
1087
+ * failure anywhere still disconnects. The session is in-process — nothing
1088
+ * listens on a port, nothing outside this process can reach it.
1089
+ */
1090
+ function createInspectorArrayBufferCensus() {
1091
+ return async () => {
1092
+ const session = new Session();
1093
+ session.connect();
1094
+ try {
1095
+ const prototypeObjectId = readObjectId(await session.post("Runtime.evaluate", { expression: "ArrayBuffer.prototype" }), ["result", "objectId"]);
1096
+ try {
1097
+ const objectId = readObjectId(await session.post("Runtime.queryObjects", { prototypeObjectId }), ["objects", "objectId"]);
1098
+ try {
1099
+ const evaluated = await session.post("Runtime.callFunctionOn", {
1100
+ objectId,
1101
+ functionDeclaration: HISTOGRAM_FN,
1102
+ returnByValue: true
1103
+ });
1104
+ const value = typeof evaluated === "object" && evaluated !== null ? Reflect.get(Reflect.get(evaluated, "result") ?? {}, "value") : void 0;
1105
+ if (!isArrayBufferCensusReport(value)) throw new Error("inspector returned a histogram of an unexpected shape");
1106
+ return value;
1107
+ } finally {
1108
+ await session.post("Runtime.releaseObject", { objectId });
1109
+ }
1110
+ } finally {
1111
+ await session.post("Runtime.releaseObject", { objectId: prototypeObjectId });
1112
+ }
1113
+ } finally {
1114
+ session.disconnect();
1115
+ }
1116
+ };
1117
+ }
1118
+ var MB$1 = 1048576;
1119
+ function mb(bytes) {
1120
+ return Math.round(bytes / MB$1);
1121
+ }
1122
+ function formatBucket(bucket) {
1123
+ return `<=${bucket.upToBytes >= MB$1 ? `${Math.round(bucket.upToBytes / MB$1)}MB` : bucket.upToBytes >= 1024 ? `${Math.round(bucket.upToBytes / 1024)}KB` : `${bucket.upToBytes}B`}:${bucket.count}x/${mb(bucket.bytes)}MB`;
1124
+ }
1125
+ function formatExact(size) {
1126
+ return `${size.byteLength}Bx${size.count}=${mb(size.bytes)}MB`;
1127
+ }
1128
+ /**
1129
+ * The census line. `live` is what survived the census's own GC; `raw` is the
1130
+ * reading that triggered it — when they disagree by a lot, the growth was dead
1131
+ * buffers awaiting a sweep, not a retention, and the line says which.
1132
+ */
1133
+ function formatArrayBufferCensus(label, report, context) {
1134
+ const live = mb(report.bytes);
1135
+ const verdict = live < context.rawMb * .5 ? "mostly DEAD buffers awaiting a sweep, not a retention" : "LIVE — something holds them";
1136
+ return `[mem] ${label} arrayBuffer census: live=${live}MB in ${report.count} ArrayBuffers (raw arrayBuffers=${context.rawMb}MB at the trigger → ${verdict}) top sizes: ${report.topSizes.map(formatExact).join(", ") || "(none)"}; buckets: ${report.buckets.map(formatBucket).join(", ") || "(none)"}; detached=${report.detached} took=${context.tookMs}ms — cost: ONE forced full GC (the same pause as a reclaim pass), walked in memory, nothing serialised, nothing written; trigger: ${context.reason}`;
1137
+ }
1138
+ //#endregion
606
1139
  //#region src/kernel/heap-watch.ts
607
1140
  /**
608
1141
  * heap-watch — hub-main's own memory, on the record, and returned to the OS.
@@ -1167,6 +1700,27 @@ function format(label, s, loop, budgetMb, suffix) {
1167
1700
  return `${line} loopP50=${loop.p50Ms}ms loopP99=${loop.p99Ms}ms loopMax=${loop.maxMs}ms${suffix}`;
1168
1701
  }
1169
1702
  /**
1703
+ * Every outbound queue the heartbeat can reach, as the gate wants them: the
1704
+ * UDS children (only those whose channel keeps a reading — an unmeasured
1705
+ * peer is not a zero) and the mesh writer sockets, each prefixed with its
1706
+ * plane.
1707
+ */
1708
+ function outboundQueueReadings(uds, mesh) {
1709
+ const readings = [];
1710
+ for (const peer of uds?.peers ?? []) {
1711
+ if (peer.queuedBytes === null) continue;
1712
+ readings.push({
1713
+ peerId: `uds:${peer.peerId}`,
1714
+ bytes: peer.queuedBytes
1715
+ });
1716
+ }
1717
+ for (const node of mesh ?? []) readings.push({
1718
+ peerId: `mesh:${node.nodeId}`,
1719
+ bytes: node.queuedBytes
1720
+ });
1721
+ return readings;
1722
+ }
1723
+ /**
1170
1724
  * Start the heartbeat. Returns a stop function.
1171
1725
  *
1172
1726
  * `unref`ed so it can never hold the process open, and every read is wrapped:
@@ -1280,7 +1834,7 @@ function startHeapWatch(label = HUB_MAIN_HEAP_WATCH_LABEL, sink = consoleSink, i
1280
1834
  const due = at - lastLoggedAt >= intervalMs;
1281
1835
  if (mode === "escalated" || due) {
1282
1836
  lastLoggedAt = at;
1283
- const line = format(label, sample, loopDelay?.read(), budgetMb, `${formatHeapSpaces(readSpaces())}${formatEventPlane(eventPlane?.read())}` + formatSocketPlane(socketPlane?.read()));
1837
+ const line = format(label, sample, loopDelay?.read(), budgetMb, `${formatHeapSpaces(readSpaces())}${formatEventPlane(eventPlane?.read())}` + formatSocketPlane(socketPlane?.read()) + formatMeshQueue(meshQueue?.()));
1284
1838
  if (sample.nearLimit) sink.warn(`${line} — APPROACHING HEAP LIMIT`);
1285
1839
  else if (mode === "escalated") sink.warn(`${line} — heap elevated, sampling every ${probeIntervalMs}ms`);
1286
1840
  else sink.info(line);
@@ -1288,8 +1842,44 @@ function startHeapWatch(label = HUB_MAIN_HEAP_WATCH_LABEL, sink = consoleSink, i
1288
1842
  if (previous === "escalated" && mode === "steady") sink.info(`[mem] ${label} heap back to routine — logging every ${intervalMs}ms`);
1289
1843
  checkRssBudget(sample, at);
1290
1844
  maybeReclaim(sample);
1845
+ judgeArrayBuffers(sample, at);
1846
+ judgeOutboundQueues(at);
1291
1847
  } catch {}
1292
1848
  };
1849
+ const meshQueue = probes?.meshQueue;
1850
+ const queueReader = probes?.socketPlane;
1851
+ const queueGate = queueReader === void 0 && meshQueue === void 0 ? void 0 : createOutboundQueueGate(probes?.outboundQueue);
1852
+ const judgeOutboundQueues = (at) => {
1853
+ if (queueGate === void 0) return;
1854
+ const readings = outboundQueueReadings(queueReader?.(), meshQueue?.());
1855
+ for (const verdict of queueGate.observe(readings, at)) sink.warn(formatOutboundQueueVerdict(label, verdict));
1856
+ };
1857
+ const censusProbe = probes?.arrayBufferCensus;
1858
+ const censusGate = censusProbe === void 0 ? void 0 : createArrayBufferCensusGate(censusProbe);
1859
+ let censusInFlight = false;
1860
+ const judgeArrayBuffers = (sample, at) => {
1861
+ if (censusProbe === void 0 || censusGate === void 0) return;
1862
+ const verdict = censusGate.observe(sample.arrayBuffersMb, at);
1863
+ if (verdict.kind === "released") {
1864
+ sink.warn(`[mem] ${label} arrayBuffers RELEASED ${verdict.fromMb}MB→${verdict.toMb}MB between two probes ${probeIntervalMs}ms apart — no owner logged this release; whatever held them let go in one instant (a socket that died, a queue that drained, a map that was cleared). Correlate with every line of this process in that window.`);
1865
+ return;
1866
+ }
1867
+ if (verdict.kind !== "take" || censusInFlight) return;
1868
+ censusInFlight = true;
1869
+ const startedAt = now();
1870
+ const rawMb = sample.arrayBuffersMb;
1871
+ censusProbe.census().then((report) => {
1872
+ sink.warn(formatArrayBufferCensus(label, report, {
1873
+ rawMb,
1874
+ tookMs: now() - startedAt,
1875
+ reason: verdict.reason
1876
+ }));
1877
+ }).catch((error) => {
1878
+ sink.warn(`[mem] ${label} arrayBuffer census FAILED after ${now() - startedAt}ms (trigger: ${verdict.reason}) — ${error instanceof Error ? error.message : String(error)}`);
1879
+ }).finally(() => {
1880
+ censusInFlight = false;
1881
+ });
1882
+ };
1293
1883
  const timer = setInterval(tick, probeIntervalMs);
1294
1884
  timer.unref?.();
1295
1885
  tick();
@@ -5060,6 +5650,74 @@ function encodeFrame(value) {
5060
5650
  Buffer.from(body.buffer, body.byteOffset, body.byteLength).copy(out, 4);
5061
5651
  return out;
5062
5652
  }
5653
+ /** msgpack `fixstr` of {@link PAYLOAD_KEY}: `0xa0 | length`, then the bytes. */
5654
+ var PAYLOAD_KEY_BYTES = Buffer.from([162, ...Buffer.from("pl")]);
5655
+ /** msgpack `bin 32` marker: one byte, then a 4-byte big-endian length. */
5656
+ var BIN32_MARKER = 198;
5657
+ /** msgpack `fixmap` range: `0x80 | count`, count in `0..15`. */
5658
+ var FIXMAP_BASE = 128;
5659
+ var FIXMAP_MAX_COUNT = 15;
5660
+ /**
5661
+ * Encode the HEAD of a frame that carries an opaque payload: the length
5662
+ * prefix, the msgpack envelope with one extra key ({@link PAYLOAD_KEY}), and
5663
+ * the `bin 32` header for the payload — everything but the payload bytes
5664
+ * themselves, which the caller writes straight after it.
5665
+ *
5666
+ * ## Why the payload is the frame's TAIL and not a field
5667
+ *
5668
+ * hub-main routes every sibling↔sibling call. Until this existed it decoded
5669
+ * the whole request (args included), built a new message, and re-encoded it
5670
+ * — walking a 13.5 KB PCM chunk or a 2 000-row settings answer twice on the
5671
+ * one thread that serves every tRPC request, and allocating an `Encoder`
5672
+ * buffer that doubles from 2 KiB up to the body's size on every write
5673
+ * (D441: ~27 % of hub-main's busy time in msgpack decode, ~6 % in encode,
5674
+ * and the churn that fed a 29.5 % GC share).
5675
+ *
5676
+ * With the payload appended as the LAST key of the map, the receiver's
5677
+ * ordinary msgpack decode yields it as a zero-copy VIEW of the frame
5678
+ * (`@msgpack/msgpack` decodes `bin` as `subarray`), the router reads only the
5679
+ * envelope, and forwarding is: encode a ~60-byte head, write it, write the
5680
+ * view. The wire stays plain msgpack — a peer that never heard of payloads
5681
+ * decodes `{ …frame, pl: <bytes> }` and can be told, at register, not to be
5682
+ * sent one.
5683
+ *
5684
+ * ## The one hand-written msgpack in this repository
5685
+ *
5686
+ * `@msgpack/msgpack` has no "encode a map, then append a key" API, so the
5687
+ * envelope is encoded normally and its `fixmap` count byte is bumped by one
5688
+ * before the key and the `bin 32` header are appended. That patch is only
5689
+ * valid for a `fixmap` (≤ 15 keys); a `Frame` has at most four, and anything
5690
+ * else is refused rather than silently corrupted.
5691
+ */
5692
+ function encodeFrameHead(frame, payload) {
5693
+ const envelope = encode(frame, ENCODE_OPTIONS);
5694
+ const first = envelope[0];
5695
+ if (envelope.byteLength === 0 || first === void 0 || first < FIXMAP_BASE || first > 142) throw new Error(`encodeFrameHead: envelope must be a msgpack fixmap with at most ${FIXMAP_MAX_COUNT - 1} keys (got leading byte 0x${(first ?? 0).toString(16)})`);
5696
+ const bodyLength = envelope.byteLength + PAYLOAD_KEY_BYTES.byteLength + 5 + payload.byteLength;
5697
+ const head = Buffer.allocUnsafe(HEADER_BYTES + envelope.byteLength + PAYLOAD_KEY_BYTES.byteLength + 5);
5698
+ head.writeUInt32BE(bodyLength, 0);
5699
+ Buffer.from(envelope.buffer, envelope.byteOffset, envelope.byteLength).copy(head, HEADER_BYTES);
5700
+ head[HEADER_BYTES] = first + 1;
5701
+ let at = HEADER_BYTES + envelope.byteLength;
5702
+ PAYLOAD_KEY_BYTES.copy(head, at);
5703
+ at += PAYLOAD_KEY_BYTES.byteLength;
5704
+ head[at] = BIN32_MARKER;
5705
+ head.writeUInt32BE(payload.byteLength, at + 1);
5706
+ return head;
5707
+ }
5708
+ /**
5709
+ * Serialise a value into the bytes a payload carries. The SAME options as the
5710
+ * envelope codec, so `undefined` properties are omitted rather than turned
5711
+ * into `null` — a `z.void()` cap output must read back as `undefined` on the
5712
+ * far side exactly as it did when it travelled inline.
5713
+ */
5714
+ function packPayload(value) {
5715
+ return encode(value, ENCODE_OPTIONS);
5716
+ }
5717
+ /** Inverse of {@link packPayload}. A `bin` inside decodes as a view of `bytes`. */
5718
+ function unpackPayload(bytes) {
5719
+ return decode(bytes);
5720
+ }
5063
5721
  /** Bytes in the big-endian length prefix that opens every frame. */
5064
5722
  var HEADER_BYTES = 4;
5065
5723
  /**
@@ -5218,6 +5876,25 @@ function localEndpointPath(nodeId) {
5218
5876
  if (process.platform === "win32") return `\\\\.\\pipe\\camstack-${safe}`;
5219
5877
  return join(tmpdir(), `camstack-${safe}.sock`);
5220
5878
  }
5879
+ //#endregion
5880
+ //#region src/kernel/transport/local-transport.ts
5881
+ /**
5882
+ * An answer that carries an opaque payload beside its body.
5883
+ *
5884
+ * A CLASS rather than a shape so the channel can tell it from a provider's
5885
+ * ordinary `{ body, payload }` return value by identity, never by duck-typing:
5886
+ * a cap method is free to return an object with those two keys and it must
5887
+ * travel inline like any other. `request()` resolves with one of these only
5888
+ * when the peer's `res` frame carried a payload.
5889
+ */
5890
+ var PayloadReply = class {
5891
+ body;
5892
+ payload;
5893
+ constructor(body, payload) {
5894
+ this.body = body;
5895
+ this.payload = payload;
5896
+ }
5897
+ };
5221
5898
  var SocketChannel = class {
5222
5899
  socket;
5223
5900
  nextId = 1;
@@ -5274,7 +5951,7 @@ var SocketChannel = class {
5274
5951
  k: "req",
5275
5952
  id,
5276
5953
  body
5277
- });
5954
+ }, opts?.payload);
5278
5955
  });
5279
5956
  }
5280
5957
  emit(body) {
@@ -5314,11 +5991,38 @@ var SocketChannel = class {
5314
5991
  rx: sampleSocketDirection(this.rxCounters)
5315
5992
  };
5316
5993
  }
5317
- send(frame) {
5994
+ /**
5995
+ * Bytes handed to the socket that the kernel has not taken yet — the
5996
+ * `Writable` buffer plus the write in flight. Read LIVE, never cached: this
5997
+ * is the number that says whether the peer is draining what we write, and
5998
+ * it is read on every heartbeat probe (D446). An unref'd socket keeps it
5999
+ * exactly like a ref'd one, which is why it is read here and not summed
6000
+ * over `_getActiveHandles()`.
6001
+ */
6002
+ queuedBytes() {
6003
+ return this.socket.writableLength;
6004
+ }
6005
+ /**
6006
+ * Write one frame. With a payload the head and the tail are two `write`
6007
+ * calls under `cork()` — the kernel sees one `writev`, and the payload bytes
6008
+ * are never copied into the head. A forwarded payload is a view into the
6009
+ * frame it arrived on, and that frame's buffer is never mutated, so handing
6010
+ * the view to the socket is safe.
6011
+ */
6012
+ send(frame, payload) {
5318
6013
  if (this.closed) return;
5319
- const encoded = encodeFrame(frame);
5320
- recordSocketFrame(this.txCounters, frame.k, encoded.byteLength);
5321
- this.socket.write(encoded);
6014
+ if (payload === void 0) {
6015
+ const encoded = encodeFrame(frame);
6016
+ recordSocketFrame(this.txCounters, frame.k, encoded.byteLength);
6017
+ this.socket.write(encoded);
6018
+ return;
6019
+ }
6020
+ const head = encodeFrameHead(frame, payload);
6021
+ recordSocketFrame(this.txCounters, frame.k, head.byteLength + payload.byteLength);
6022
+ this.socket.cork();
6023
+ this.socket.write(head);
6024
+ this.socket.write(payload);
6025
+ this.socket.uncork();
5322
6026
  }
5323
6027
  onData(chunk) {
5324
6028
  const frames = this.decoder.push(chunk);
@@ -5338,8 +6042,14 @@ var SocketChannel = class {
5338
6042
  recordSocketFrame(this.rxCounters, frame.k, wireBytes);
5339
6043
  if (frame.k === "req") {
5340
6044
  try {
5341
- const result = await this.requestHandler(frame.body);
5342
- this.send({
6045
+ const result = await this.requestHandler(frame.body, frame.pl);
6046
+ if (result instanceof PayloadReply) this.send({
6047
+ k: "res",
6048
+ id: frame.id,
6049
+ ok: true,
6050
+ body: result.body
6051
+ }, result.payload);
6052
+ else this.send({
5343
6053
  k: "res",
5344
6054
  id: frame.id,
5345
6055
  ok: true,
@@ -5360,8 +6070,9 @@ var SocketChannel = class {
5360
6070
  if (!p) return;
5361
6071
  this.pending.delete(frame.id);
5362
6072
  if (p.timer !== null) clearTimeout(p.timer);
5363
- if (frame.ok) p.resolve(frame.body);
5364
- else p.reject(new Error(frame.err));
6073
+ if (!frame.ok) p.reject(new Error(frame.err));
6074
+ else if (frame.pl !== void 0) p.resolve(new PayloadReply(frame.body, frame.pl));
6075
+ else p.resolve(frame.body);
5365
6076
  return;
5366
6077
  }
5367
6078
  this.eventHandler(frame.body);
@@ -5912,688 +6623,278 @@ function deliverShared(state, event) {
5912
6623
  }
5913
6624
  }
5914
6625
  //#endregion
5915
- //#region src/kernel/moleculer/resilient-cap-call.ts
5916
- /** Moleculer error `type` values meaning "the service is not (yet) routable". */
5917
- var DISCOVERY_ERROR_TYPES = new Set(["SERVICE_NOT_FOUND", "SERVICE_NOT_AVAILABLE"]);
5918
- /** Default ceiling for the discovery wait — fail-fast past this. */
5919
- var DEFAULT_DISCOVERY_TIMEOUT_MS$1 = 3e4;
5920
- function isDiscoveryError(err) {
5921
- if (typeof err !== "object" || err === null) return false;
5922
- const type = err.type;
5923
- return typeof type === "string" && DISCOVERY_ERROR_TYPES.has(type);
5924
- }
5925
- /**
5926
- * Call a Moleculer action; on a service-discovery error, wait for the
5927
- * named service to be discovered and retry the call exactly once.
5928
- */
5929
- async function callWithServiceDiscovery(broker, serviceName, action, params, opts, discoveryTimeoutMs = DEFAULT_DISCOVERY_TIMEOUT_MS$1) {
5930
- try {
5931
- return await broker.call(action, params, opts);
5932
- } catch (err) {
5933
- if (!isDiscoveryError(err)) throw err;
5934
- await broker.waitForServices([serviceName], discoveryTimeoutMs);
5935
- return await broker.call(action, params, opts);
5936
- }
6626
+ //#region src/kernel/transport/cap-routing-hints.ts
6627
+ /** Lift {@link CapRoutingHints} from a call's args. Never throws. */
6628
+ function liftRoutingHints(args) {
6629
+ if (args === null || typeof args !== "object" || Array.isArray(args)) return {};
6630
+ const nodeId = Reflect.get(args, "nodeId");
6631
+ const addonId = Reflect.get(args, "addonId");
6632
+ return {
6633
+ ...typeof nodeId === "string" && nodeId.length > 0 ? { nodeId } : {},
6634
+ ...typeof addonId === "string" && addonId.length > 0 ? { addonId } : {}
6635
+ };
5937
6636
  }
5938
6637
  //#endregion
5939
- //#region src/kernel/transport/cap-route.ts
5940
- function buildMessage(capName, method, detail) {
5941
- const call = method !== void 0 ? `${capName}.${method}` : capName;
5942
- const target = detail.nodeId !== void 0 ? ` to node ${detail.nodeId}` : "";
5943
- const rejectedStr = detail.rejected.map((r) => `${r.kind}=${r.why}`).join("; ");
5944
- const rejectedClause = rejectedStr.length > 0 ? ` (rejected: ${rejectedStr})` : "";
5945
- return `${call} not routable${target}: ${detail.reason}${rejectedClause}`;
6638
+ //#region src/kernel/transport/local-child-registry.ts
6639
+ function readFanoutMode() {
6640
+ const raw = process.env.CAMSTACK_UDS_EVENT_FANOUT;
6641
+ if (raw === "shadow" || raw === "broadcast") return raw;
6642
+ return "filter";
5946
6643
  }
5947
- var CapRouteError = class extends Error {
5948
- reason;
5949
- nodeId;
5950
- rejected;
6644
+ /**
6645
+ * Sentinel prefix used in the no-route error thrown when `cap-call-out` has no
6646
+ * local sibling and no `onUnownedCall` fallback. `ipcParentLink` detects this
6647
+ * prefix to distinguish routing failures (safe to retry via broker) from real
6648
+ * provider errors (must NOT retry to avoid double-executing side effects).
6649
+ */
6650
+ var UDS_NO_ROUTE_PREFIX = "UDS_NO_ROUTE";
6651
+ /**
6652
+ * Parent-side authority for local addon-runners reachable over a
6653
+ * `LocalTransportServer` (UDS). Children register their cap manifest on
6654
+ * connect; the registry routes `(capName, deviceId?)` cap calls to the
6655
+ * owning child over the channel and drops a child's caps on disconnect.
6656
+ */
6657
+ var LocalChildRegistry = class {
6658
+ children = /* @__PURE__ */ new Map();
6659
+ registeredHandler = () => {};
6660
+ goneHandler = () => {};
6661
+ /** Source of {@link RegisteredChild.incarnation}; one bump per accepted connection. */
6662
+ nextIncarnation = 1;
6663
+ eventHandler = null;
6664
+ logHandler = null;
6665
+ readinessHandler = null;
6666
+ server;
6667
+ onUnownedCall;
6668
+ logger;
6669
+ getActiveSingletonAddonId;
6670
+ resolveChildIdForAddon;
6671
+ /** This node's own Moleculer nodeId (see {@link LocalChildRegistryOptions.ownNodeId}). */
6672
+ ownNodeId;
6673
+ /** See {@link LocalChildRegistryOptions.isAggregatedCollectionMethod}. */
6674
+ isAggregatedCollectionMethod;
6675
+ isAddonPinnedCall;
6676
+ /** See {@link ForwardCensus}. Mutable counters on the hot path, by design. */
6677
+ forwardedRaw = 0;
6678
+ forwardedDecoded = 0;
6679
+ /** See {@link LocalChildRegistryOptions.capTimeoutMs}. */
6680
+ capTimeoutMs;
6681
+ /** See {@link LocalChildRegistryOptions.capUsageObserver}. */
6682
+ capUsageObserver;
6683
+ /** Tracks capNames already logged as UDS-routed; one INFO line per capName per process. */
6684
+ egressRoutedCaps = /* @__PURE__ */ new Set();
6685
+ /** Active event fan-out mode, read once from `CAMSTACK_UDS_EVENT_FANOUT`. */
6686
+ fanoutMode = readFanoutMode();
6687
+ /** Per-child event fan-out counters (sent / suppressed). */
6688
+ childEventStats = /* @__PURE__ */ new Map();
6689
+ /** Last pattern-set string logged per child, to dedup the INFO line. */
6690
+ loggedPatternSet = /* @__PURE__ */ new Map();
5951
6691
  /**
5952
- * @param cause Optional original error that triggered this routing failure.
5953
- * Stored as `Error.cause` (TC39 standard option, Node 16.9+).
5954
- * Dispatchers wrapping transport errors MUST pass the original.
6692
+ * Accepts either a plain positional `server` argument (backward-compatible)
6693
+ * or a full `LocalChildRegistryOptions` object.
6694
+ *
6695
+ * Positional overloads (existing call sites are unchanged):
6696
+ * new LocalChildRegistry(server)
6697
+ * new LocalChildRegistry(server, onUnownedCall)
6698
+ *
6699
+ * Options object (new call sites that pass a logger):
6700
+ * new LocalChildRegistry({ server, onUnownedCall, logger })
5955
6701
  */
5956
- constructor(capName, method, detail, cause) {
5957
- super(buildMessage(capName, method, detail), cause !== void 0 ? { cause } : void 0);
5958
- this.name = "CapRouteError";
5959
- this.reason = detail.reason;
5960
- this.nodeId = detail.nodeId;
5961
- this.rejected = detail.rejected;
6702
+ constructor(serverOrOptions, onUnownedCallArg) {
6703
+ if (serverOrOptions && !("listen" in serverOrOptions)) {
6704
+ const opts = serverOrOptions;
6705
+ this.server = opts.server;
6706
+ this.onUnownedCall = opts.onUnownedCall;
6707
+ this.logger = opts.logger;
6708
+ this.getActiveSingletonAddonId = opts.getActiveSingletonAddonId;
6709
+ this.resolveChildIdForAddon = opts.resolveChildIdForAddon;
6710
+ this.ownNodeId = opts.ownNodeId;
6711
+ this.isAggregatedCollectionMethod = opts.isAggregatedCollectionMethod;
6712
+ this.isAddonPinnedCall = opts.isAddonPinnedCall;
6713
+ this.capTimeoutMs = opts.capTimeoutMs;
6714
+ this.capUsageObserver = opts.capUsageObserver;
6715
+ } else {
6716
+ this.server = serverOrOptions;
6717
+ this.onUnownedCall = onUnownedCallArg;
6718
+ }
5962
6719
  }
5963
- };
5964
- /**
5965
- * Classifies a (capName, opts) pair into a typed CapRoute dispatch descriptor.
5966
- *
5967
- * Precedence (explicit nodeId path):
5968
- * 1. hub-in-process — hub node + hubInProcessProvides
5969
- * 2. hub-local-uds — hub node + hubLocalChildProvides
5970
- * 3. node-offline → CapRouteError{reason:'node-offline'}
5971
- * 4. agent-child-forward — node is an agent AND nodeKnowsCap
5972
- * 5. remote-moleculer — any other online non-agent node
5973
- *
5974
- * Singleton path (no nodeId):
5975
- * 1. hub-in-process (hub provides in-process)
5976
- * 2. hub-local-uds (a hub-local UDS child provides it)
5977
- * 3. remote-moleculer (any online, non-hub node that knows it)
5978
- * 4. → CapRouteError{reason:'no-provider', rejected: all considered routes}
5979
- *
5980
- * PURE: no side effects, no async, no broker/registry imports.
5981
- */
5982
- function classifyCapRoute(capName, opts, snapshot) {
5983
- const rejected = [];
5984
- if (opts.nodeId !== void 0) return classifyExplicitNode(capName, opts.nodeId, opts.deviceId, snapshot, rejected);
5985
- return classifySingleton(capName, opts.deviceId, snapshot, rejected);
5986
- }
5987
- function classifyExplicitNode(capName, nodeId, deviceId, snap, rejected) {
5988
- if (nodeId === snap.hubNodeId) {
5989
- const residentProvides = snap.hubResidentProvides;
5990
- const providesOnHub = residentProvides ?? snap.hubInProcessProvides;
5991
- const getRef = residentProvides !== void 0 ? snap.getHubResidentProviderRef : snap.getInProcessProviderRef;
5992
- if (providesOnHub(capName)) {
5993
- const ref = getRef?.(capName) ?? null;
5994
- if (ref !== null) return {
5995
- kind: "hub-in-process",
5996
- capName,
5997
- ref
5998
- };
5999
- rejected.push({
6000
- kind: "hub-in-process",
6001
- why: "provider ref not available in snapshot"
6002
- });
6003
- throw new CapRouteError(capName, void 0, {
6004
- reason: "no-provider",
6005
- nodeId,
6006
- rejected
6007
- });
6008
- }
6009
- if (snap.hubLocalChildProvides(capName, deviceId)) {
6010
- const childId = snap.getHubLocalChildId?.(capName, deviceId) ?? null;
6011
- if (childId !== null) return {
6012
- kind: "hub-local-uds",
6013
- capName,
6014
- childId
6015
- };
6016
- rejected.push({
6017
- kind: "hub-local-uds",
6018
- why: "child id not resolvable from snapshot"
6019
- });
6020
- throw new CapRouteError(capName, void 0, {
6021
- reason: "no-provider",
6022
- nodeId,
6023
- rejected
6024
- });
6025
- }
6026
- rejected.push({
6027
- kind: "hub-in-process",
6028
- why: "hub does not provide this cap in-process"
6029
- });
6030
- rejected.push({
6031
- kind: "hub-local-uds",
6032
- why: "no hub-local child provides this cap"
6033
- });
6034
- throw new CapRouteError(capName, void 0, {
6035
- reason: "no-provider",
6036
- nodeId,
6037
- rejected
6038
- });
6039
- }
6040
- if (!snap.nodeOnline(nodeId)) {
6041
- rejected.push({
6042
- kind: "remote-moleculer",
6043
- why: `node ${nodeId} is offline`
6044
- });
6045
- throw new CapRouteError(capName, void 0, {
6046
- reason: "node-offline",
6047
- nodeId,
6048
- rejected
6049
- });
6720
+ async start() {
6721
+ this.logger?.info("UDS event fan-out mode", { mode: this.fanoutMode });
6722
+ this.server.onConnection((channel) => this.onConnection(channel));
6723
+ await this.server.listen();
6050
6724
  }
6051
- if (snap.nodeIsAgent(nodeId)) {
6052
- if (snap.nodeKnowsCap(nodeId, capName)) return {
6053
- kind: "agent-child-forward",
6054
- capName,
6055
- agentNodeId: nodeId,
6056
- childId: snap.getAgentChildId?.(nodeId, capName) ?? void 0
6725
+ /**
6726
+ * Per-child event fan-out counters (`{ sent, suppressed }`) or `null` if the
6727
+ * child has no recorded events yet. Exposed for the starvation-verification
6728
+ * surface (shadow burn-in + operator debug).
6729
+ */
6730
+ getChildEventStats(childId) {
6731
+ const s = this.childEventStats.get(childId);
6732
+ return s === void 0 ? null : {
6733
+ sent: s.sent,
6734
+ suppressed: s.suppressed
6057
6735
  };
6058
- rejected.push({
6059
- kind: "agent-child-forward",
6060
- why: `agent ${nodeId} does not know cap ${capName}`
6061
- });
6062
- throw new CapRouteError(capName, void 0, {
6063
- reason: "no-provider",
6064
- nodeId,
6065
- rejected
6066
- });
6067
6736
  }
6068
- return {
6069
- kind: "remote-moleculer",
6070
- capName,
6071
- nodeId
6072
- };
6073
- }
6074
- function classifySingleton(capName, deviceId, snap, rejected) {
6075
- if (snap.hubInProcessProvides(capName)) {
6076
- const ref = snap.getInProcessProviderRef?.(capName) ?? null;
6077
- if (ref !== null) return {
6078
- kind: "hub-in-process",
6079
- capName,
6080
- ref
6081
- };
6082
- rejected.push({
6083
- kind: "hub-in-process",
6084
- why: "provider ref not available in snapshot"
6085
- });
6086
- } else rejected.push({
6087
- kind: "hub-in-process",
6088
- why: "hub does not provide this cap in-process"
6089
- });
6090
- if (snap.hubLocalChildProvides(capName, deviceId)) {
6091
- const childId = snap.getHubLocalChildId?.(capName, deviceId) ?? null;
6092
- if (childId !== null) return {
6093
- kind: "hub-local-uds",
6094
- capName,
6095
- childId
6096
- };
6097
- rejected.push({
6098
- kind: "hub-local-uds",
6099
- why: "child id not resolvable from snapshot"
6100
- });
6101
- } else rejected.push({
6102
- kind: "hub-local-uds",
6103
- why: "no hub-local child provides this cap"
6104
- });
6105
- const knownNodes = snap.listKnownNodeIds?.() ?? [];
6106
- for (const nodeId of knownNodes) if (nodeId !== snap.hubNodeId && snap.nodeOnline(nodeId) && snap.nodeKnowsCap(nodeId, capName)) return {
6107
- kind: "remote-moleculer",
6108
- capName,
6109
- nodeId
6110
- };
6111
- rejected.push({
6112
- kind: "remote-moleculer",
6113
- why: "no online remote node knows this cap"
6114
- });
6115
- throw new CapRouteError(capName, void 0, {
6116
- reason: "no-provider",
6117
- rejected
6118
- });
6119
- }
6120
- //#endregion
6121
- //#region src/kernel/transport/cap-route-resolver.ts
6122
- /** The Moleculer service name that Task 6's agent registers. */
6123
- var AGENT_CAP_FWD_SERVICE = "$agent-cap-fwd";
6124
- /** The Moleculer action (service.action) for agent cap forwarding. */
6125
- var AGENT_CAP_FWD_ACTION = `${AGENT_CAP_FWD_SERVICE}.forward`;
6126
- /** Default timeout for remote Moleculer cap calls (ms). */
6127
- var REMOTE_CALL_TIMEOUT_MS = 6e4;
6128
- function extractDeviceId(args) {
6129
- if (args === null || typeof args !== "object") return void 0;
6130
- const raw = Reflect.get(args, "deviceId");
6131
- return typeof raw === "number" ? raw : void 0;
6132
- }
6133
- /**
6134
- * Extract an inline `nodeId` string from a cap call's args — the per-call node
6135
- * pin a forked addon expresses by carrying `nodeId` in the input (the SAME way
6136
- * device-scoped caps carry `deviceId`, and the way the generated cap-router on
6137
- * the hub already honours an inline pin). Mirrors {@link extractDeviceId}.
6138
- *
6139
- * This is the routing source for hub→remote-node EXECUTION pinning (e.g. the
6140
- * benchmark addon running a synthetic/decoder workload ON a chosen agent). The
6141
- * out-of-band `nodePin(op.context)` path does NOT survive the forked addon →
6142
- * hub UDS link chain, so `onUnownedCall` reads the inline pin instead. Provider
6143
- * methods that don't declare `nodeId` simply ignore the extra field (they
6144
- * destructure the fields they need); methods that DO declare it (e.g.
6145
- * `getEngineProvisioning({nodeId})`) still receive it unchanged.
6146
- */
6147
- function extractNodeId(args) {
6148
- if (args === null || typeof args !== "object") return void 0;
6149
- const raw = Reflect.get(args, "nodeId");
6150
- return typeof raw === "string" && raw.length > 0 ? raw : void 0;
6151
- }
6152
- /**
6153
- * Merge `callerAddonId` into `args` as an extra field — the SAME "provider
6154
- * destructures what it needs and ignores the rest" convention
6155
- * {@link extractNodeId} already relies on for the inline `nodeId` pin.
6156
- * `undefined` (the overwhelmingly common case: no caller hint, or the census
6157
- * consuming it is disarmed) returns `args` unchanged — no allocation. A
6158
- * non-object `args` (or `null`/array) is returned unchanged too: there is
6159
- * nowhere to attach an extra key without changing the payload's shape, and a
6160
- * provider taking a bare value never reads `callerAddonId` off it anyway.
6161
- */
6162
- function withCallerAddonId(args, callerAddonId) {
6163
- if (callerAddonId === void 0) return args;
6164
- if (args === null || typeof args !== "object" || Array.isArray(args)) return args;
6165
- return {
6166
- ...args,
6167
- callerAddonId
6168
- };
6169
- }
6170
- var CapRouteResolver = class {
6171
- hubNodeId;
6172
- broker;
6173
- hubLocalRegistry;
6174
- nodeAuthority;
6175
- inProcessProviders;
6176
- hubResidentProviders;
6177
- capTimeoutMs;
6178
- snapshot;
6179
- constructor(deps) {
6180
- this.hubNodeId = deps.hubNodeId;
6181
- this.broker = deps.broker;
6182
- this.hubLocalRegistry = deps.hubLocalRegistry;
6183
- this.nodeAuthority = deps.nodeAuthority;
6184
- this.inProcessProviders = deps.inProcessProviders;
6185
- this.hubResidentProviders = deps.hubResidentProviders;
6186
- this.capTimeoutMs = deps.capTimeoutMs;
6187
- this.snapshot = this.buildSnapshot();
6737
+ /**
6738
+ * This child's socket traffic — bytes and messages, both directions, split by
6739
+ * frame kind — or `null` when the child is gone or its channel keeps no
6740
+ * counters (an in-process or test transport).
6741
+ *
6742
+ * The event counters above cover ONE kind of frame and, once surfaced on
6743
+ * 2026-08-29, accounted for 4% of hub-main's ~44 000 socket syscalls/s. This
6744
+ * is the same question asked of every frame the channel moves, and it is the
6745
+ * only per-peer attribution that exists: `/proc` reports one `rchar`/`wchar`
6746
+ * pair for the whole process and has no per-socket breakdown.
6747
+ */
6748
+ getChildSocketTraffic(childId) {
6749
+ const entry = this.children.get(childId);
6750
+ if (entry === void 0) return null;
6751
+ return entry.channel.readTraffic?.() ?? null;
6188
6752
  }
6189
- /** Per-call RPC timeout: the cap method's declared override, else the default. */
6190
- callTimeout(capName, method) {
6191
- return this.capTimeoutMs?.(capName, method) ?? REMOTE_CALL_TIMEOUT_MS;
6753
+ /**
6754
+ * Bytes this process has written to a child that the child has not yet
6755
+ * taken, live, or `null` when the channel keeps no such reading. Read on
6756
+ * every heartbeat probe (D446) — a child that stops draining is the shape
6757
+ * of an outbound-queue episode, and the number lives on the channel.
6758
+ */
6759
+ getChildQueuedBytes(childId) {
6760
+ const entry = this.children.get(childId);
6761
+ if (entry === void 0) return null;
6762
+ return entry.channel.queuedBytes?.() ?? null;
6192
6763
  }
6193
- resolveCapRoute(capName, opts) {
6194
- return classifyCapRoute(capName, opts, this.snapshot);
6764
+ /** Cumulative {@link ForwardCensus} of this parent's `cap-call-out` routing. */
6765
+ readForwardCensus() {
6766
+ return {
6767
+ raw: this.forwardedRaw,
6768
+ decoded: this.forwardedDecoded
6769
+ };
6195
6770
  }
6196
6771
  /**
6197
- * Resolve the hub-local-uds route for a cap owned by a forked hub-local
6198
- * child, IGNORING any in-hub provider registered for the same cap name.
6772
+ * The regime the counters above were produced under, read once from
6773
+ * `CAMSTACK_UDS_EVENT_FANOUT` at construction.
6199
6774
  *
6200
- * `resolveCapRoute` gives Priority 1 to `hub-in-process`: when an in-hub
6201
- * provider (e.g. a wrapper) is registered for the cap, the route always
6202
- * classifies as `hub-in-process` and the hub-local-uds NATIVE child is never
6203
- * reached. That is correct for the generic dispatch path (the wrapper is the
6204
- * active provider), but WRONG for the native-cap fallback
6205
- * (`setNativeFallback`), whose contract is to reach the NATIVE provider in
6206
- * the forked vendor child so a wrapper can delegate to it. A wrapper cap
6207
- * with a forked native (today: `snapshot`) otherwise resolves to the wrapper
6208
- * itself, the native is never invoked, and the wrapper silently falls
6209
- * through to its secondary strategy.
6775
+ * A suppressed count means "nothing WAS dropped" under `filter` and "nothing
6776
+ * WOULD have been dropped" under `shadow`. Those are different claims, and
6777
+ * until this getter existed the mode was invisible at runtime — the counters
6778
+ * were readable and the regime that produced them was not.
6779
+ */
6780
+ get eventFanoutMode() {
6781
+ return this.fanoutMode;
6782
+ }
6783
+ /**
6784
+ * Connected children that never declared a subscription pattern set.
6210
6785
  *
6211
- * This method consults ONLY the hub-local-child authority
6212
- * (`hubLocalChildProvides` + `getHubLocalChildId`, both deviceId-aware), so
6213
- * it returns the native child route regardless of any in-process shadow.
6214
- * Returns null when no hub-local child owns `(capName, deviceId)` — the
6215
- * caller then falls through to its remote-resolution branch.
6786
+ * Each one is a fail-open on BOTH planes: `childWantsEvent` returns true for
6787
+ * an undeclared child ({@link ChildEntry.eventPatterns} `=== null`), and
6788
+ * `aggregateEventInterest` returns `null` — accept everything — if ANY
6789
+ * connected child is undeclared, which disables the node's inbound cross-node
6790
+ * gate as well. **One legacy runner silently turns off the filtering for the
6791
+ * whole node**, and nothing reported it, so a zero here is as load-bearing as
6792
+ * a non-zero one.
6216
6793
  */
6217
- resolveHubLocalUdsRoute(capName, deviceId) {
6218
- if (!this.snapshot.hubLocalChildProvides(capName, deviceId)) return null;
6219
- const childId = this.snapshot.getHubLocalChildId?.(capName, deviceId) ?? null;
6220
- if (childId === null) return null;
6221
- return {
6222
- kind: "hub-local-uds",
6223
- capName,
6224
- childId
6225
- };
6794
+ undeclaredChildCount() {
6795
+ let count = 0;
6796
+ for (const entry of this.children.values()) if (entry.eventPatterns === null) count += 1;
6797
+ return count;
6226
6798
  }
6227
6799
  /**
6228
- * @param addonId Optional out-of-band PROVIDER-selection hint (Task 7.5a):
6229
- * the addonId of the provider the CALLER already resolved (e.g.
6230
- * `buildCapCallFn`'s `deps.addonId` for a registered grouped-runner
6231
- * provider). Only meaningful for `agent-child-forward` — a `remote-moleculer`
6232
- * route already encodes the provider in its action name
6233
- * (`${addonId}.${cap}.${method}`, via `NodeCapAuthority.getAddonId`), and a
6234
- * `hub-local-uds` caller that already knows the addonId (`buildCapCallFn`)
6235
- * talks to `LocalChildRegistry.callCapOnChild` directly rather than through
6236
- * `dispatch`.
6237
- * @param callerAddonId Optional out-of-band CALLER-identity hint — the
6238
- * addon that ORIGINATED the call, distinct from `addonId` above (which
6239
- * names the resolved PROVIDER, not the caller). Set by
6240
- * `onUnownedCall` from `CapCallInput.callerAddonId`
6241
- * (`LocalChildRegistry` stamped it off the registered `childId`). Only
6242
- * the `hub-in-process` branch consumes it — merged into `args` so a
6243
- * diagnostic tool deep in that provider (e.g. device-manager's
6244
- * fleet-read census) can read it without every provider method changing
6245
- * signature, the same "extra field the provider destructures or
6246
- * ignores" convention `nodeId` already uses on this path.
6800
+ * Does `entry` want to receive an event in `category`? Honours the fan-out
6801
+ * mode: `broadcast` → always; undeclared patterns (`null`) → always
6802
+ * (fail-open); else the child's declared set must match via `matchesPattern`
6803
+ * — the SAME function the child's own `deliverShared` map-key gate uses,
6804
+ * giving provable delivery parity. `shadow` is handled by the caller (it
6805
+ * counts the would-suppress but still sends).
6806
+ *
6807
+ * NOTE: this gate must NOT consult `RING_BUFFER_DENY_PATTERNS` /
6808
+ * `isHighFrequencyCategory` — those are ring-buffer STORAGE policy; a child
6809
+ * that explicitly subscribes to a ring-denied category must still RECEIVE it.
6247
6810
  */
6248
- async dispatch(route, method, args, addonId, callerAddonId) {
6249
- try {
6250
- return await this.dispatchInner(route, method, args, addonId, callerAddonId);
6251
- } catch (err) {
6252
- if (err instanceof CapRouteError) throw err;
6253
- const nodeId = this.routeNodeId(route);
6254
- const cause = err instanceof Error ? err : new Error(String(err));
6255
- throw new CapRouteError(route.capName, method, {
6256
- reason: "transport-failed",
6257
- nodeId,
6258
- rejected: [{
6259
- kind: route.kind,
6260
- why: cause.message
6261
- }]
6262
- }, cause);
6263
- }
6811
+ childWantsEvent(entry, category) {
6812
+ if (this.fanoutMode === "broadcast") return true;
6813
+ if (entry.eventPatterns === null) return true;
6814
+ return entry.eventPatterns.some((p) => matchesPattern(p, category));
6264
6815
  }
6265
6816
  /**
6266
- * Inner dispatch: may throw CapRouteError (validation failures) or arbitrary
6267
- * transport errors. The outer `dispatch` wraps non-CapRouteErrors.
6817
+ * D2: this node's aggregate cross-node event interest — the UNION of every
6818
+ * connected child's declared category patterns. Consumed by the parent's
6819
+ * `$event-bus` inbound gate (via `setNodeEventInterest`) so the node only
6820
+ * accepts cross-node (Moleculer) categories at least one local child wants.
6821
+ *
6822
+ * Returns `null` (fail-open — accept everything) when:
6823
+ * - NO child is currently connected (boot window before the first forked
6824
+ * child completes its UDS handshake, or every child has disconnected): a
6825
+ * node MUST NOT drop 100% of inbound cross-node events during that window,
6826
+ * and a child may connect imminently. This is distinct from the
6827
+ * declared-empty case below.
6828
+ * - ANY connected child is UNDECLARED (`eventPatterns === null`, a legacy
6829
+ * runner): a node cannot safely narrow its inbound set while a child's
6830
+ * real interest is unknown. Mirrors the per-child fail-open in
6831
+ * {@link childWantsEvent}, aggregated conservatively.
6832
+ *
6833
+ * Returns an EMPTY array only when ≥1 child is connected and EVERY connected
6834
+ * child declared an explicit empty set — a genuine "wants nothing". Reads the
6835
+ * live `children` map, so a child registering / disconnecting / sending
6836
+ * `event-sub` is reflected on the next call with no extra bookkeeping.
6268
6837
  */
6269
- async dispatchInner(route, method, args, addonId, callerAddonId) {
6270
- switch (route.kind) {
6271
- case "hub-in-process": return route.ref.invoke(method, withCallerAddonId(args, callerAddonId));
6272
- case "hub-local-uds": {
6273
- const registry = this.hubLocalRegistry;
6274
- if (registry === null) throw new CapRouteError(route.capName, method, {
6275
- reason: "no-provider",
6276
- rejected: [{
6277
- kind: "hub-local-uds",
6278
- why: "UDS registry not available"
6279
- }]
6280
- });
6281
- const deviceId = extractDeviceId(args);
6282
- const input = {
6283
- capName: route.capName,
6284
- method,
6285
- args,
6286
- ...deviceId !== void 0 ? { deviceId } : {}
6287
- };
6288
- return registry.callCapOnChild(route.childId, input);
6289
- }
6290
- case "remote-moleculer": {
6291
- const addonId = this.nodeAuthority.getAddonId(route.nodeId, route.capName);
6292
- if (addonId === null) throw new CapRouteError(route.capName, method, {
6293
- reason: "no-provider",
6294
- nodeId: route.nodeId,
6295
- rejected: [{
6296
- kind: "remote-moleculer",
6297
- why: `no addonId known for ${route.nodeId}/${route.capName}`
6298
- }]
6299
- });
6300
- const deviceId = extractDeviceId(args);
6301
- const isNative = this.nodeAuthority.isNativeCap(route.nodeId, route.capName, deviceId);
6302
- const action = capActionName(addonId, route.capName, method, isNative);
6303
- return callWithServiceDiscovery(this.broker, addonId, action, args, {
6304
- nodeID: route.nodeId,
6305
- timeout: this.callTimeout(route.capName, method)
6306
- });
6307
- }
6308
- case "agent-child-forward": {
6309
- const deviceId = extractDeviceId(args);
6310
- const params = {
6311
- capName: route.capName,
6312
- method,
6313
- args,
6314
- ...route.childId !== void 0 ? { childId: route.childId } : {},
6315
- ...deviceId !== void 0 ? { deviceId } : {},
6316
- ...addonId !== void 0 ? { addonId } : {}
6317
- };
6318
- return callWithServiceDiscovery(this.broker, AGENT_CAP_FWD_SERVICE, AGENT_CAP_FWD_ACTION, params, {
6319
- nodeID: route.agentNodeId,
6320
- timeout: this.callTimeout(route.capName, method)
6321
- });
6322
- }
6838
+ aggregateEventInterest() {
6839
+ if (this.children.size === 0) return null;
6840
+ const union = /* @__PURE__ */ new Set();
6841
+ for (const entry of this.children.values()) {
6842
+ if (entry.eventPatterns === null) return null;
6843
+ for (const p of entry.eventPatterns) union.add(p);
6323
6844
  }
6845
+ return [...union];
6324
6846
  }
6325
- buildSnapshot() {
6326
- const hubLocalRegistry = this.hubLocalRegistry;
6327
- const nodeAuthority = this.nodeAuthority;
6328
- const inProcessProviders = this.inProcessProviders;
6329
- const hubResidentProviders = this.hubResidentProviders;
6330
- return {
6331
- hubNodeId: this.hubNodeId,
6332
- hubInProcessProvides: (cap) => inProcessProviders(cap) !== null,
6333
- ...hubResidentProviders !== void 0 ? {
6334
- hubResidentProvides: (cap) => hubResidentProviders(cap) !== null,
6335
- getHubResidentProviderRef: (cap) => hubResidentProviders(cap)
6336
- } : {},
6337
- hubLocalChildProvides: (cap, deviceId) => hubLocalRegistry !== null && hubLocalRegistry.resolveChildId(cap, deviceId) !== null,
6338
- nodeKnowsCap: (nodeId, cap) => nodeAuthority.nodeKnowsCap(nodeId, cap),
6339
- nodeIsAgent: (nodeId) => nodeAuthority.nodeIsAgent(nodeId),
6340
- nodeOnline: (nodeId) => nodeAuthority.nodeOnline(nodeId),
6341
- listKnownNodeIds: () => nodeAuthority.listNodeIds(),
6342
- getInProcessProviderRef: (cap) => inProcessProviders(cap),
6343
- getHubLocalChildId: (cap, deviceId) => hubLocalRegistry !== null ? hubLocalRegistry.resolveChildId(cap, deviceId) : null,
6344
- getAgentChildId: (agentNodeId, cap) => nodeAuthority.getAgentChildId(agentNodeId, cap)
6345
- };
6847
+ statsFor(childId) {
6848
+ let s = this.childEventStats.get(childId);
6849
+ if (s === void 0) {
6850
+ s = {
6851
+ sent: 0,
6852
+ suppressed: 0
6853
+ };
6854
+ this.childEventStats.set(childId, s);
6855
+ }
6856
+ return s;
6346
6857
  }
6347
- /** Extract a nodeId string from a route for error reporting, or undefined. */
6348
- routeNodeId(route) {
6349
- switch (route.kind) {
6350
- case "hub-in-process": return this.hubNodeId;
6351
- case "hub-local-uds": return `${this.hubNodeId}/${route.childId}`;
6352
- case "remote-moleculer": return route.nodeId;
6353
- case "agent-child-forward": return route.agentNodeId;
6858
+ /** Deduped INFO line whenever a child's declared pattern set changes. */
6859
+ logPatternSet(childId, patterns) {
6860
+ const key = patterns === null ? "<undeclared>" : JSON.stringify([...patterns].toSorted());
6861
+ if (this.loggedPatternSet.get(childId) === key) return;
6862
+ this.loggedPatternSet.set(childId, key);
6863
+ this.logger?.info("child event patterns", {
6864
+ childId,
6865
+ patterns: patterns ?? null
6866
+ });
6867
+ }
6868
+ /**
6869
+ * Child id that can service a call to `capName` (optionally addressing
6870
+ * `deviceId`), or null.
6871
+ *
6872
+ * `deviceId` is a routing HINT, not a hard filter. A singleton cap
6873
+ * (`pipeline-runner`, `stream-broker`, …) addresses devices through its
6874
+ * METHOD ARGUMENTS — `attachCamera({ deviceId })` is one provider serving
6875
+ * many cameras — so its descriptor carries no `deviceId`. Resolving on
6876
+ * `deviceId` alone would never match it and would force the call onto the
6877
+ * broker fallback (which then hangs in service discovery). Hence:
6878
+ * 1. exact device-scoped owner (native per-device caps where each child
6879
+ * owns a disjoint device subset) is preferred, then
6880
+ * 2. a singleton owner (deviceId-less descriptor) is the fallback.
6881
+ * A cap is globally singleton XOR device-scoped, so the two tiers never
6882
+ * compete for the same capName.
6883
+ */
6884
+ resolveChildId(capName, deviceId) {
6885
+ if (deviceId !== void 0) {
6886
+ const deviceOwner = this.findChildId((cap) => cap.capName === capName && cap.deviceId === deviceId);
6887
+ if (deviceOwner !== null) return deviceOwner;
6888
+ }
6889
+ const candidates = this.findAllChildIds((cap) => cap.capName === capName && cap.deviceId === void 0);
6890
+ if (candidates.length === 0) return null;
6891
+ if (candidates.length === 1) return candidates[0];
6892
+ const preferredAddonId = this.getActiveSingletonAddonId?.(capName) ?? null;
6893
+ if (preferredAddonId !== null) {
6894
+ const preferredChildId = this.resolveChildIdForAddon?.(preferredAddonId) ?? preferredAddonId;
6895
+ if (candidates.includes(preferredChildId)) return preferredChildId;
6354
6896
  }
6355
- }
6356
- };
6357
- //#endregion
6358
- //#region src/kernel/transport/local-child-registry.ts
6359
- function readFanoutMode() {
6360
- const raw = process.env.CAMSTACK_UDS_EVENT_FANOUT;
6361
- if (raw === "shadow" || raw === "broadcast") return raw;
6362
- return "filter";
6363
- }
6364
- /**
6365
- * Sentinel prefix used in the no-route error thrown when `cap-call-out` has no
6366
- * local sibling and no `onUnownedCall` fallback. `ipcParentLink` detects this
6367
- * prefix to distinguish routing failures (safe to retry via broker) from real
6368
- * provider errors (must NOT retry to avoid double-executing side effects).
6369
- */
6370
- var UDS_NO_ROUTE_PREFIX = "UDS_NO_ROUTE";
6371
- /**
6372
- * Parent-side authority for local addon-runners reachable over a
6373
- * `LocalTransportServer` (UDS). Children register their cap manifest on
6374
- * connect; the registry routes `(capName, deviceId?)` cap calls to the
6375
- * owning child over the channel and drops a child's caps on disconnect.
6376
- */
6377
- var LocalChildRegistry = class {
6378
- children = /* @__PURE__ */ new Map();
6379
- registeredHandler = () => {};
6380
- goneHandler = () => {};
6381
- /** Source of {@link RegisteredChild.incarnation}; one bump per accepted connection. */
6382
- nextIncarnation = 1;
6383
- eventHandler = null;
6384
- logHandler = null;
6385
- readinessHandler = null;
6386
- server;
6387
- onUnownedCall;
6388
- logger;
6389
- getActiveSingletonAddonId;
6390
- resolveChildIdForAddon;
6391
- /** This node's own Moleculer nodeId (see {@link LocalChildRegistryOptions.ownNodeId}). */
6392
- ownNodeId;
6393
- /** See {@link LocalChildRegistryOptions.isAggregatedCollectionMethod}. */
6394
- isAggregatedCollectionMethod;
6395
- isAddonPinnedCall;
6396
- /** See {@link LocalChildRegistryOptions.capTimeoutMs}. */
6397
- capTimeoutMs;
6398
- /** See {@link LocalChildRegistryOptions.capUsageObserver}. */
6399
- capUsageObserver;
6400
- /** Tracks capNames already logged as UDS-routed; one INFO line per capName per process. */
6401
- egressRoutedCaps = /* @__PURE__ */ new Set();
6402
- /** Active event fan-out mode, read once from `CAMSTACK_UDS_EVENT_FANOUT`. */
6403
- fanoutMode = readFanoutMode();
6404
- /** Per-child event fan-out counters (sent / suppressed). */
6405
- childEventStats = /* @__PURE__ */ new Map();
6406
- /** Last pattern-set string logged per child, to dedup the INFO line. */
6407
- loggedPatternSet = /* @__PURE__ */ new Map();
6408
- /**
6409
- * Accepts either a plain positional `server` argument (backward-compatible)
6410
- * or a full `LocalChildRegistryOptions` object.
6411
- *
6412
- * Positional overloads (existing call sites are unchanged):
6413
- * new LocalChildRegistry(server)
6414
- * new LocalChildRegistry(server, onUnownedCall)
6415
- *
6416
- * Options object (new call sites that pass a logger):
6417
- * new LocalChildRegistry({ server, onUnownedCall, logger })
6418
- */
6419
- constructor(serverOrOptions, onUnownedCallArg) {
6420
- if (serverOrOptions && !("listen" in serverOrOptions)) {
6421
- const opts = serverOrOptions;
6422
- this.server = opts.server;
6423
- this.onUnownedCall = opts.onUnownedCall;
6424
- this.logger = opts.logger;
6425
- this.getActiveSingletonAddonId = opts.getActiveSingletonAddonId;
6426
- this.resolveChildIdForAddon = opts.resolveChildIdForAddon;
6427
- this.ownNodeId = opts.ownNodeId;
6428
- this.isAggregatedCollectionMethod = opts.isAggregatedCollectionMethod;
6429
- this.isAddonPinnedCall = opts.isAddonPinnedCall;
6430
- this.capTimeoutMs = opts.capTimeoutMs;
6431
- this.capUsageObserver = opts.capUsageObserver;
6432
- } else {
6433
- this.server = serverOrOptions;
6434
- this.onUnownedCall = onUnownedCallArg;
6435
- }
6436
- }
6437
- async start() {
6438
- this.logger?.info("UDS event fan-out mode", { mode: this.fanoutMode });
6439
- this.server.onConnection((channel) => this.onConnection(channel));
6440
- await this.server.listen();
6441
- }
6442
- /**
6443
- * Per-child event fan-out counters (`{ sent, suppressed }`) or `null` if the
6444
- * child has no recorded events yet. Exposed for the starvation-verification
6445
- * surface (shadow burn-in + operator debug).
6446
- */
6447
- getChildEventStats(childId) {
6448
- const s = this.childEventStats.get(childId);
6449
- return s === void 0 ? null : {
6450
- sent: s.sent,
6451
- suppressed: s.suppressed
6452
- };
6453
- }
6454
- /**
6455
- * This child's socket traffic — bytes and messages, both directions, split by
6456
- * frame kind — or `null` when the child is gone or its channel keeps no
6457
- * counters (an in-process or test transport).
6458
- *
6459
- * The event counters above cover ONE kind of frame and, once surfaced on
6460
- * 2026-08-29, accounted for 4% of hub-main's ~44 000 socket syscalls/s. This
6461
- * is the same question asked of every frame the channel moves, and it is the
6462
- * only per-peer attribution that exists: `/proc` reports one `rchar`/`wchar`
6463
- * pair for the whole process and has no per-socket breakdown.
6464
- */
6465
- getChildSocketTraffic(childId) {
6466
- const entry = this.children.get(childId);
6467
- if (entry === void 0) return null;
6468
- return entry.channel.readTraffic?.() ?? null;
6469
- }
6470
- /**
6471
- * The regime the counters above were produced under, read once from
6472
- * `CAMSTACK_UDS_EVENT_FANOUT` at construction.
6473
- *
6474
- * A suppressed count means "nothing WAS dropped" under `filter` and "nothing
6475
- * WOULD have been dropped" under `shadow`. Those are different claims, and
6476
- * until this getter existed the mode was invisible at runtime — the counters
6477
- * were readable and the regime that produced them was not.
6478
- */
6479
- get eventFanoutMode() {
6480
- return this.fanoutMode;
6481
- }
6482
- /**
6483
- * Connected children that never declared a subscription pattern set.
6484
- *
6485
- * Each one is a fail-open on BOTH planes: `childWantsEvent` returns true for
6486
- * an undeclared child ({@link ChildEntry.eventPatterns} `=== null`), and
6487
- * `aggregateEventInterest` returns `null` — accept everything — if ANY
6488
- * connected child is undeclared, which disables the node's inbound cross-node
6489
- * gate as well. **One legacy runner silently turns off the filtering for the
6490
- * whole node**, and nothing reported it, so a zero here is as load-bearing as
6491
- * a non-zero one.
6492
- */
6493
- undeclaredChildCount() {
6494
- let count = 0;
6495
- for (const entry of this.children.values()) if (entry.eventPatterns === null) count += 1;
6496
- return count;
6497
- }
6498
- /**
6499
- * Does `entry` want to receive an event in `category`? Honours the fan-out
6500
- * mode: `broadcast` → always; undeclared patterns (`null`) → always
6501
- * (fail-open); else the child's declared set must match via `matchesPattern`
6502
- * — the SAME function the child's own `deliverShared` map-key gate uses,
6503
- * giving provable delivery parity. `shadow` is handled by the caller (it
6504
- * counts the would-suppress but still sends).
6505
- *
6506
- * NOTE: this gate must NOT consult `RING_BUFFER_DENY_PATTERNS` /
6507
- * `isHighFrequencyCategory` — those are ring-buffer STORAGE policy; a child
6508
- * that explicitly subscribes to a ring-denied category must still RECEIVE it.
6509
- */
6510
- childWantsEvent(entry, category) {
6511
- if (this.fanoutMode === "broadcast") return true;
6512
- if (entry.eventPatterns === null) return true;
6513
- return entry.eventPatterns.some((p) => matchesPattern(p, category));
6514
- }
6515
- /**
6516
- * D2: this node's aggregate cross-node event interest — the UNION of every
6517
- * connected child's declared category patterns. Consumed by the parent's
6518
- * `$event-bus` inbound gate (via `setNodeEventInterest`) so the node only
6519
- * accepts cross-node (Moleculer) categories at least one local child wants.
6520
- *
6521
- * Returns `null` (fail-open — accept everything) when:
6522
- * - NO child is currently connected (boot window before the first forked
6523
- * child completes its UDS handshake, or every child has disconnected): a
6524
- * node MUST NOT drop 100% of inbound cross-node events during that window,
6525
- * and a child may connect imminently. This is distinct from the
6526
- * declared-empty case below.
6527
- * - ANY connected child is UNDECLARED (`eventPatterns === null`, a legacy
6528
- * runner): a node cannot safely narrow its inbound set while a child's
6529
- * real interest is unknown. Mirrors the per-child fail-open in
6530
- * {@link childWantsEvent}, aggregated conservatively.
6531
- *
6532
- * Returns an EMPTY array only when ≥1 child is connected and EVERY connected
6533
- * child declared an explicit empty set — a genuine "wants nothing". Reads the
6534
- * live `children` map, so a child registering / disconnecting / sending
6535
- * `event-sub` is reflected on the next call with no extra bookkeeping.
6536
- */
6537
- aggregateEventInterest() {
6538
- if (this.children.size === 0) return null;
6539
- const union = /* @__PURE__ */ new Set();
6540
- for (const entry of this.children.values()) {
6541
- if (entry.eventPatterns === null) return null;
6542
- for (const p of entry.eventPatterns) union.add(p);
6543
- }
6544
- return [...union];
6545
- }
6546
- statsFor(childId) {
6547
- let s = this.childEventStats.get(childId);
6548
- if (s === void 0) {
6549
- s = {
6550
- sent: 0,
6551
- suppressed: 0
6552
- };
6553
- this.childEventStats.set(childId, s);
6554
- }
6555
- return s;
6556
- }
6557
- /** Deduped INFO line whenever a child's declared pattern set changes. */
6558
- logPatternSet(childId, patterns) {
6559
- const key = patterns === null ? "<undeclared>" : JSON.stringify([...patterns].toSorted());
6560
- if (this.loggedPatternSet.get(childId) === key) return;
6561
- this.loggedPatternSet.set(childId, key);
6562
- this.logger?.info("child event patterns", {
6563
- childId,
6564
- patterns: patterns ?? null
6565
- });
6566
- }
6567
- /**
6568
- * Child id that can service a call to `capName` (optionally addressing
6569
- * `deviceId`), or null.
6570
- *
6571
- * `deviceId` is a routing HINT, not a hard filter. A singleton cap
6572
- * (`pipeline-runner`, `stream-broker`, …) addresses devices through its
6573
- * METHOD ARGUMENTS — `attachCamera({ deviceId })` is one provider serving
6574
- * many cameras — so its descriptor carries no `deviceId`. Resolving on
6575
- * `deviceId` alone would never match it and would force the call onto the
6576
- * broker fallback (which then hangs in service discovery). Hence:
6577
- * 1. exact device-scoped owner (native per-device caps where each child
6578
- * owns a disjoint device subset) is preferred, then
6579
- * 2. a singleton owner (deviceId-less descriptor) is the fallback.
6580
- * A cap is globally singleton XOR device-scoped, so the two tiers never
6581
- * compete for the same capName.
6582
- */
6583
- resolveChildId(capName, deviceId) {
6584
- if (deviceId !== void 0) {
6585
- const deviceOwner = this.findChildId((cap) => cap.capName === capName && cap.deviceId === deviceId);
6586
- if (deviceOwner !== null) return deviceOwner;
6587
- }
6588
- const candidates = this.findAllChildIds((cap) => cap.capName === capName && cap.deviceId === void 0);
6589
- if (candidates.length === 0) return null;
6590
- if (candidates.length === 1) return candidates[0];
6591
- const preferredAddonId = this.getActiveSingletonAddonId?.(capName) ?? null;
6592
- if (preferredAddonId !== null) {
6593
- const preferredChildId = this.resolveChildIdForAddon?.(preferredAddonId) ?? preferredAddonId;
6594
- if (candidates.includes(preferredChildId)) return preferredChildId;
6595
- }
6596
- return candidates[0];
6897
+ return candidates[0];
6597
6898
  }
6598
6899
  /**
6599
6900
  * Publish one cap-usage observation, if a sink is wired.
@@ -6845,7 +7146,7 @@ var LocalChildRegistry = class {
6845
7146
  return;
6846
7147
  }
6847
7148
  });
6848
- channel.onRequest(async (body) => {
7149
+ channel.onRequest(async (body, payload) => {
6849
7150
  const msg = body;
6850
7151
  if (msg.kind === "register") {
6851
7152
  if (childId !== null && childId !== msg.childId) throw new Error(`child attempted to change identity from "${childId}" to "${msg.childId}"`);
@@ -6862,31 +7163,38 @@ var LocalChildRegistry = class {
6862
7163
  channel,
6863
7164
  caps: msg.caps,
6864
7165
  eventPatterns,
6865
- incarnation
7166
+ incarnation,
7167
+ rawForward: msg.rawForward === true
6866
7168
  });
6867
7169
  this.logPatternSet(msg.childId, eventPatterns);
6868
7170
  this.registeredHandler({
6869
7171
  childId: msg.childId,
6870
7172
  caps: msg.caps,
6871
- incarnation
7173
+ incarnation,
7174
+ ...msg.customActions !== void 0 ? { customActions: msg.customActions } : {}
6872
7175
  });
6873
- return { ok: true };
7176
+ return {
7177
+ ok: true,
7178
+ rawForward: true
7179
+ };
6874
7180
  }
6875
7181
  if (msg.kind === "cap-call-out") {
6876
7182
  const out = msg;
6877
- const input = {
7183
+ const hints = out.hints ?? liftRoutingHints(out.args);
7184
+ const materialise = () => payload !== void 0 ? unpackPayload(payload) : out.args;
7185
+ const buildInput = () => ({
6878
7186
  capName: out.capName,
6879
7187
  method: out.method,
6880
- args: out.args,
7188
+ args: materialise(),
6881
7189
  ...out.deviceId !== void 0 ? { deviceId: out.deviceId } : {},
6882
7190
  ...out.nodeId !== void 0 ? { nodeId: out.nodeId } : {},
6883
7191
  ...out.native === true ? { native: true } : {},
6884
7192
  ...childId !== null ? { callerAddonId: childId } : {}
6885
- };
6886
- const pinnedNodeId = out.nodeId ?? extractNodeId(out.args);
7193
+ });
7194
+ const pinnedNodeId = out.nodeId ?? hints.nodeId;
6887
7195
  const pinTargetsThisNode = pinnedNodeId !== void 0 && this.ownNodeId !== void 0 && pinnedNodeId === this.ownNodeId;
6888
7196
  const aggregated = pinnedNodeId === void 0 && this.isAggregatedCollectionMethod?.(out.capName, out.method) === true;
6889
- const addonPinned = pinnedNodeId === void 0 && this.isAddonPinnedCall?.(out.capName, out.method, out.args) === true;
7197
+ const addonPinned = pinnedNodeId === void 0 && this.isAddonPinnedCall?.(out.capName, out.method, hints) === true;
6890
7198
  const target = !(out.native === true) && !aggregated && !addonPinned && (pinnedNodeId === void 0 || pinTargetsThisNode) ? this.resolveChildId(out.capName, out.deviceId) : null;
6891
7199
  this.recordCapUsage(childId, target, out.capName, out.method);
6892
7200
  if (target !== null) {
@@ -6894,9 +7202,27 @@ var LocalChildRegistry = class {
6894
7202
  this.egressRoutedCaps.add(out.capName);
6895
7203
  this.logger?.info("routed child egress over UDS", { capName: out.capName });
6896
7204
  }
6897
- return this.callCap(input);
7205
+ const entry = this.children.get(target);
7206
+ if (payload !== void 0 && entry !== void 0 && entry.rawForward) {
7207
+ this.forwardedRaw += 1;
7208
+ const forward = {
7209
+ kind: "cap-call",
7210
+ capName: out.capName,
7211
+ method: out.method,
7212
+ args: void 0,
7213
+ ...out.deviceId !== void 0 ? { deviceId: out.deviceId } : {}
7214
+ };
7215
+ const declared = this.capTimeoutMs?.(out.capName, out.method);
7216
+ return entry.channel.request(forward, {
7217
+ ...typeof declared === "number" ? { timeoutMs: declared } : {},
7218
+ payload
7219
+ });
7220
+ }
7221
+ this.forwardedDecoded += 1;
7222
+ return this.callCap(buildInput());
6898
7223
  }
6899
- if (this.onUnownedCall !== void 0) return this.onUnownedCall(input);
7224
+ this.forwardedDecoded += 1;
7225
+ if (this.onUnownedCall !== void 0) return this.onUnownedCall(buildInput());
6900
7226
  throw new Error(`${UDS_NO_ROUTE_PREFIX}: cap-call-out has no local provider for "${out.capName}" and no fallback`);
6901
7227
  }
6902
7228
  if (msg.kind === "readiness-request") return {
@@ -6926,6 +7252,11 @@ var LocalChildRegistry = class {
6926
7252
  };
6927
7253
  //#endregion
6928
7254
  //#region src/kernel/transport/local-child-client.ts
7255
+ /** Did the parent's `register` answer say it accepts payload-borne args? */
7256
+ function ackAcceptsRawForward(ack) {
7257
+ if (ack === null || typeof ack !== "object") return false;
7258
+ return ack.rawForward === true;
7259
+ }
6929
7260
  /**
6930
7261
  * Child side of the local UDS transport. Connects to its parent (hub or
6931
7262
  * agent), registers its cap manifest, and serves parent→child cap calls by
@@ -6970,8 +7301,24 @@ var LocalChildClient = class {
6970
7301
  * subscriptions do), the register carries the real union — even if empty.
6971
7302
  */
6972
7303
  hasDeclaredEventPatterns = false;
7304
+ /**
7305
+ * The custom-action catalogs of every hosted addon, once the runner's init
7306
+ * loop has produced them (`updateCaps(caps, catalogs)` at post-init). `null`
7307
+ * until then, and OMITTED from the register frame while null: the parent
7308
+ * reads absence as "not described yet", and a pre-init register must never
7309
+ * look like "this child declares no actions". Kept on the client so every
7310
+ * later re-register (native-cap change, reconnect) re-carries it unchanged.
7311
+ */
7312
+ latestCustomActions = null;
6973
7313
  /** Events and logs queued while the channel is not yet open. */
6974
7314
  pendingEmits = [];
7315
+ /**
7316
+ * Whether the parent acknowledged raw-forward at `register`. Until it has,
7317
+ * `callOut` sends args inline exactly as every child always did — a legacy
7318
+ * parent would decode a payload-borne call to `args: undefined` and run the
7319
+ * provider on nothing, silently. Re-read on every re-register.
7320
+ */
7321
+ parentRawForward = false;
6975
7322
  /** Handler for parent→child events. Registered via `onEvent`. */
6976
7323
  eventHandler = null;
6977
7324
  /**
@@ -7079,17 +7426,22 @@ var LocalChildClient = class {
7079
7426
  if (this.client !== null) throw new Error("LocalChildClient: already started — call close() first");
7080
7427
  const client = createLocalTransport().createClient(this.options.nodeId);
7081
7428
  const channel = await client.connect();
7082
- channel.onRequest(async (body) => {
7429
+ channel.onRequest(async (body, payload) => {
7083
7430
  const msg = body;
7084
- if (msg.kind === "cap-call") return this.options.dispatch({
7085
- capName: msg.capName,
7086
- method: msg.method,
7087
- args: msg.args,
7088
- ...msg.deviceId !== void 0 ? { deviceId: msg.deviceId } : {},
7089
- ...msg.addonId !== void 0 ? { addonId: msg.addonId } : {}
7090
- });
7091
- if (msg.kind === "addon-call") {
7092
- if (this.addonCallHandler === null) throw new Error(`LocalChildClient: addon-call for "${msg.addonId}" arrived but no onAddonCall handler is registered`);
7431
+ if (msg.kind === "cap-call") {
7432
+ const args = payload !== void 0 ? unpackPayload(payload) : msg.args;
7433
+ const result = await this.options.dispatch({
7434
+ capName: msg.capName,
7435
+ method: msg.method,
7436
+ args,
7437
+ ...msg.deviceId !== void 0 ? { deviceId: msg.deviceId } : {},
7438
+ ...msg.addonId !== void 0 ? { addonId: msg.addonId } : {}
7439
+ });
7440
+ if (payload !== void 0 && result !== void 0) return new PayloadReply(void 0, packPayload(result));
7441
+ return result;
7442
+ }
7443
+ if (msg.kind === "addon-call") {
7444
+ if (this.addonCallHandler === null) throw new Error(`LocalChildClient: addon-call for "${msg.addonId}" arrived but no onAddonCall handler is registered`);
7093
7445
  return this.addonCallHandler({
7094
7446
  addonId: msg.addonId,
7095
7447
  target: msg.target,
@@ -7109,14 +7461,9 @@ var LocalChildClient = class {
7109
7461
  }
7110
7462
  if (msg.kind === "set-log-level") this.setLogLevelHandler?.(msg.level);
7111
7463
  });
7112
- const register = {
7113
- kind: "register",
7114
- childId: this.options.childId,
7115
- caps: this.latestCaps,
7116
- ...this.hasDeclaredEventPatterns ? { eventPatterns: this.latestEventPatterns } : {}
7117
- };
7464
+ const register = this.registerFrame(this.latestCaps);
7118
7465
  try {
7119
- await channel.request(register);
7466
+ this.parentRawForward = ackAcceptsRawForward(await channel.request(register));
7120
7467
  } catch (err) {
7121
7468
  await client.close();
7122
7469
  throw err;
@@ -7126,95 +7473,564 @@ var LocalChildClient = class {
7126
7473
  this.flushPending(channel);
7127
7474
  for (const cb of this.connectedHandlers) cb();
7128
7475
  }
7129
- /** Flush buffered pre-start emits over the now-open channel. */
7130
- flushPending(channel) {
7131
- for (const item of this.pendingEmits) channel.emit(item.msg);
7132
- this.pendingEmits.length = 0;
7476
+ /** Flush buffered pre-start emits over the now-open channel. */
7477
+ flushPending(channel) {
7478
+ for (const item of this.pendingEmits) channel.emit(item.msg);
7479
+ this.pendingEmits.length = 0;
7480
+ }
7481
+ /**
7482
+ * Re-send the cap manifest to the parent, atomically replacing the child's
7483
+ * registered descriptor set. Call this after device-restore so newly
7484
+ * registered native (device-scoped) caps become routable over UDS.
7485
+ *
7486
+ * Safe to call before `start()`: the new set is buffered and `start()` sends
7487
+ * it (device-restore can race ahead of the UDS connect). After `start()`,
7488
+ * the set is sent immediately.
7489
+ */
7490
+ async updateCaps(caps, customActions) {
7491
+ this.latestCaps = caps;
7492
+ if (customActions !== void 0) this.latestCustomActions = customActions;
7493
+ if (this.channel === null) return;
7494
+ this.parentRawForward = ackAcceptsRawForward(await this.channel.request(this.registerFrame(caps)));
7495
+ }
7496
+ /**
7497
+ * The register frame: caps, plus everything a re-register must atomically
7498
+ * re-carry so no separate re-sync can be missed — the subscription union
7499
+ * (omitted if nothing ever declared, which keeps the fail-open shape) and the
7500
+ * custom-action catalogs (omitted until the init loop produced them).
7501
+ */
7502
+ registerFrame(caps) {
7503
+ return {
7504
+ kind: "register",
7505
+ childId: this.options.childId,
7506
+ caps,
7507
+ ...this.hasDeclaredEventPatterns ? { eventPatterns: this.latestEventPatterns } : {},
7508
+ ...this.latestCustomActions !== null ? { customActions: this.latestCustomActions } : {},
7509
+ rawForward: true
7510
+ };
7511
+ }
7512
+ /**
7513
+ * Fire-and-forget: send a system event to the parent for forwarding to the
7514
+ * hub event bus. Safe to call before `start()` — events are buffered and
7515
+ * flushed on connect.
7516
+ */
7517
+ emitEvent(event) {
7518
+ const msg = {
7519
+ kind: "event",
7520
+ event
7521
+ };
7522
+ if (this.channel !== null) this.channel.emit(msg);
7523
+ else this.pendingEmits.push({
7524
+ kind: "event",
7525
+ msg
7526
+ });
7527
+ }
7528
+ /**
7529
+ * Fire-and-forget: send a structured log entry to the parent. Safe to call
7530
+ * before `start()` — log entries are buffered and flushed on connect.
7531
+ */
7532
+ sendLog(entry) {
7533
+ const msg = {
7534
+ kind: "log",
7535
+ ...entry
7536
+ };
7537
+ if (this.channel !== null) this.channel.emit(msg);
7538
+ else this.pendingEmits.push({
7539
+ kind: "log",
7540
+ msg
7541
+ });
7542
+ }
7543
+ /**
7544
+ * Request the current readiness snapshot from the parent. Returns the
7545
+ * authoritative set of `IReadinessRegistryRecord` entries the hub holds.
7546
+ * Requires `start()` to have been called; throws with a clear message if not.
7547
+ */
7548
+ async requestReadinessSnapshot() {
7549
+ if (this.channel === null) throw new Error("LocalChildClient: requestReadinessSnapshot called before start()");
7550
+ return (await this.channel.request({ kind: "readiness-request" })).records;
7551
+ }
7552
+ /**
7553
+ * Ask the parent to execute a cap call this child does NOT own. The parent
7554
+ * routes to the owning local sibling over UDS, or — if no sibling owns it —
7555
+ * to its `onUnownedCall` fallback (the cluster CapabilityRegistry in
7556
+ * production). Throws if called before `start()`.
7557
+ */
7558
+ async callOut(input) {
7559
+ if (this.channel === null) throw new Error("LocalChildClient: callOut before start");
7560
+ const routing = {
7561
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
7562
+ ...input.nodeId !== void 0 ? { nodeId: input.nodeId } : {},
7563
+ ...input.native === true ? { native: true } : {}
7564
+ };
7565
+ if (this.parentRawForward && input.args !== void 0) {
7566
+ const hints = liftRoutingHints(input.args);
7567
+ const msg = {
7568
+ kind: "cap-call-out",
7569
+ capName: input.capName,
7570
+ method: input.method,
7571
+ ...Object.keys(hints).length > 0 ? { hints } : {},
7572
+ ...routing
7573
+ };
7574
+ const reply = await this.channel.request(msg, { payload: packPayload(input.args) });
7575
+ return reply instanceof PayloadReply ? unpackPayload(reply.payload) : reply;
7576
+ }
7577
+ const msg = {
7578
+ kind: "cap-call-out",
7579
+ capName: input.capName,
7580
+ method: input.method,
7581
+ args: input.args,
7582
+ ...routing
7583
+ };
7584
+ return this.channel.request(msg);
7585
+ }
7586
+ /** Disconnect from the parent. Safe to call before `start()` (no-op) and idempotent. */
7587
+ async close() {
7588
+ await this.client?.close();
7589
+ this.client = null;
7590
+ this.channel = null;
7591
+ }
7592
+ };
7593
+ //#endregion
7594
+ //#region src/kernel/transport/cap-route.ts
7595
+ function buildMessage(capName, method, detail) {
7596
+ const call = method !== void 0 ? `${capName}.${method}` : capName;
7597
+ const target = detail.nodeId !== void 0 ? ` to node ${detail.nodeId}` : "";
7598
+ const rejectedStr = detail.rejected.map((r) => `${r.kind}=${r.why}`).join("; ");
7599
+ const rejectedClause = rejectedStr.length > 0 ? ` (rejected: ${rejectedStr})` : "";
7600
+ return `${call} not routable${target}: ${detail.reason}${rejectedClause}`;
7601
+ }
7602
+ var CapRouteError = class extends Error {
7603
+ reason;
7604
+ nodeId;
7605
+ rejected;
7606
+ /**
7607
+ * @param cause Optional original error that triggered this routing failure.
7608
+ * Stored as `Error.cause` (TC39 standard option, Node 16.9+).
7609
+ * Dispatchers wrapping transport errors MUST pass the original.
7610
+ */
7611
+ constructor(capName, method, detail, cause) {
7612
+ super(buildMessage(capName, method, detail), cause !== void 0 ? { cause } : void 0);
7613
+ this.name = "CapRouteError";
7614
+ this.reason = detail.reason;
7615
+ this.nodeId = detail.nodeId;
7616
+ this.rejected = detail.rejected;
7617
+ }
7618
+ };
7619
+ /**
7620
+ * Classifies a (capName, opts) pair into a typed CapRoute dispatch descriptor.
7621
+ *
7622
+ * Precedence (explicit nodeId path):
7623
+ * 1. hub-in-process — hub node + hubInProcessProvides
7624
+ * 2. hub-local-uds — hub node + hubLocalChildProvides
7625
+ * 3. node-offline → CapRouteError{reason:'node-offline'}
7626
+ * 4. agent-child-forward — node is an agent AND nodeKnowsCap
7627
+ * 5. remote-moleculer — any other online non-agent node
7628
+ *
7629
+ * Singleton path (no nodeId):
7630
+ * 1. hub-in-process (hub provides in-process)
7631
+ * 2. hub-local-uds (a hub-local UDS child provides it)
7632
+ * 3. remote-moleculer (any online, non-hub node that knows it)
7633
+ * 4. → CapRouteError{reason:'no-provider', rejected: all considered routes}
7634
+ *
7635
+ * PURE: no side effects, no async, no broker/registry imports.
7636
+ */
7637
+ function classifyCapRoute(capName, opts, snapshot) {
7638
+ const rejected = [];
7639
+ if (opts.nodeId !== void 0) return classifyExplicitNode(capName, opts.nodeId, opts.deviceId, snapshot, rejected);
7640
+ return classifySingleton(capName, opts.deviceId, snapshot, rejected);
7641
+ }
7642
+ function classifyExplicitNode(capName, nodeId, deviceId, snap, rejected) {
7643
+ if (nodeId === snap.hubNodeId) {
7644
+ const residentProvides = snap.hubResidentProvides;
7645
+ const providesOnHub = residentProvides ?? snap.hubInProcessProvides;
7646
+ const getRef = residentProvides !== void 0 ? snap.getHubResidentProviderRef : snap.getInProcessProviderRef;
7647
+ if (providesOnHub(capName)) {
7648
+ const ref = getRef?.(capName) ?? null;
7649
+ if (ref !== null) return {
7650
+ kind: "hub-in-process",
7651
+ capName,
7652
+ ref
7653
+ };
7654
+ rejected.push({
7655
+ kind: "hub-in-process",
7656
+ why: "provider ref not available in snapshot"
7657
+ });
7658
+ throw new CapRouteError(capName, void 0, {
7659
+ reason: "no-provider",
7660
+ nodeId,
7661
+ rejected
7662
+ });
7663
+ }
7664
+ if (snap.hubLocalChildProvides(capName, deviceId)) {
7665
+ const childId = snap.getHubLocalChildId?.(capName, deviceId) ?? null;
7666
+ if (childId !== null) return {
7667
+ kind: "hub-local-uds",
7668
+ capName,
7669
+ childId
7670
+ };
7671
+ rejected.push({
7672
+ kind: "hub-local-uds",
7673
+ why: "child id not resolvable from snapshot"
7674
+ });
7675
+ throw new CapRouteError(capName, void 0, {
7676
+ reason: "no-provider",
7677
+ nodeId,
7678
+ rejected
7679
+ });
7680
+ }
7681
+ rejected.push({
7682
+ kind: "hub-in-process",
7683
+ why: "hub does not provide this cap in-process"
7684
+ });
7685
+ rejected.push({
7686
+ kind: "hub-local-uds",
7687
+ why: "no hub-local child provides this cap"
7688
+ });
7689
+ throw new CapRouteError(capName, void 0, {
7690
+ reason: "no-provider",
7691
+ nodeId,
7692
+ rejected
7693
+ });
7694
+ }
7695
+ if (!snap.nodeOnline(nodeId)) {
7696
+ rejected.push({
7697
+ kind: "remote-moleculer",
7698
+ why: `node ${nodeId} is offline`
7699
+ });
7700
+ throw new CapRouteError(capName, void 0, {
7701
+ reason: "node-offline",
7702
+ nodeId,
7703
+ rejected
7704
+ });
7705
+ }
7706
+ if (snap.nodeIsAgent(nodeId)) {
7707
+ if (snap.nodeKnowsCap(nodeId, capName)) return {
7708
+ kind: "agent-child-forward",
7709
+ capName,
7710
+ agentNodeId: nodeId,
7711
+ childId: snap.getAgentChildId?.(nodeId, capName) ?? void 0
7712
+ };
7713
+ rejected.push({
7714
+ kind: "agent-child-forward",
7715
+ why: `agent ${nodeId} does not know cap ${capName}`
7716
+ });
7717
+ throw new CapRouteError(capName, void 0, {
7718
+ reason: "no-provider",
7719
+ nodeId,
7720
+ rejected
7721
+ });
7722
+ }
7723
+ return {
7724
+ kind: "remote-moleculer",
7725
+ capName,
7726
+ nodeId
7727
+ };
7728
+ }
7729
+ function classifySingleton(capName, deviceId, snap, rejected) {
7730
+ if (snap.hubInProcessProvides(capName)) {
7731
+ const ref = snap.getInProcessProviderRef?.(capName) ?? null;
7732
+ if (ref !== null) return {
7733
+ kind: "hub-in-process",
7734
+ capName,
7735
+ ref
7736
+ };
7737
+ rejected.push({
7738
+ kind: "hub-in-process",
7739
+ why: "provider ref not available in snapshot"
7740
+ });
7741
+ } else rejected.push({
7742
+ kind: "hub-in-process",
7743
+ why: "hub does not provide this cap in-process"
7744
+ });
7745
+ if (snap.hubLocalChildProvides(capName, deviceId)) {
7746
+ const childId = snap.getHubLocalChildId?.(capName, deviceId) ?? null;
7747
+ if (childId !== null) return {
7748
+ kind: "hub-local-uds",
7749
+ capName,
7750
+ childId
7751
+ };
7752
+ rejected.push({
7753
+ kind: "hub-local-uds",
7754
+ why: "child id not resolvable from snapshot"
7755
+ });
7756
+ } else rejected.push({
7757
+ kind: "hub-local-uds",
7758
+ why: "no hub-local child provides this cap"
7759
+ });
7760
+ const knownNodes = snap.listKnownNodeIds?.() ?? [];
7761
+ for (const nodeId of knownNodes) if (nodeId !== snap.hubNodeId && snap.nodeOnline(nodeId) && snap.nodeKnowsCap(nodeId, capName)) return {
7762
+ kind: "remote-moleculer",
7763
+ capName,
7764
+ nodeId
7765
+ };
7766
+ rejected.push({
7767
+ kind: "remote-moleculer",
7768
+ why: "no online remote node knows this cap"
7769
+ });
7770
+ throw new CapRouteError(capName, void 0, {
7771
+ reason: "no-provider",
7772
+ rejected
7773
+ });
7774
+ }
7775
+ //#endregion
7776
+ //#region src/kernel/moleculer/resilient-cap-call.ts
7777
+ /** Moleculer error `type` values meaning "the service is not (yet) routable". */
7778
+ var DISCOVERY_ERROR_TYPES = new Set(["SERVICE_NOT_FOUND", "SERVICE_NOT_AVAILABLE"]);
7779
+ /** Default ceiling for the discovery wait — fail-fast past this. */
7780
+ var DEFAULT_DISCOVERY_TIMEOUT_MS$1 = 3e4;
7781
+ function isDiscoveryError(err) {
7782
+ if (typeof err !== "object" || err === null) return false;
7783
+ const type = err.type;
7784
+ return typeof type === "string" && DISCOVERY_ERROR_TYPES.has(type);
7785
+ }
7786
+ /**
7787
+ * Call a Moleculer action; on a service-discovery error, wait for the
7788
+ * named service to be discovered and retry the call exactly once.
7789
+ */
7790
+ async function callWithServiceDiscovery(broker, serviceName, action, params, opts, discoveryTimeoutMs = DEFAULT_DISCOVERY_TIMEOUT_MS$1) {
7791
+ try {
7792
+ return await broker.call(action, params, opts);
7793
+ } catch (err) {
7794
+ if (!isDiscoveryError(err)) throw err;
7795
+ await broker.waitForServices([serviceName], discoveryTimeoutMs);
7796
+ return await broker.call(action, params, opts);
7797
+ }
7798
+ }
7799
+ //#endregion
7800
+ //#region src/kernel/transport/cap-route-resolver.ts
7801
+ /** The Moleculer service name that Task 6's agent registers. */
7802
+ var AGENT_CAP_FWD_SERVICE = "$agent-cap-fwd";
7803
+ /** The Moleculer action (service.action) for agent cap forwarding. */
7804
+ var AGENT_CAP_FWD_ACTION = `${AGENT_CAP_FWD_SERVICE}.forward`;
7805
+ /** Default timeout for remote Moleculer cap calls (ms). */
7806
+ var REMOTE_CALL_TIMEOUT_MS = 6e4;
7807
+ function extractDeviceId(args) {
7808
+ if (args === null || typeof args !== "object") return void 0;
7809
+ const raw = Reflect.get(args, "deviceId");
7810
+ return typeof raw === "number" ? raw : void 0;
7811
+ }
7812
+ /**
7813
+ * Extract an inline `nodeId` string from a cap call's args — the per-call node
7814
+ * pin a forked addon expresses by carrying `nodeId` in the input (the SAME way
7815
+ * device-scoped caps carry `deviceId`, and the way the generated cap-router on
7816
+ * the hub already honours an inline pin). Mirrors {@link extractDeviceId}.
7817
+ *
7818
+ * This is the routing source for hub→remote-node EXECUTION pinning (e.g. the
7819
+ * benchmark addon running a synthetic/decoder workload ON a chosen agent). The
7820
+ * out-of-band `nodePin(op.context)` path does NOT survive the forked addon →
7821
+ * hub UDS link chain, so `onUnownedCall` reads the inline pin instead. Provider
7822
+ * methods that don't declare `nodeId` simply ignore the extra field (they
7823
+ * destructure the fields they need); methods that DO declare it (e.g.
7824
+ * `getEngineProvisioning({nodeId})`) still receive it unchanged.
7825
+ */
7826
+ function extractNodeId(args) {
7827
+ if (args === null || typeof args !== "object") return void 0;
7828
+ const raw = Reflect.get(args, "nodeId");
7829
+ return typeof raw === "string" && raw.length > 0 ? raw : void 0;
7830
+ }
7831
+ /**
7832
+ * Merge `callerAddonId` into `args` as an extra field — the SAME "provider
7833
+ * destructures what it needs and ignores the rest" convention
7834
+ * {@link extractNodeId} already relies on for the inline `nodeId` pin.
7835
+ * `undefined` (the overwhelmingly common case: no caller hint, or the census
7836
+ * consuming it is disarmed) returns `args` unchanged — no allocation. A
7837
+ * non-object `args` (or `null`/array) is returned unchanged too: there is
7838
+ * nowhere to attach an extra key without changing the payload's shape, and a
7839
+ * provider taking a bare value never reads `callerAddonId` off it anyway.
7840
+ */
7841
+ function withCallerAddonId(args, callerAddonId) {
7842
+ if (callerAddonId === void 0) return args;
7843
+ if (args === null || typeof args !== "object" || Array.isArray(args)) return args;
7844
+ return {
7845
+ ...args,
7846
+ callerAddonId
7847
+ };
7848
+ }
7849
+ var CapRouteResolver = class {
7850
+ hubNodeId;
7851
+ broker;
7852
+ hubLocalRegistry;
7853
+ nodeAuthority;
7854
+ inProcessProviders;
7855
+ hubResidentProviders;
7856
+ capTimeoutMs;
7857
+ snapshot;
7858
+ constructor(deps) {
7859
+ this.hubNodeId = deps.hubNodeId;
7860
+ this.broker = deps.broker;
7861
+ this.hubLocalRegistry = deps.hubLocalRegistry;
7862
+ this.nodeAuthority = deps.nodeAuthority;
7863
+ this.inProcessProviders = deps.inProcessProviders;
7864
+ this.hubResidentProviders = deps.hubResidentProviders;
7865
+ this.capTimeoutMs = deps.capTimeoutMs;
7866
+ this.snapshot = this.buildSnapshot();
7867
+ }
7868
+ /** Per-call RPC timeout: the cap method's declared override, else the default. */
7869
+ callTimeout(capName, method) {
7870
+ return this.capTimeoutMs?.(capName, method) ?? REMOTE_CALL_TIMEOUT_MS;
7133
7871
  }
7134
- /**
7135
- * Re-send the cap manifest to the parent, atomically replacing the child's
7136
- * registered descriptor set. Call this after device-restore so newly
7137
- * registered native (device-scoped) caps become routable over UDS.
7138
- *
7139
- * Safe to call before `start()`: the new set is buffered and `start()` sends
7140
- * it (device-restore can race ahead of the UDS connect). After `start()`,
7141
- * the set is sent immediately.
7142
- */
7143
- async updateCaps(caps) {
7144
- this.latestCaps = caps;
7145
- if (this.channel === null) return;
7146
- const register = {
7147
- kind: "register",
7148
- childId: this.options.childId,
7149
- caps,
7150
- ...this.hasDeclaredEventPatterns ? { eventPatterns: this.latestEventPatterns } : {}
7151
- };
7152
- await this.channel.request(register);
7872
+ resolveCapRoute(capName, opts) {
7873
+ return classifyCapRoute(capName, opts, this.snapshot);
7153
7874
  }
7154
7875
  /**
7155
- * Fire-and-forget: send a system event to the parent for forwarding to the
7156
- * hub event bus. Safe to call before `start()` — events are buffered and
7157
- * flushed on connect.
7876
+ * Resolve the hub-local-uds route for a cap owned by a forked hub-local
7877
+ * child, IGNORING any in-hub provider registered for the same cap name.
7878
+ *
7879
+ * `resolveCapRoute` gives Priority 1 to `hub-in-process`: when an in-hub
7880
+ * provider (e.g. a wrapper) is registered for the cap, the route always
7881
+ * classifies as `hub-in-process` and the hub-local-uds NATIVE child is never
7882
+ * reached. That is correct for the generic dispatch path (the wrapper is the
7883
+ * active provider), but WRONG for the native-cap fallback
7884
+ * (`setNativeFallback`), whose contract is to reach the NATIVE provider in
7885
+ * the forked vendor child so a wrapper can delegate to it. A wrapper cap
7886
+ * with a forked native (today: `snapshot`) otherwise resolves to the wrapper
7887
+ * itself, the native is never invoked, and the wrapper silently falls
7888
+ * through to its secondary strategy.
7889
+ *
7890
+ * This method consults ONLY the hub-local-child authority
7891
+ * (`hubLocalChildProvides` + `getHubLocalChildId`, both deviceId-aware), so
7892
+ * it returns the native child route regardless of any in-process shadow.
7893
+ * Returns null when no hub-local child owns `(capName, deviceId)` — the
7894
+ * caller then falls through to its remote-resolution branch.
7158
7895
  */
7159
- emitEvent(event) {
7160
- const msg = {
7161
- kind: "event",
7162
- event
7896
+ resolveHubLocalUdsRoute(capName, deviceId) {
7897
+ if (!this.snapshot.hubLocalChildProvides(capName, deviceId)) return null;
7898
+ const childId = this.snapshot.getHubLocalChildId?.(capName, deviceId) ?? null;
7899
+ if (childId === null) return null;
7900
+ return {
7901
+ kind: "hub-local-uds",
7902
+ capName,
7903
+ childId
7163
7904
  };
7164
- if (this.channel !== null) this.channel.emit(msg);
7165
- else this.pendingEmits.push({
7166
- kind: "event",
7167
- msg
7168
- });
7169
7905
  }
7170
7906
  /**
7171
- * Fire-and-forget: send a structured log entry to the parent. Safe to call
7172
- * before `start()` — log entries are buffered and flushed on connect.
7907
+ * @param addonId Optional out-of-band PROVIDER-selection hint (Task 7.5a):
7908
+ * the addonId of the provider the CALLER already resolved (e.g.
7909
+ * `buildCapCallFn`'s `deps.addonId` for a registered grouped-runner
7910
+ * provider). Only meaningful for `agent-child-forward` — a `remote-moleculer`
7911
+ * route already encodes the provider in its action name
7912
+ * (`${addonId}.${cap}.${method}`, via `NodeCapAuthority.getAddonId`), and a
7913
+ * `hub-local-uds` caller that already knows the addonId (`buildCapCallFn`)
7914
+ * talks to `LocalChildRegistry.callCapOnChild` directly rather than through
7915
+ * `dispatch`.
7916
+ * @param callerAddonId Optional out-of-band CALLER-identity hint — the
7917
+ * addon that ORIGINATED the call, distinct from `addonId` above (which
7918
+ * names the resolved PROVIDER, not the caller). Set by
7919
+ * `onUnownedCall` from `CapCallInput.callerAddonId`
7920
+ * (`LocalChildRegistry` stamped it off the registered `childId`). Only
7921
+ * the `hub-in-process` branch consumes it — merged into `args` so a
7922
+ * diagnostic tool deep in that provider (e.g. device-manager's
7923
+ * fleet-read census) can read it without every provider method changing
7924
+ * signature, the same "extra field the provider destructures or
7925
+ * ignores" convention `nodeId` already uses on this path.
7173
7926
  */
7174
- sendLog(entry) {
7175
- const msg = {
7176
- kind: "log",
7177
- ...entry
7178
- };
7179
- if (this.channel !== null) this.channel.emit(msg);
7180
- else this.pendingEmits.push({
7181
- kind: "log",
7182
- msg
7183
- });
7927
+ async dispatch(route, method, args, addonId, callerAddonId) {
7928
+ try {
7929
+ return await this.dispatchInner(route, method, args, addonId, callerAddonId);
7930
+ } catch (err) {
7931
+ if (err instanceof CapRouteError) throw err;
7932
+ const nodeId = this.routeNodeId(route);
7933
+ const cause = err instanceof Error ? err : new Error(String(err));
7934
+ throw new CapRouteError(route.capName, method, {
7935
+ reason: "transport-failed",
7936
+ nodeId,
7937
+ rejected: [{
7938
+ kind: route.kind,
7939
+ why: cause.message
7940
+ }]
7941
+ }, cause);
7942
+ }
7184
7943
  }
7185
7944
  /**
7186
- * Request the current readiness snapshot from the parent. Returns the
7187
- * authoritative set of `IReadinessRegistryRecord` entries the hub holds.
7188
- * Requires `start()` to have been called; throws with a clear message if not.
7945
+ * Inner dispatch: may throw CapRouteError (validation failures) or arbitrary
7946
+ * transport errors. The outer `dispatch` wraps non-CapRouteErrors.
7189
7947
  */
7190
- async requestReadinessSnapshot() {
7191
- if (this.channel === null) throw new Error("LocalChildClient: requestReadinessSnapshot called before start()");
7192
- return (await this.channel.request({ kind: "readiness-request" })).records;
7948
+ async dispatchInner(route, method, args, addonId, callerAddonId) {
7949
+ switch (route.kind) {
7950
+ case "hub-in-process": return route.ref.invoke(method, withCallerAddonId(args, callerAddonId));
7951
+ case "hub-local-uds": {
7952
+ const registry = this.hubLocalRegistry;
7953
+ if (registry === null) throw new CapRouteError(route.capName, method, {
7954
+ reason: "no-provider",
7955
+ rejected: [{
7956
+ kind: "hub-local-uds",
7957
+ why: "UDS registry not available"
7958
+ }]
7959
+ });
7960
+ const deviceId = extractDeviceId(args);
7961
+ const input = {
7962
+ capName: route.capName,
7963
+ method,
7964
+ args,
7965
+ ...deviceId !== void 0 ? { deviceId } : {}
7966
+ };
7967
+ return registry.callCapOnChild(route.childId, input);
7968
+ }
7969
+ case "remote-moleculer": {
7970
+ const addonId = this.nodeAuthority.getAddonId(route.nodeId, route.capName);
7971
+ if (addonId === null) throw new CapRouteError(route.capName, method, {
7972
+ reason: "no-provider",
7973
+ nodeId: route.nodeId,
7974
+ rejected: [{
7975
+ kind: "remote-moleculer",
7976
+ why: `no addonId known for ${route.nodeId}/${route.capName}`
7977
+ }]
7978
+ });
7979
+ const deviceId = extractDeviceId(args);
7980
+ const isNative = this.nodeAuthority.isNativeCap(route.nodeId, route.capName, deviceId);
7981
+ const action = capActionName(addonId, route.capName, method, isNative);
7982
+ return callWithServiceDiscovery(this.broker, addonId, action, args, {
7983
+ nodeID: route.nodeId,
7984
+ timeout: this.callTimeout(route.capName, method)
7985
+ });
7986
+ }
7987
+ case "agent-child-forward": {
7988
+ const deviceId = extractDeviceId(args);
7989
+ const params = {
7990
+ capName: route.capName,
7991
+ method,
7992
+ args,
7993
+ ...route.childId !== void 0 ? { childId: route.childId } : {},
7994
+ ...deviceId !== void 0 ? { deviceId } : {},
7995
+ ...addonId !== void 0 ? { addonId } : {}
7996
+ };
7997
+ return callWithServiceDiscovery(this.broker, AGENT_CAP_FWD_SERVICE, AGENT_CAP_FWD_ACTION, params, {
7998
+ nodeID: route.agentNodeId,
7999
+ timeout: this.callTimeout(route.capName, method)
8000
+ });
8001
+ }
8002
+ }
7193
8003
  }
7194
- /**
7195
- * Ask the parent to execute a cap call this child does NOT own. The parent
7196
- * routes to the owning local sibling over UDS, or — if no sibling owns it —
7197
- * to its `onUnownedCall` fallback (the cluster CapabilityRegistry in
7198
- * production). Throws if called before `start()`.
7199
- */
7200
- async callOut(input) {
7201
- if (this.channel === null) throw new Error("LocalChildClient: callOut before start");
7202
- const msg = {
7203
- kind: "cap-call-out",
7204
- capName: input.capName,
7205
- method: input.method,
7206
- args: input.args,
7207
- ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
7208
- ...input.nodeId !== void 0 ? { nodeId: input.nodeId } : {},
7209
- ...input.native === true ? { native: true } : {}
8004
+ buildSnapshot() {
8005
+ const hubLocalRegistry = this.hubLocalRegistry;
8006
+ const nodeAuthority = this.nodeAuthority;
8007
+ const inProcessProviders = this.inProcessProviders;
8008
+ const hubResidentProviders = this.hubResidentProviders;
8009
+ return {
8010
+ hubNodeId: this.hubNodeId,
8011
+ hubInProcessProvides: (cap) => inProcessProviders(cap) !== null,
8012
+ ...hubResidentProviders !== void 0 ? {
8013
+ hubResidentProvides: (cap) => hubResidentProviders(cap) !== null,
8014
+ getHubResidentProviderRef: (cap) => hubResidentProviders(cap)
8015
+ } : {},
8016
+ hubLocalChildProvides: (cap, deviceId) => hubLocalRegistry !== null && hubLocalRegistry.resolveChildId(cap, deviceId) !== null,
8017
+ nodeKnowsCap: (nodeId, cap) => nodeAuthority.nodeKnowsCap(nodeId, cap),
8018
+ nodeIsAgent: (nodeId) => nodeAuthority.nodeIsAgent(nodeId),
8019
+ nodeOnline: (nodeId) => nodeAuthority.nodeOnline(nodeId),
8020
+ listKnownNodeIds: () => nodeAuthority.listNodeIds(),
8021
+ getInProcessProviderRef: (cap) => inProcessProviders(cap),
8022
+ getHubLocalChildId: (cap, deviceId) => hubLocalRegistry !== null ? hubLocalRegistry.resolveChildId(cap, deviceId) : null,
8023
+ getAgentChildId: (agentNodeId, cap) => nodeAuthority.getAgentChildId(agentNodeId, cap)
7210
8024
  };
7211
- return this.channel.request(msg);
7212
8025
  }
7213
- /** Disconnect from the parent. Safe to call before `start()` (no-op) and idempotent. */
7214
- async close() {
7215
- await this.client?.close();
7216
- this.client = null;
7217
- this.channel = null;
8026
+ /** Extract a nodeId string from a route for error reporting, or undefined. */
8027
+ routeNodeId(route) {
8028
+ switch (route.kind) {
8029
+ case "hub-in-process": return this.hubNodeId;
8030
+ case "hub-local-uds": return `${this.hubNodeId}/${route.childId}`;
8031
+ case "remote-moleculer": return route.nodeId;
8032
+ case "agent-child-forward": return route.agentNodeId;
8033
+ }
7218
8034
  }
7219
8035
  };
7220
8036
  //#endregion
@@ -9886,4 +10702,4 @@ async function defaultRun(command, args) {
9886
10702
  await execFileAsync(command, [...args], { timeout: INSTALL_TIMEOUT_MS });
9887
10703
  }
9888
10704
  //#endregion
9889
- export { buildUdsNativeCapProxy as $, formatHeapSpaces as $t, createUdsLoggerWithControl as A, HUB_MAIN_RSS_BUDGET_MB as At, readMoleculerFanoutMode as B, createV8Reclaimer as Bt, ipcParentLink as C, createAddonDataPlaneFacility as Ct, createUdsEventBus as D, HEAP_WATCH_INTERVAL_MS as Dt, createUdsEventBridge as E, HEAP_RECLAIM_TRIGGER_MB as Et, AGENT_CAP_FWD_SERVICE as F, RUNNER_RSS_BUDGET_ENV as Ft, createLocalTransport as G, reclaimIntervalMs as Gt, PARENT_ROUTED_PROVIDER_ADDON_ID as H, hubMainRssBudget as Ht, CapRouteResolver as I, ZERO_LOOP_DELAY as It, SocketChannel as J, startRunnerHeapWatch as Jt, UdsLocalTransportClient as K, shouldReclaim as Kt, CapRouteError as L, buildHeapSample as Lt, LocalChildRegistry as M, RSS_BUDGET_REANNOUNCE_MIN_MS as Mt, UDS_NO_ROUTE_PREFIX as N, RSS_BUDGET_RELEASE_RATIO as Nt, udsChildLogToWorkerEntry as O, HEAP_WATCH_WARN_RATIO as Ot, AGENT_CAP_FWD_ACTION as P, RUNNER_HEAP_WATCH_INTERVAL_MS as Pt, buildNativeCapProxy as Q, emitHeapDiagnosticReport as Qt, classifyCapRoute as R, createDeferredHeapWatchSink as Rt, buildLinkChain as S, runNpm as St, createParentUnownedCallHandler as T, HEAP_RECLAIM_STEADY_STATE_INTERVAL_MS as Tt, __resetCapUsageRegistryForTests as U, nextRssBudgetState as Ut, CapUsageRegistry as V, describeRss as Vt, getCapUsageRegistry as W, parseRssBudgetMb as Wt, FrameDecoder as X, DECISIVE_HEAP_SPACES as Xt, localEndpointPath as Y, strandedMb as Yt, encodeFrame as Z, HEAP_SPACE_REPORT_MIN_MB as Zt, AddonDepsManager as _, createEventPlaneReader as _n, CapabilityUnavailableError as _t, createUdsAddonContext as a, createSocketPlaneMeter as an, createAddonService as at, brokerCallForCap as b, resolveAddonClass as bt, getOrInitReadinessRegistryForClient as c, formatSocketPlane as cn, capActionName as ct, clusterEventTopic as d, recordSocketFrame as dn, capServiceName as dt, heapSpaceField as en, createBrokerDeviceManagerApi as et, getBrokerEventBus as f, sampleSocketDirection as fn, parseCapAction as ft, subscribePassthrough as g, createEventPlaneMeter as gn, CapabilityHandle as gt, setNodeEventInterest as h, EVENT_PLANE_TOP_N as hn, DeviceRegistry as ht, createAddonContext as i, SOCKET_PLANE_TOP_N as in, setWorkerNativeCapsChangeListener as it, LocalChildClient as j, HUB_RSS_BUDGET_ENV as jt, createUdsLogger as k, HUB_MAIN_HEAP_WATCH_LABEL as kt, setHubConnected as l, EMPTY_SOCKET_DIRECTION as ln, capActionSuffix as lt, registerEventBusService as m, socketDirectionMessages as mn, serializeTypedArrays as mt, installManifestPythonDeps as n, selectReportedSpaces as nn, getWorkerNativeCapSnapshot as nt, getWorkerDeviceRegistry as o, createSocketPlaneReader as on, validateProviderRegistrations as ot, getMoleculerEventStats as p, socketDirectionBytes as pn, deserializeTypedArrays as pt, UdsLocalTransportServer as q, startHeapWatch as qt, adaptBrokerToCluster as r, EMPTY_SOCKET_PLANE_BASELINE as rn, mountNativeCapService as rt, getOrInitReadinessRegistry as s, diffSocketPlane as sn, NATIVE_PROVIDER_SERVICE_INFIX as st, installManifestSystemDeps as t, readHeapSpaces as tn, getWorkerNativeCapProvider as tt, EVENT_TOPIC_PREFIX as u, createSocketDirectionCounters as un, capBareAction as ut, createKernelHwAccel as v, diffEventPlane as vn, copyBundledNativeModules as vt, localProviderLink as w, HEAP_RECLAIM_MIN_INTERVAL_MS as wt, brokerTransportLink as x, resolveNpmInvocation as xt, resolveHwAccel as y, formatEventPlane as yn, installManifestNativeDeps as yt, callWithServiceDiscovery as z, createLoopDelayMeter as zt };
10705
+ export { buildUdsNativeCapProxy as $, ARRAY_BUFFER_RELEASE_MIN_DROP_RATIO as $t, createUdsLoggerWithControl as A, sampleSocketDirection as An, HUB_MAIN_RSS_BUDGET_MB as At, readMoleculerFanoutMode as B, createV8Reclaimer as Bt, ipcParentLink as C, createSocketPlaneReader as Cn, createAddonDataPlaneFacility as Ct, createUdsEventBus as D, EMPTY_SOCKET_DIRECTION as Dn, HEAP_WATCH_INTERVAL_MS as Dt, createUdsEventBridge as E, readSocketQueues as En, HEAP_RECLAIM_TRIGGER_MB as Et, CapRouteError as F, createEventPlaneReader as Fn, RUNNER_RSS_BUDGET_ENV as Ft, createLocalTransport as G, reclaimIntervalMs as Gt, PARENT_ROUTED_PROVIDER_ADDON_ID as H, hubMainRssBudget as Ht, classifyCapRoute as I, diffEventPlane as In, ZERO_LOOP_DELAY as It, SocketChannel as J, startRunnerHeapWatch as Jt, UdsLocalTransportClient as K, shouldReclaim as Kt, LocalChildClient as L, formatEventPlane as Ln, buildHeapSample as Lt, AGENT_CAP_FWD_SERVICE as M, socketDirectionMessages as Mn, RSS_BUDGET_REANNOUNCE_MIN_MS as Mt, CapRouteResolver as N, EVENT_PLANE_TOP_N as Nn, RSS_BUDGET_RELEASE_RATIO as Nt, udsChildLogToWorkerEntry as O, createSocketDirectionCounters as On, HEAP_WATCH_WARN_RATIO as Ot, callWithServiceDiscovery as P, createEventPlaneMeter as Pn, RUNNER_HEAP_WATCH_INTERVAL_MS as Pt, buildNativeCapProxy as Q, ARRAY_BUFFER_CENSUS_THRESHOLD_MB as Qt, LocalChildRegistry as R, createDeferredHeapWatchSink as Rt, buildLinkChain as S, createSocketPlaneMeter as Sn, runNpm as St, createParentUnownedCallHandler as T, formatSocketPlane as Tn, HEAP_RECLAIM_STEADY_STATE_INTERVAL_MS as Tt, __resetCapUsageRegistryForTests as U, nextRssBudgetState as Ut, CapUsageRegistry as V, describeRss as Vt, getCapUsageRegistry as W, parseRssBudgetMb as Wt, FrameDecoder as X, ARRAY_BUFFER_CENSUS_COOLDOWN_MS as Xt, localEndpointPath as Y, strandedMb as Yt, encodeFrame as Z, ARRAY_BUFFER_CENSUS_SUSTAIN_MS as Zt, AddonDepsManager as _, createMeshQueueReader as _n, CapabilityUnavailableError as _t, createUdsAddonContext as a, HEAP_SPACE_REPORT_MIN_MB as an, createAddonService as at, brokerCallForCap as b, SOCKET_PLANE_TOP_N as bn, resolveAddonClass as bt, getOrInitReadinessRegistryForClient as c, heapSpaceField as cn, capActionName as ct, clusterEventTopic as d, OUTBOUND_QUEUE_COOLDOWN_MS as dn, capServiceName as dt, createArrayBufferCensusGate as en, createBrokerDeviceManagerApi as et, getBrokerEventBus as f, OUTBOUND_QUEUE_SUSTAIN_MS as fn, parseCapAction as ft, subscribePassthrough as g, MESH_QUEUE_TOP_N as gn, CapabilityHandle as gt, setNodeEventInterest as h, formatOutboundQueueVerdict as hn, DeviceRegistry as ht, createAddonContext as i, DECISIVE_HEAP_SPACES as in, setWorkerNativeCapsChangeListener as it, AGENT_CAP_FWD_ACTION as j, socketDirectionBytes as jn, HUB_RSS_BUDGET_ENV as jt, createUdsLogger as k, recordSocketFrame as kn, HUB_MAIN_HEAP_WATCH_LABEL as kt, setHubConnected as l, readHeapSpaces as ln, capActionSuffix as lt, registerEventBusService as m, createOutboundQueueGate as mn, serializeTypedArrays as mt, installManifestPythonDeps as n, formatArrayBufferCensus as nn, getWorkerNativeCapSnapshot as nt, getWorkerDeviceRegistry as o, emitHeapDiagnosticReport as on, validateProviderRegistrations as ot, getMoleculerEventStats as p, OUTBOUND_QUEUE_THRESHOLD_BYTES as pn, deserializeTypedArrays as pt, UdsLocalTransportServer as q, startHeapWatch as qt, adaptBrokerToCluster as r, isArrayBufferCensusReport as rn, mountNativeCapService as rt, getOrInitReadinessRegistry as s, formatHeapSpaces as sn, NATIVE_PROVIDER_SERVICE_INFIX as st, installManifestSystemDeps as t, createInspectorArrayBufferCensus as tn, getWorkerNativeCapProvider as tt, EVENT_TOPIC_PREFIX as u, selectReportedSpaces as un, capBareAction as ut, createKernelHwAccel as v, formatMeshQueue as vn, copyBundledNativeModules as vt, localProviderLink as w, diffSocketPlane as wn, HEAP_RECLAIM_MIN_INTERVAL_MS as wt, brokerTransportLink as x, SOCKET_QUEUE_TOP_N as xn, resolveNpmInvocation as xt, resolveHwAccel as y, EMPTY_SOCKET_PLANE_BASELINE as yn, installManifestNativeDeps as yt, UDS_NO_ROUTE_PREFIX as z, createLoopDelayMeter as zt };