@camstack/addon-pipeline 1.2.364 → 1.2.366

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 (35) hide show
  1. package/dist/audio-analyzer/index.js +3 -3
  2. package/dist/audio-analyzer/index.mjs +3 -3
  3. package/dist/{custom-model-catalog-nx2TXAGS.js → custom-model-catalog-C34VqcPo.js} +2 -2
  4. package/dist/{custom-model-catalog-DHFPSu0E.mjs → custom-model-catalog-CtpK3FKL.mjs} +2 -2
  5. package/dist/detection-pipeline/index.js +4 -4
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-BX9VTm12.mjs → dist-BshC0NZR.mjs} +411 -291
  8. package/dist/{dist-CGOb--t-.js → dist-i4kS_3O7.js} +411 -291
  9. package/dist/{model-catalogs-DHV48cdY.js → model-catalogs-Cy7mZRWh.js} +1 -1
  10. package/dist/{model-catalogs-DNL4ydAg.mjs → model-catalogs-DNx5ATgm.mjs} +1 -1
  11. package/dist/motion-wasm/index.js +1 -1
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/{node-C8bbmV3c.mjs → node-Dqmrb990.mjs} +1 -1
  14. package/dist/{node-UBMRK8SK.js → node-TzgGsCvW.js} +1 -1
  15. package/dist/pipeline-runner/index.js +3 -3
  16. package/dist/pipeline-runner/index.mjs +3 -3
  17. package/dist/{process-memory-1BwIq2Ks.js → process-memory-DLCPFTGC.js} +1 -1
  18. package/dist/{process-memory-BHG2uCeq.mjs → process-memory-DyfrbFY4.mjs} +1 -1
  19. package/dist/recorder/index.js +2 -2
  20. package/dist/recorder/index.mjs +2 -2
  21. package/dist/{segment-demux-js-PkDP-xQg.js → segment-demux-js-C9AYdKkD.js} +1 -1
  22. package/dist/{segment-demux-js-BhnT__dA.mjs → segment-demux-js-COoPqx93.mjs} +1 -1
  23. package/dist/stream-broker/_stub.js +2 -2
  24. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-vdmrukBk.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DVgnss_e.mjs} +2 -2
  25. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DWI1BCfa.mjs +26 -0
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BUBmyDde.mjs +26 -0
  27. package/dist/stream-broker/demux-worker-child.js +1 -1
  28. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  29. package/dist/stream-broker/{hostInit-D_DBY5wu.mjs → hostInit-ZvA7Cl_1.mjs} +2 -2
  30. package/dist/stream-broker/index.js +641 -206
  31. package/dist/stream-broker/index.mjs +641 -206
  32. package/dist/stream-broker/remoteEntry.js +1 -1
  33. package/package.json +4 -2
  34. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-ScPS8ZWM.mjs +0 -26
  35. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Cq5NPjBX.mjs +0 -26
@@ -1,10 +1,10 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { $n as discriminatedUnion, An as BaseAddon, D as G711_SCALE_CORRECTION_DB, Dn as DeviceType, Dt as gifDeriveSpeed, En as DeviceFeature, Gn as maskUrlCredentials, Hn as isEvent, I as RATE_CONTROL_RELAXED, In as createEvent, J as addonWidgetsSourceCapability, Kn as nodePin, L as RATE_CONTROL_TIGHT, Mn as CamProfileSchema, Nt as loadContributionCapability, On as errMsg, Ot as gifFromMp4Args, Pt as logChannelsCapability, Qn as boolean, Rt as memoizePinnedFfmpeg, Sn as buildFfmpegArgs, Tn as isSoftwareDecode, Tt as ffmpegRate, Un as makeProfileBrokerId, W as RingBuffer, Wn as makeSourceBrokerId, Xn as _enum, Xt as rankStreams, Zn as array, _t as egressTranscodeSharingKey, ar as record, at as clipExportFailureOf, c as CLIP_STILL_REASON_CODE_HEADER, cr as EventCategory, ct as createHwAccelCache, et as batteryCapability, ft as deriveBatteryPresence, jn as CAM_PROFILE_ORDER, kn as BOOT_RECOVERY_BACKOFF_MS, lt as createLogChannelsProvider, mn as streamBrokerCapability, nr as number, o as BatteryStatusSchema, or as string, qn as parseProfileBrokerId, rn as resolveEgressDecodeHwAccel, rr as object, rt as cameraStreamsCapability, s as CLIP_BROKER_PACED_RATES, sr as union, tr as literal, ut as declareLogChannel, vn as webrtcSessionCapability, vt as egressTransportFromRequest, w as EncodeProfileSchema, wn as invocationFromEncodeProfile, yn as AUDIO_PRESETS, zn as expandAudioChunkToF32le } from "../dist-BX9VTm12.mjs";
3
- import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-C8bbmV3c.mjs";
2
+ import { $n as discriminatedUnion, An as BaseAddon, D as G711_SCALE_CORRECTION_DB, Dn as DeviceType, Dt as gifDeriveSpeed, En as DeviceFeature, Gn as maskUrlCredentials, Hn as isEvent, I as RATE_CONTROL_RELAXED, In as createEvent, J as addonWidgetsSourceCapability, Kn as nodePin, L as RATE_CONTROL_TIGHT, Mn as CamProfileSchema, Nt as loadContributionCapability, On as errMsg, Ot as gifFromMp4Args, Pt as logChannelsCapability, Qn as boolean, Rt as memoizePinnedFfmpeg, Sn as buildFfmpegArgs, Tn as isSoftwareDecode, Tt as ffmpegRate, Un as makeProfileBrokerId, W as RingBuffer, Wn as makeSourceBrokerId, Xn as _enum, Xt as rankStreams, Zn as array, _t as egressTranscodeSharingKey, ar as record, at as clipExportFailureOf, c as CLIP_STILL_REASON_CODE_HEADER, cr as EventCategory, ct as createHwAccelCache, et as batteryCapability, ft as deriveBatteryPresence, jn as CAM_PROFILE_ORDER, kn as BOOT_RECOVERY_BACKOFF_MS, lt as createLogChannelsProvider, mn as streamBrokerCapability, nr as number, o as BatteryStatusSchema, or as string, qn as parseProfileBrokerId, rn as resolveEgressDecodeHwAccel, rr as object, rt as cameraStreamsCapability, s as CLIP_BROKER_PACED_RATES, sr as union, tr as literal, ut as declareLogChannel, vn as webrtcSessionCapability, vt as egressTransportFromRequest, w as EncodeProfileSchema, wn as invocationFromEncodeProfile, yn as AUDIO_PRESETS, zn as expandAudioChunkToF32le } from "../dist-BshC0NZR.mjs";
3
+ import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-Dqmrb990.mjs";
4
4
  import { a as parseRestreamPath, d as PREBUFFER_MAX_MS, f as capPreRollRequestMs, i as isLiveEdgeOnlyDial, m as resolvePrebuffer, n as NO_DECLARATION, p as prebufferRetentionMs, s as sdpVariantFor, u as withSentryIntent } from "../restream-intent-DyGmLAU9.mjs";
5
5
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
6
6
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
7
- import { a as require_mp4box_all, c as parseNalLengthSize, i as runFfmpegOnce, n as demuxSegmentPreferJs, o as avccSampleToAnnexB, r as FfmpegFallbackRefusedError, s as buildKeyframeAnnexB, t as demuxSegmentFullJs } from "../segment-demux-js-BhnT__dA.mjs";
7
+ import { a as require_mp4box_all, c as parseNalLengthSize, i as runFfmpegOnce, n as demuxSegmentPreferJs, o as avccSampleToAnnexB, r as FfmpegFallbackRefusedError, s as buildKeyframeAnnexB, t as demuxSegmentFullJs } from "../segment-demux-js-COoPqx93.mjs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as os$1 from "node:os";
10
10
  import { networkInterfaces, tmpdir } from "node:os";
@@ -3081,6 +3081,262 @@ var RtpPacketizer = class {
3081
3081
  }
3082
3082
  };
3083
3083
  //#endregion
3084
+ //#region src/stream-broker/stream-broker/broker-mem-probe.ts
3085
+ /** Cadence of the `[broker-mem]` line — aligned with the runner's `[mem]`. */
3086
+ var BROKER_MEM_INTERVAL_MS = 5 * 6e4;
3087
+ var copiedPackets = 0;
3088
+ var copiedBytes = 0;
3089
+ /** O(1) per packet: called by `copyOwned`. No allocation. */
3090
+ function recordCopyOwned(bytes) {
3091
+ copiedPackets += 1;
3092
+ copiedBytes += bytes;
3093
+ }
3094
+ function readCopyOwnedTotals() {
3095
+ return {
3096
+ packets: copiedPackets,
3097
+ bytes: copiedBytes
3098
+ };
3099
+ }
3100
+ /** `RssAnon:` / `RssFile:` out of `/proc/self/status` text; null when absent. */
3101
+ function parseProcStatus(text) {
3102
+ const anon = /^RssAnon:\s+(\d+)\s*kB/m.exec(text);
3103
+ const file = /^RssFile:\s+(\d+)\s*kB/m.exec(text);
3104
+ if (anon === null || file === null) return null;
3105
+ return {
3106
+ rssAnonKb: Number(anon[1]),
3107
+ rssFileKb: Number(file[1])
3108
+ };
3109
+ }
3110
+ /**
3111
+ * Summarise `/proc/self/maps`. Cheaper than `smaps_rollup`'s kernel page walk:
3112
+ * the file is one line per mapping and needs no per-page accounting. The
3113
+ * `[heap]` figure is the brk extent (virtual), which is the number glibc's
3114
+ * main arena grows when fragmented.
3115
+ *
3116
+ * Line shape: `start-end perms offset dev inode [path]` — an anonymous mapping
3117
+ * has no sixth field.
3118
+ */
3119
+ function parseProcMaps(text) {
3120
+ let heapBytes = 0;
3121
+ let anonMappings = 0;
3122
+ let anonBytes = 0;
3123
+ let from = 0;
3124
+ while (from < text.length) {
3125
+ let to = text.indexOf("\n", from);
3126
+ if (to === -1) to = text.length;
3127
+ const line = text.slice(from, to);
3128
+ from = to + 1;
3129
+ const dash = line.indexOf("-");
3130
+ const space = line.indexOf(" ");
3131
+ if (dash <= 0 || space <= dash) continue;
3132
+ const size = Number.parseInt(line.slice(dash + 1, space), 16) - Number.parseInt(line.slice(0, dash), 16);
3133
+ if (!Number.isFinite(size)) continue;
3134
+ const path = line.split(/\s+/, 6)[5];
3135
+ if (path === void 0 || path === "") {
3136
+ anonMappings += 1;
3137
+ anonBytes += size;
3138
+ } else if (path === "[heap]") heapBytes += size;
3139
+ }
3140
+ return {
3141
+ heapBytes,
3142
+ anonMappings,
3143
+ anonBytes
3144
+ };
3145
+ }
3146
+ /** Opened/closed consumers since the last reading; `drain()` resets. */
3147
+ var ConsumerChurn = class {
3148
+ opened = 0;
3149
+ closed = 0;
3150
+ openedByKind = /* @__PURE__ */ new Map();
3151
+ closedByKind = /* @__PURE__ */ new Map();
3152
+ recordOpen(kind) {
3153
+ this.opened += 1;
3154
+ this.openedByKind.set(kind, (this.openedByKind.get(kind) ?? 0) + 1);
3155
+ }
3156
+ recordClose(kind) {
3157
+ this.closed += 1;
3158
+ this.closedByKind.set(kind, (this.closedByKind.get(kind) ?? 0) + 1);
3159
+ }
3160
+ drain() {
3161
+ const reading = {
3162
+ opened: this.opened,
3163
+ closed: this.closed,
3164
+ openedByKind: Object.fromEntries(this.openedByKind),
3165
+ closedByKind: Object.fromEntries(this.closedByKind)
3166
+ };
3167
+ this.opened = 0;
3168
+ this.closed = 0;
3169
+ this.openedByKind = /* @__PURE__ */ new Map();
3170
+ this.closedByKind = /* @__PURE__ */ new Map();
3171
+ return reading;
3172
+ }
3173
+ };
3174
+ /** The one churn counter of this process (the broker runner). */
3175
+ var consumerChurn = new ConsumerChurn();
3176
+ /**
3177
+ * Sliding-window limiter for the attach/detach lines. `admit(now)` answers
3178
+ * whether this line may be written; the number of lines swallowed since the
3179
+ * last admitted one is returned with it so the next line can say so (a branch
3180
+ * that drops work names it).
3181
+ */
3182
+ function createLineLimiter(maxPerWindow, windowMs) {
3183
+ let windowStart = 0;
3184
+ let inWindow = 0;
3185
+ let suppressed = 0;
3186
+ return (now) => {
3187
+ if (now - windowStart >= windowMs) {
3188
+ windowStart = now;
3189
+ inWindow = 0;
3190
+ }
3191
+ if (inWindow >= maxPerWindow) {
3192
+ suppressed += 1;
3193
+ return {
3194
+ admit: false,
3195
+ suppressedBefore: 0
3196
+ };
3197
+ }
3198
+ inWindow += 1;
3199
+ const suppressedBefore = suppressed;
3200
+ suppressed = 0;
3201
+ return {
3202
+ admit: true,
3203
+ suppressedBefore
3204
+ };
3205
+ };
3206
+ }
3207
+ var consumerLineLimiter = createLineLimiter(40, 6e4);
3208
+ /**
3209
+ * One consumer attached to / detached from a broker: counted always (the
3210
+ * `[broker-mem]` line reports opened/closed since the last line), written at
3211
+ * info under a rate limit so a step in RSS can be matched to an event. The
3212
+ * line after a suppressed burst says how many were swallowed.
3213
+ */
3214
+ function reportConsumerEvent(logger, event, kind, deviceId, total, now = Date.now()) {
3215
+ if (event === "attach") consumerChurn.recordOpen(kind);
3216
+ else consumerChurn.recordClose(kind);
3217
+ if (logger === void 0 || logger === null) return;
3218
+ const verdict = consumerLineLimiter(now);
3219
+ if (!verdict.admit) return;
3220
+ const suppressed = verdict.suppressedBefore > 0 ? ` suppressedBefore=${verdict.suppressedBefore}` : "";
3221
+ logger.info(`consumer ${event} kind=${kind} total=${total}${suppressed}`, { ...deviceId !== void 0 ? { tags: { deviceId } } : {} });
3222
+ }
3223
+ var MB = 1024 * 1024;
3224
+ function ringBytesOf(s) {
3225
+ return s.preBufferBytes + s.clipBytes + s.rtpRingBytes + s.keyframeCacheBytes;
3226
+ }
3227
+ function mb(bytes) {
3228
+ return (bytes / MB).toFixed(1);
3229
+ }
3230
+ function kindList(counts) {
3231
+ const keys = Object.keys(counts).sort();
3232
+ return keys.length === 0 ? "none" : keys.map((k) => `${k}:${counts[k]}`).join(",");
3233
+ }
3234
+ /** One greppable `key=value` line; no per-device breakdown beyond the top N. */
3235
+ function formatBrokerMem(input) {
3236
+ const { samples, proc, churn, copied } = input;
3237
+ const consumers = {};
3238
+ let rtsp = 0;
3239
+ let audio = 0;
3240
+ let pipe = 0;
3241
+ let ingesting = 0;
3242
+ let planes = 0;
3243
+ let pre = 0;
3244
+ let clip = 0;
3245
+ let rtp = 0;
3246
+ let kf = 0;
3247
+ let qSum = 0;
3248
+ let qMax = 0;
3249
+ for (const s of samples) {
3250
+ if (s.ingesting) ingesting += 1;
3251
+ if (s.annexBPlaneActive) planes += 1;
3252
+ rtsp += s.rtspSessions;
3253
+ audio += s.audioSubscribers;
3254
+ pipe += s.pipeClients;
3255
+ pre += s.preBufferBytes;
3256
+ clip += s.clipBytes;
3257
+ rtp += s.rtpRingBytes;
3258
+ kf += s.keyframeCacheBytes;
3259
+ qSum += s.writeQueueSumBytes;
3260
+ if (s.writeQueueMaxBytes > qMax) qMax = s.writeQueueMaxBytes;
3261
+ for (const [kind, n] of Object.entries(s.encodedByKind)) consumers[kind] = (consumers[kind] ?? 0) + n;
3262
+ }
3263
+ const top = [...samples].filter((s) => s.deviceId !== void 0).sort((a, b) => ringBytesOf(b) - ringBytesOf(a)).slice(0, 5).map((s) => `${s.deviceId}:${mb(ringBytesOf(s))}`).join(",");
3264
+ const parts = ["[broker-mem]"];
3265
+ if (proc.status !== null) parts.push(`rssAnonMb=${(proc.status.rssAnonKb / 1024).toFixed(0)}`, `rssFileMb=${(proc.status.rssFileKb / 1024).toFixed(0)}`);
3266
+ if (proc.maps !== null) parts.push(`heapMapMb=${mb(proc.maps.heapBytes)}`, `anonMaps=${proc.maps.anonMappings}`, `anonMapsMb=${mb(proc.maps.anonBytes)}`);
3267
+ parts.push(`brokers=${samples.length}`, `ingesting=${ingesting}`, `annexBPlanes=${planes}`, `rtsp=${rtsp}`, `webrtc=${input.webrtcSessions}`, `encoded=${kindList(consumers)}`, `audioSubs=${audio}`, `pipeClients=${pipe}`, `egress=${input.egressTranscodes}`, `children=${input.childProcesses}`, `opened=${churn.opened}`, `closed=${churn.closed}`, `openedKinds=${kindList(churn.openedByKind)}`, `closedKinds=${kindList(churn.closedByKind)}`, `preBufferMb=${mb(pre)}`, `clipMb=${mb(clip)}`, `rtpRingMb=${mb(rtp)}`, `keyframeMb=${mb(kf)}`, `writeQueueSumMb=${mb(qSum)}`, `writeQueueMaxMb=${mb(qMax)}`, `copiedPackets=${copied.packets}`, `copiedMb=${mb(copied.bytes)}`, `topRingMb=${top === "" ? "none" : top}`);
3268
+ return parts.join(" ");
3269
+ }
3270
+ /** Count entries named `name` in `process.getActiveResourcesInfo()`. */
3271
+ function countActiveResources(resources, name) {
3272
+ let n = 0;
3273
+ for (const r of resources) if (r === name) n += 1;
3274
+ return n;
3275
+ }
3276
+ //#endregion
3277
+ //#region src/stream-broker/stream-broker/owned-buffer.ts
3278
+ /**
3279
+ * Buffers that enter a LONG-LIVED ring own their backing store.
3280
+ *
3281
+ * Node serves `Buffer.from(...)`, `Buffer.allocUnsafe(n)` and `Buffer.concat`
3282
+ * below `Buffer.poolSize >>> 1` (4 KiB) as views of shared 8 KiB pool slabs.
3283
+ * That is the right choice for the ingest path — a start code, a 4-byte
3284
+ * interleave header, a 1.3 KB RTP packet that is fanned out and forgotten
3285
+ * within a tick — and the wrong one for anything RETAINED: a 600-byte NAL kept
3286
+ * for 60 s in a clip ring pins its whole slab, and with it every short-lived
3287
+ * packet that happened to be allocated beside it. On the hub that held
3288
+ * 550–980 MB of ArrayBuffers for ~150–250 MB of payload, and the full GCs the
3289
+ * pinned slabs caused ran every 0.2–0.5 s at 40–65 % of a core.
3290
+ *
3291
+ * The ring byte caps counted payload bytes, so they never saw it. Both rings
3292
+ * now pass every entry through {@link detachFromPool} on the way in and
3293
+ * account it with {@link backingStoreBytes}, so the cap measures what the
3294
+ * entry costs. Short-lived traffic stays pooled.
3295
+ */
3296
+ /** True when `buf` is the whole of its ArrayBuffer — nothing else shares it. */
3297
+ function ownsBackingStore(buf) {
3298
+ return buf.byteOffset === 0 && buf.buffer.byteLength === buf.length;
3299
+ }
3300
+ /**
3301
+ * What retaining `buf` really costs: its own length when it owns its
3302
+ * ArrayBuffer, the whole shared slab when it is a view of one.
3303
+ */
3304
+ function backingStoreBytes(buf) {
3305
+ return ownsBackingStore(buf) ? buf.length : buf.buffer.byteLength;
3306
+ }
3307
+ /**
3308
+ * `buf` itself when it already owns its ArrayBuffer (decided by inspecting the
3309
+ * buffer, never by its size: whether a large `allocUnsafe` is pooled is a Node
3310
+ * runtime detail), else a copy that does. One copy, never two: a buffer that is
3311
+ * already unpooled is returned as-is.
3312
+ */
3313
+ function detachFromPool(buf) {
3314
+ if (ownsBackingStore(buf)) return buf;
3315
+ const owned = Buffer.allocUnsafeSlow(buf.length);
3316
+ buf.copy(owned);
3317
+ return owned;
3318
+ }
3319
+ /**
3320
+ * A copy of `src[start, end)` that OWNS its backing store — the shape a reader
3321
+ * hands out when the packet it cuts from its socket buffer is going to be
3322
+ * retained anyway.
3323
+ *
3324
+ * Every source video packet is: `captureRtpForPreBuffer` keeps it for the life
3325
+ * of a GOP, and the restreamer's keyframe cache keeps the keyframe ones. With
3326
+ * the readers cutting packets as `Buffer.from(subarray)` — a pooled view — the
3327
+ * ring then had to copy each one a second time to own it: two copies of every
3328
+ * packet on the ingest path (measured 15.1 + 14.8 MB/s on the hub's load).
3329
+ * Cutting the packet owned up front makes the ring's and the cache's
3330
+ * `detachFromPool` an identity, so the packet is copied once and shared by the
3331
+ * reader's fan-out, the ring, the cache and every session's write queue.
3332
+ */
3333
+ function copyOwned(src, start, end) {
3334
+ const owned = Buffer.allocUnsafeSlow(end - start);
3335
+ src.copy(owned, 0, start, end);
3336
+ recordCopyOwned(end - start);
3337
+ return owned;
3338
+ }
3339
+ //#endregion
3084
3340
  //#region src/stream-broker/rtsp/sdp-builder.ts
3085
3341
  function buildSdp(options) {
3086
3342
  const { codec, parameterSets, streamName } = options;
@@ -3690,6 +3946,26 @@ var RtspRestreamer = class {
3690
3946
  for (const session of this.sessions.values()) if (session.isPrestreamIntent()) n += 1;
3691
3947
  return n;
3692
3948
  }
3949
+ /**
3950
+ * What this restreamer holds, for the `[broker-mem]` probe: live sessions,
3951
+ * their socket write queues (sum and max) and the keyframe caches. One pass
3952
+ * over the sessions, no allocation beyond the result.
3953
+ */
3954
+ getMemProbe() {
3955
+ let sum = 0;
3956
+ let max = 0;
3957
+ for (const s of this.sessions.values()) {
3958
+ const q = s.getWriteQueueBytes();
3959
+ sum += q;
3960
+ if (q > max) max = q;
3961
+ }
3962
+ return {
3963
+ sessions: this.sessions.size,
3964
+ writeQueueSumBytes: sum,
3965
+ writeQueueMaxBytes: max,
3966
+ keyframeCacheBytes: this.lastKeyframeNalBytes + this.lastKeyframeRtpBytes
3967
+ };
3968
+ }
3693
3969
  /** Per-session diagnostic snapshots consumed by `StreamBroker.listClients`. */
3694
3970
  listSessionInfos() {
3695
3971
  return [...this.sessions.values()].map((s) => s.getInfo());
@@ -3701,6 +3977,7 @@ var RtspRestreamer = class {
3701
3977
  streamName: this.streamName,
3702
3978
  total: this.sessions.size
3703
3979
  } });
3980
+ reportConsumerEvent(this.logger, "attach", "rtsp", this.deviceId, this.sessions.size);
3704
3981
  this._onSessionCountChanged?.();
3705
3982
  this.reconcileSentryAudio();
3706
3983
  if (this._rtpPassthroughMode) setTimeout(() => this.servePendingSessionsRtp(), 0);
@@ -3751,7 +4028,7 @@ var RtspRestreamer = class {
3751
4028
  if (this.collectingKeyframeRtp && !isKeyframe && !isParameterSet) this.collectingKeyframeRtp = false;
3752
4029
  if (this.collectingKeyframeRtp) {
3753
4030
  this.lastKeyframeRtpPackets.push({
3754
- data: Buffer.from(rtpData),
4031
+ data: detachFromPool(rtpData),
3755
4032
  marker: rtpPacket.marker
3756
4033
  });
3757
4034
  this.lastKeyframeRtpBytes += rtpData.length;
@@ -4165,6 +4442,7 @@ var RtspRestreamer = class {
4165
4442
  streamName: this.streamName,
4166
4443
  total: this.sessions.size
4167
4444
  } });
4445
+ reportConsumerEvent(this.logger, "detach", "rtsp", this.deviceId, this.sessions.size);
4168
4446
  this._onSessionCountChanged?.();
4169
4447
  this.reconcileSentryAudio();
4170
4448
  }
@@ -4353,6 +4631,19 @@ var RtspSession = class {
4353
4631
  lastRtpAt = 0;
4354
4632
  /** Total video RTP bytes written to the socket. */
4355
4633
  bytesSent = 0;
4634
+ /**
4635
+ * The socket is corked for the access unit in progress — see
4636
+ * {@link writeInterleaved}. Cleared by the packet that ends the picture (RTP
4637
+ * marker, or any audio packet) or by the next-tick safety net, whichever
4638
+ * comes first.
4639
+ */
4640
+ accessUnitCorked = false;
4641
+ /** Flush the access unit in progress. Idempotent: the marker and the safety net both call it. */
4642
+ uncorkAccessUnit = () => {
4643
+ if (!this.accessUnitCorked) return;
4644
+ this.accessUnitCorked = false;
4645
+ this.socket.uncork();
4646
+ };
4356
4647
  /** Client `User-Agent` (from the first request that carries one). Lets the
4357
4648
  * broker panel identify a consumer by purpose — e.g. the recorder sets
4358
4649
  * `-user_agent recorder` on its RTSP pull — instead of a bare address. */
@@ -4421,6 +4712,10 @@ var RtspSession = class {
4421
4712
  this.onClose?.();
4422
4713
  });
4423
4714
  }
4715
+ /** Bytes the socket has accepted but not yet flushed (corked bytes included). */
4716
+ getWriteQueueBytes() {
4717
+ return this.socket.writableLength;
4718
+ }
4424
4719
  getSessionId() {
4425
4720
  return this.sessionId;
4426
4721
  }
@@ -4600,7 +4895,7 @@ var RtspSession = class {
4600
4895
  return "dropped-backlog";
4601
4896
  }
4602
4897
  }
4603
- this.writeInterleaved(this.tracks[0].rtpChannel, packet.data);
4898
+ this.writeInterleaved(this.tracks[0].rtpChannel, packet.data, packet.marker);
4604
4899
  this.missedKeyframes = 0;
4605
4900
  return "written";
4606
4901
  }
@@ -4609,7 +4904,7 @@ var RtspSession = class {
4609
4904
  if (!this.playing || this.closed || this.muted) return "not-playing";
4610
4905
  if (this.tracks.length < 2) return "not-playing";
4611
4906
  if (this.socket.writableLength > 2097152) return "dropped-backlog";
4612
- this.writeInterleaved(this.tracks[1].rtpChannel, packet.data);
4907
+ this.writeInterleaved(this.tracks[1].rtpChannel, packet.data, true);
4613
4908
  return "written";
4614
4909
  }
4615
4910
  /**
@@ -4631,14 +4926,13 @@ var RtspSession = class {
4631
4926
  * ## Why sharing the payload is safe, and what would make it unsafe
4632
4927
  *
4633
4928
  * The payload buffer handed here is a private allocation per packet —
4634
- * `Buffer.from(readBuffer.subarray(...))` in `rtsp-client.ts`'s `onData`, for
4635
- * video and audio alike — never a view of the reader's socket buffer, which
4636
- * advances on the next read. Nothing in the broker writes into a captured
4637
- * packet: every mutation in this path targets a freshly allocated buffer.
4638
- * (The broker's `rtpRing` keeps its OWN copy of a pooled packet —
4639
- * `detachFromPool`, so a ring entry never pins a shared pool slab — so the
4640
- * ring no longer vouches for THIS object's lifetime, and it does not need
4641
- * to: a pending write holds its own reference, and the rule below is about
4929
+ * `copyOwned(readBuffer, …)` in the readers' `onData`, for video and audio
4930
+ * alike — never a view of the reader's socket buffer, which advances on the
4931
+ * next read. Nothing in the broker writes into a captured packet: every
4932
+ * mutation in this path targets a freshly allocated buffer. (The same object
4933
+ * is what the broker's `rtpRing` and the restreamer's keyframe cache retain —
4934
+ * it owns its backing store, so `detachFromPool` keeps it as is — and that is
4935
+ * fine: a pending write holds its own reference, and the rule below is about
4642
4936
  * mutation, not retention.)
4643
4937
  *
4644
4938
  * The rule to keep: **`socket.write` forbids MUTATION before the write
@@ -4648,18 +4942,36 @@ var RtspSession = class {
4648
4942
  * queued. Four bytes from the Buffer pool is a pointer bump; the copy this
4649
4943
  * replaces was ~1.4 KB.
4650
4944
  *
4651
- * `cork`/`uncork` keeps the pair a single `writev` syscall, so removing the
4652
- * copy does not buy an extra write syscall per packet.
4945
+ * ## One cork per ACCESS UNIT, not per packet
4946
+ *
4947
+ * The pair used to be corked and uncorked on its own: one `writev` per RTP
4948
+ * packet per session, so a 4K keyframe fragmented into a hundred packets and
4949
+ * fanned out to three sessions was three hundred syscalls for one picture no
4950
+ * decoder can use before it is whole. The socket is corked on the first write
4951
+ * of an access unit and uncorked by the packet that ENDS it
4952
+ * (`endOfAccessUnit`: the RTP marker on video, every audio packet), so the
4953
+ * picture leaves in one `writev`. A `process.nextTick` safety net uncorks
4954
+ * whatever is still open before the tick yields to I/O — a marker-less camera
4955
+ * or a lost marker degrades to one `writev` per tick, never to a socket held
4956
+ * corked across a read.
4957
+ *
4958
+ * Backpressure sees through the cork: `writableLength` counts corked bytes,
4959
+ * so the `MAX_WRITE_BUFFER` gate, the latch and the missed-keyframe close
4960
+ * (D447) judge exactly what they judged per packet.
4653
4961
  */
4654
- writeInterleaved(channel, payload) {
4962
+ writeInterleaved(channel, payload, endOfAccessUnit) {
4655
4963
  const header = Buffer.allocUnsafe(4);
4656
4964
  header[0] = 36;
4657
4965
  header[1] = channel;
4658
4966
  header.writeUInt16BE(payload.length, 2);
4659
- this.socket.cork();
4967
+ if (!this.accessUnitCorked) {
4968
+ this.accessUnitCorked = true;
4969
+ this.socket.cork();
4970
+ process.nextTick(this.uncorkAccessUnit);
4971
+ }
4660
4972
  this.socket.write(header);
4661
4973
  this.socket.write(payload);
4662
- this.socket.uncork();
4974
+ if (endOfAccessUnit) this.uncorkAccessUnit();
4663
4975
  this.lastRtpAt = Date.now();
4664
4976
  this.bytesSent += 4 + payload.length;
4665
4977
  }
@@ -6357,6 +6669,10 @@ var EgressTranscodeManager = class {
6357
6669
  /** In-flight starts, keyed by sharing key — concurrent acquires share one. */
6358
6670
  pending = /* @__PURE__ */ new Map();
6359
6671
  releaseGraceMs;
6672
+ /** Live egress transcodes (ffmpeg children), for the `[broker-mem]` probe. */
6673
+ activeCount() {
6674
+ return this.entries.size;
6675
+ }
6360
6676
  constructor(opts) {
6361
6677
  this.opts = opts;
6362
6678
  this.releaseGraceMs = opts.releaseGraceMs ?? DEFAULT_RELEASE_GRACE_MS;
@@ -10083,7 +10399,7 @@ var Rfc4571Reader = class {
10083
10399
  while (this.readBuffer.length >= 2) {
10084
10400
  const length = this.readBuffer.readUInt16BE(0);
10085
10401
  if (this.readBuffer.length < 2 + length) return;
10086
- const rtpPacket = Buffer.from(this.readBuffer.subarray(2, 2 + length));
10402
+ const rtpPacket = copyOwned(this.readBuffer, 2, 2 + length);
10087
10403
  this.readBuffer = this.readBuffer.subarray(2 + length);
10088
10404
  this.dispatchRtp(rtpPacket);
10089
10405
  }
@@ -10144,6 +10460,93 @@ function totalFragmentBytes(fragments) {
10144
10460
  for (const fragment of fragments) total += fragment.length;
10145
10461
  return total;
10146
10462
  }
10463
+ /** H.264: single NAL by its type; STAP-A by every aggregated type; FU-A by the FU header. */
10464
+ function classifyH264Payload(payload) {
10465
+ const nalType = payload[0] & 31;
10466
+ if (nalType === H264_NAL_STAP_A) {
10467
+ let keyframe = false;
10468
+ let parameterSet = false;
10469
+ let offset = 1;
10470
+ while (offset + 2 <= payload.length) {
10471
+ const nalSize = payload.readUInt16BE(offset);
10472
+ offset += 2;
10473
+ if (nalSize === 0 || offset + nalSize > payload.length) break;
10474
+ const type = payload[offset] & 31;
10475
+ keyframe ||= type === H264_NAL_IDR;
10476
+ parameterSet ||= type === H264_NAL_SPS || type === H264_NAL_PPS;
10477
+ offset += nalSize;
10478
+ }
10479
+ return {
10480
+ keyframe,
10481
+ parameterSet
10482
+ };
10483
+ }
10484
+ if (nalType === 28) {
10485
+ if (payload.length < 2) return NO_PAYLOAD_CLASS;
10486
+ return {
10487
+ keyframe: (payload[1] & 31) === H264_NAL_IDR,
10488
+ parameterSet: false
10489
+ };
10490
+ }
10491
+ return {
10492
+ keyframe: nalType === H264_NAL_IDR,
10493
+ parameterSet: nalType === H264_NAL_SPS || nalType === H264_NAL_PPS
10494
+ };
10495
+ }
10496
+ /** H.265: single NAL by its type; AP by every aggregated type; FU by the FU header. */
10497
+ function classifyH265Payload(payload) {
10498
+ if (payload.length < 2) return NO_PAYLOAD_CLASS;
10499
+ const nalType = payload[0] >> 1 & 63;
10500
+ if (nalType === H265_NAL_AP) {
10501
+ let keyframe = false;
10502
+ let parameterSet = false;
10503
+ let offset = 2;
10504
+ while (offset + 2 <= payload.length) {
10505
+ const nalSize = payload.readUInt16BE(offset);
10506
+ offset += 2;
10507
+ if (nalSize === 0 || offset + nalSize > payload.length) break;
10508
+ const type = payload[offset] >> 1 & 63;
10509
+ keyframe ||= isH265IrapType(type);
10510
+ parameterSet ||= isH265ParameterSetType(type);
10511
+ offset += nalSize;
10512
+ }
10513
+ return {
10514
+ keyframe,
10515
+ parameterSet
10516
+ };
10517
+ }
10518
+ if (nalType === 49) {
10519
+ if (payload.length < 3) return NO_PAYLOAD_CLASS;
10520
+ return {
10521
+ keyframe: isH265IrapType(payload[2] & 63),
10522
+ parameterSet: false
10523
+ };
10524
+ }
10525
+ return {
10526
+ keyframe: isH265IrapType(nalType),
10527
+ parameterSet: isH265ParameterSetType(nalType)
10528
+ };
10529
+ }
10530
+ /**
10531
+ * Classify one raw RTP packet (12-byte header + payload) by what it carries —
10532
+ * the per-packet half of {@link RtpDepacketizer.processPacket}, without the
10533
+ * reassembly half. Pure: reads the headers, allocates nothing, keeps no state.
10534
+ * `null` for a packet too short to be RTP with a payload.
10535
+ */
10536
+ function classifyRtpPacket(codec, rtpData) {
10537
+ if (rtpData.length < 12) return null;
10538
+ const marker = (rtpData[1] & 128) !== 0;
10539
+ const timestamp = rtpData.readUInt32BE(4);
10540
+ const payload = rtpData.subarray(12);
10541
+ if (payload.length === 0) return null;
10542
+ const carried = codec === "h264" ? classifyH264Payload(payload) : classifyH265Payload(payload);
10543
+ return {
10544
+ timestamp,
10545
+ marker,
10546
+ keyframe: carried.keyframe,
10547
+ parameterSet: carried.parameterSet
10548
+ };
10549
+ }
10147
10550
  /**
10148
10551
  * RTP → Annex-B NAL depacketizer.
10149
10552
  *
@@ -10188,87 +10591,22 @@ var RtpDepacketizer = class {
10188
10591
  * short to be RTP with a payload.
10189
10592
  */
10190
10593
  processPacket(rtpData) {
10191
- if (rtpData.length < 12) return null;
10192
- const marker = (rtpData[1] & 128) !== 0;
10193
- const timestamp = rtpData.readUInt32BE(4);
10594
+ const carried = this.classifyPacket(rtpData);
10595
+ if (carried === null) return null;
10194
10596
  const payload = rtpData.subarray(12);
10195
- if (payload.length === 0) return null;
10196
- const carried = this.codec === "h264" ? this.classifyH264Payload(payload) : this.classifyH265Payload(payload);
10197
- if (this.codec === "h264") this.processH264(payload, timestamp, marker);
10198
- else this.processH265(payload, timestamp, marker);
10199
- return {
10200
- timestamp,
10201
- marker,
10202
- keyframe: carried.keyframe,
10203
- parameterSet: carried.parameterSet
10204
- };
10205
- }
10206
- /** H.264: single NAL by its type; STAP-A by every aggregated type; FU-A by the FU header. */
10207
- classifyH264Payload(payload) {
10208
- const nalType = payload[0] & 31;
10209
- if (nalType === H264_NAL_STAP_A) {
10210
- let keyframe = false;
10211
- let parameterSet = false;
10212
- let offset = 1;
10213
- while (offset + 2 <= payload.length) {
10214
- const nalSize = payload.readUInt16BE(offset);
10215
- offset += 2;
10216
- if (nalSize === 0 || offset + nalSize > payload.length) break;
10217
- const type = payload[offset] & 31;
10218
- keyframe ||= type === H264_NAL_IDR;
10219
- parameterSet ||= type === H264_NAL_SPS || type === H264_NAL_PPS;
10220
- offset += nalSize;
10221
- }
10222
- return {
10223
- keyframe,
10224
- parameterSet
10225
- };
10226
- }
10227
- if (nalType === 28) {
10228
- if (payload.length < 2) return NO_PAYLOAD_CLASS;
10229
- return {
10230
- keyframe: (payload[1] & 31) === H264_NAL_IDR,
10231
- parameterSet: false
10232
- };
10233
- }
10234
- return {
10235
- keyframe: nalType === H264_NAL_IDR,
10236
- parameterSet: nalType === H264_NAL_SPS || nalType === H264_NAL_PPS
10237
- };
10597
+ if (this.codec === "h264") this.processH264(payload, carried.timestamp, carried.marker);
10598
+ else this.processH265(payload, carried.timestamp, carried.marker);
10599
+ return carried;
10238
10600
  }
10239
- /** H.265: single NAL by its type; AP by every aggregated type; FU by the FU header. */
10240
- classifyH265Payload(payload) {
10241
- if (payload.length < 2) return NO_PAYLOAD_CLASS;
10242
- const nalType = payload[0] >> 1 & 63;
10243
- if (nalType === H265_NAL_AP) {
10244
- let keyframe = false;
10245
- let parameterSet = false;
10246
- let offset = 2;
10247
- while (offset + 2 <= payload.length) {
10248
- const nalSize = payload.readUInt16BE(offset);
10249
- offset += 2;
10250
- if (nalSize === 0 || offset + nalSize > payload.length) break;
10251
- const type = payload[offset] >> 1 & 63;
10252
- keyframe ||= isH265IrapType(type);
10253
- parameterSet ||= isH265ParameterSetType(type);
10254
- offset += nalSize;
10255
- }
10256
- return {
10257
- keyframe,
10258
- parameterSet
10259
- };
10260
- }
10261
- if (nalType === 49) {
10262
- if (payload.length < 3) return NO_PAYLOAD_CLASS;
10263
- return {
10264
- keyframe: isH265IrapType(payload[2] & 63),
10265
- parameterSet: false
10266
- };
10267
- }
10268
- return {
10269
- keyframe: isH265IrapType(nalType),
10270
- parameterSet: isH265ParameterSetType(nalType)
10271
- };
10601
+ /**
10602
+ * What this packet CARRIES, and nothing else: no NAL is reassembled, no buffer
10603
+ * allocated, no reassembly state touched. The same verdict `processPacket`
10604
+ * returns, for a broker whose Annex-B plane has no consumer — the source-RTP
10605
+ * ring, the restreamer's keyframe cache and the stats need exactly this and
10606
+ * none of the copies.
10607
+ */
10608
+ classifyPacket(rtpData) {
10609
+ return classifyRtpPacket(this.codec, rtpData);
10272
10610
  }
10273
10611
  processH264(payload, timestamp, marker) {
10274
10612
  const nalType = payload[0] & 31;
@@ -10448,11 +10786,13 @@ var RtpAccessUnitTracker = class {
10448
10786
  const keyframeContent = packet.keyframe || packet.parameterSet;
10449
10787
  const keyframeAuStart = keyframeContent && !this.keyframeAu;
10450
10788
  if (keyframeContent) this.keyframeAu = true;
10789
+ const pictureStart = !packet.parameterSet && !this.pictureSeen;
10451
10790
  if (!packet.parameterSet) this.pictureSeen = true;
10452
10791
  return {
10453
10792
  auStart,
10454
10793
  keyframeAu: this.keyframeAu,
10455
10794
  keyframeAuStart,
10795
+ pictureStart,
10456
10796
  parameterSet: packet.parameterSet
10457
10797
  };
10458
10798
  }
@@ -10778,7 +11118,7 @@ var NativeRtspClient = class {
10778
11118
  const channel = this.readBuffer[1];
10779
11119
  const length = this.readBuffer.readUInt16BE(2);
10780
11120
  if (this.readBuffer.length < 4 + length) return;
10781
- const payload = Buffer.from(this.readBuffer.subarray(4, 4 + length));
11121
+ const payload = copyOwned(this.readBuffer, 4, 4 + length);
10782
11122
  this.readBuffer = this.readBuffer.subarray(4 + length);
10783
11123
  this.handleInterleavedPacket(channel, payload);
10784
11124
  } else {
@@ -11514,49 +11854,6 @@ var AudioCodecSession = class {
11514
11854
  }
11515
11855
  };
11516
11856
  //#endregion
11517
- //#region src/stream-broker/stream-broker/owned-buffer.ts
11518
- /**
11519
- * Buffers that enter a LONG-LIVED ring own their backing store.
11520
- *
11521
- * Node serves `Buffer.from(...)`, `Buffer.allocUnsafe(n)` and `Buffer.concat`
11522
- * below `Buffer.poolSize >>> 1` (4 KiB) as views of shared 8 KiB pool slabs.
11523
- * That is the right choice for the ingest path — a start code, a 4-byte
11524
- * interleave header, a 1.3 KB RTP packet that is fanned out and forgotten
11525
- * within a tick — and the wrong one for anything RETAINED: a 600-byte NAL kept
11526
- * for 60 s in a clip ring pins its whole slab, and with it every short-lived
11527
- * packet that happened to be allocated beside it. On the hub that held
11528
- * 550–980 MB of ArrayBuffers for ~150–250 MB of payload, and the full GCs the
11529
- * pinned slabs caused ran every 0.2–0.5 s at 40–65 % of a core.
11530
- *
11531
- * The ring byte caps counted payload bytes, so they never saw it. Both rings
11532
- * now pass every entry through {@link detachFromPool} on the way in and
11533
- * account it with {@link backingStoreBytes}, so the cap measures what the
11534
- * entry costs. Short-lived traffic stays pooled.
11535
- */
11536
- /** True when `buf` is the whole of its ArrayBuffer — nothing else shares it. */
11537
- function ownsBackingStore(buf) {
11538
- return buf.byteOffset === 0 && buf.buffer.byteLength === buf.length;
11539
- }
11540
- /**
11541
- * What retaining `buf` really costs: its own length when it owns its
11542
- * ArrayBuffer, the whole shared slab when it is a view of one.
11543
- */
11544
- function backingStoreBytes(buf) {
11545
- return ownsBackingStore(buf) ? buf.length : buf.buffer.byteLength;
11546
- }
11547
- /**
11548
- * `buf` itself when it already owns its ArrayBuffer (decided by inspecting the
11549
- * buffer, never by its size: whether a large `allocUnsafe` is pooled is a Node
11550
- * runtime detail), else a copy that does. One copy, never two: a buffer that is
11551
- * already unpooled is returned as-is.
11552
- */
11553
- function detachFromPool(buf) {
11554
- if (ownsBackingStore(buf)) return buf;
11555
- const owned = Buffer.allocUnsafeSlow(buf.length);
11556
- buf.copy(owned);
11557
- return owned;
11558
- }
11559
- //#endregion
11560
11857
  //#region src/stream-broker/stream-broker/encoded-ring-buffer.ts
11561
11858
  /**
11562
11859
  * Time-based ring buffer for encoded video packets.
@@ -12496,9 +12793,6 @@ var StreamBroker = class StreamBroker {
12496
12793
  */
12497
12794
  ffmpegBinaryPathSource = () => null;
12498
12795
  rtpDepacketizer = null;
12499
- /** Flag-only depacketizer for the `push-rtp` source-RTP pre-buffer path
12500
- * (sets keyframe/param-set flags without re-emitting AnnexB packets). */
12501
- pushRtpFlagDepacketizer = null;
12502
12796
  audioRtpDecoder = null;
12503
12797
  audioCodecSession = null;
12504
12798
  /**
@@ -12742,6 +13036,8 @@ var StreamBroker = class StreamBroker {
12742
13036
  * timeout was chosen against.
12743
13037
  */
12744
13038
  static RTP_ACTIVITY_SWEEP_MS = 1e3;
13039
+ /** A source counts as ingesting when its last video packet is younger than this. */
13040
+ static INGESTING_WITHIN_MS = 1e4;
12745
13041
  manualStop = false;
12746
13042
  stopping = false;
12747
13043
  pipeServer;
@@ -12866,10 +13162,24 @@ var StreamBroker = class StreamBroker {
12866
13162
  * log alongside `audioRtpSeen`, so a wake-up cycle that brings audio
12867
13163
  * back but never video is observable from the structured logs. */
12868
13164
  videoRtpSeen = 0;
12869
- /** Tracking flags set synchronously by the RTP depacketizer callback. */
13165
+ /**
13166
+ * What the source RTP packet being handled CARRIES (keyframe / parameter-set
13167
+ * content, read from its payload headers by `classifyRtpPacket`) — the flags
13168
+ * `captureRtpForPreBuffer` reads. Per packet, not per completed NAL: every
13169
+ * fragment of a fragmented IDR carries them, and the ring marks one keyframe
13170
+ * AU for the lot.
13171
+ */
12870
13172
  _lastNalKeyframe = false;
12871
13173
  _lastNalParamSet = false;
12872
13174
  /**
13175
+ * Whether the Annex-B plane (NAL reassembly + access-unit assembly →
13176
+ * `EncodedPacket`) is being BUILT for this stream. False while nothing
13177
+ * consumes it: the source-RTP path then classifies each packet and copies
13178
+ * nothing. Armed at the next keyframe access unit once a consumer attaches
13179
+ * (`needsAnnexBPlane`), so the first packet it emits is decodable.
13180
+ */
13181
+ annexBPlaneActive = false;
13182
+ /**
12873
13183
  * Source-RTP pre-buffer: a ring of raw source RTP packets trimmed to
12874
13184
  * begin at the most recent keyframe access unit — i.e. the current GOP so
12875
13185
  * far. A late-joining WebRTC viewer on the repacketizer path replays this
@@ -13002,6 +13312,33 @@ var StreamBroker = class StreamBroker {
13002
13312
  return this.lastPacketAt;
13003
13313
  }
13004
13314
  /**
13315
+ * What this broker holds, for the `[broker-mem]` probe (`broker-mem-probe.ts`).
13316
+ * Ring sizes are the rings' own counters (backing-store bytes), so this is
13317
+ * O(sessions + subscribers) and allocates only the result.
13318
+ */
13319
+ getMemSample(now) {
13320
+ const encodedByKind = {};
13321
+ for (const entry of this.encodedCallbacks.values()) encodedByKind[entry.attribution.kind] = (encodedByKind[entry.attribution.kind] ?? 0) + 1;
13322
+ for (const entry of this.rtpVideoCallbacks.values()) encodedByKind[entry.attribution.kind] = (encodedByKind[entry.attribution.kind] ?? 0) + 1;
13323
+ const rtsp = this.rtspRestreamer.getMemProbe();
13324
+ return {
13325
+ deviceId: this.numericDeviceId,
13326
+ brokerId: this.deviceId,
13327
+ ingesting: this.lastPacketAt > 0 && now - this.lastPacketAt < StreamBroker.INGESTING_WITHIN_MS,
13328
+ preBufferBytes: this.preBuffer.getBufferedBytes(),
13329
+ clipBytes: this.clipBuffer?.getBufferedBytes() ?? 0,
13330
+ rtpRingBytes: this.rtpRingBytes,
13331
+ keyframeCacheBytes: rtsp.keyframeCacheBytes,
13332
+ rtspSessions: rtsp.sessions,
13333
+ writeQueueSumBytes: rtsp.writeQueueSumBytes,
13334
+ writeQueueMaxBytes: rtsp.writeQueueMaxBytes,
13335
+ encodedByKind,
13336
+ audioSubscribers: this.audioChunkPlane.subscriberCount,
13337
+ pipeClients: this.pipeServer.getClientCount(),
13338
+ annexBPlaneActive: this.annexBPlaneActive
13339
+ };
13340
+ }
13341
+ /**
13005
13342
  * Whether a LAZY publisher is being built right now.
13006
13343
  *
13007
13344
  * A consumer deciding whether it is worth waiting for this source needs to
@@ -13945,26 +14282,7 @@ var StreamBroker = class StreamBroker {
13945
14282
  return;
13946
14283
  }
13947
14284
  }
13948
- if (packet.type === "video") {
13949
- this.packetCount++;
13950
- this.totalBytes += packet.data.length;
13951
- this.bytesInWindow += packet.data.length;
13952
- this.packetsInWindow++;
13953
- this.lastPacketAt = Date.now();
13954
- const now = Date.now();
13955
- const windowMs = now - this.windowStartMs;
13956
- if (windowMs >= 2e3) {
13957
- this.bitrateKbps = Math.round(this.bytesInWindow * 8 / windowMs);
13958
- this.encodedInputFps = this.packetsInWindow / windowMs * 1e3;
13959
- this.bytesInWindow = 0;
13960
- this.packetsInWindow = 0;
13961
- this.windowStartMs = now;
13962
- }
13963
- if (packet.keyframe) {
13964
- if (this.lastKeyframeMs > 0) this.idrIntervalMs = now - this.lastKeyframeMs;
13965
- this.lastKeyframeMs = now;
13966
- }
13967
- }
14285
+ if (packet.type === "video" && this.rtpDepacketizer === null) this.recordVideoStats(packet.data.length, true, packet.keyframe);
13968
14286
  if (packet.type === "video" && !this.isRtpSource()) this.preBuffer.push(packet);
13969
14287
  if (packet.type === "video" && this.clipBuffer && packet.isPlaceholder !== true) this.clipBuffer.push(packet);
13970
14288
  this.fanOutEncoded(packet);
@@ -13983,6 +14301,7 @@ var StreamBroker = class StreamBroker {
13983
14301
  packetsDelivered: 0
13984
14302
  };
13985
14303
  this.encodedCallbacks.set(callback, entry);
14304
+ reportConsumerEvent(this.logger, "attach", attribution.kind, this.numericDeviceId, this.encodedCallbacks.size + this.rtpVideoCallbacks.size);
13986
14305
  if (this.streamingDebug) this.logger?.info("encoded subscriber added", { meta: {
13987
14306
  total: this.encodedCallbacks.size,
13988
14307
  subscriberId: entry.id,
@@ -13993,6 +14312,7 @@ var StreamBroker = class StreamBroker {
13993
14312
  this.checkDemand();
13994
14313
  return () => {
13995
14314
  this.encodedCallbacks.delete(callback);
14315
+ reportConsumerEvent(this.logger, "detach", attribution.kind, this.numericDeviceId, this.encodedCallbacks.size + this.rtpVideoCallbacks.size);
13996
14316
  if (this.streamingDebug) this.logger?.info("encoded subscriber removed", { meta: {
13997
14317
  total: this.encodedCallbacks.size,
13998
14318
  subscriberId: entry.id
@@ -14087,6 +14407,7 @@ var StreamBroker = class StreamBroker {
14087
14407
  packetsDelivered: 0
14088
14408
  };
14089
14409
  this.rtpVideoCallbacks.set(callback, entry);
14410
+ reportConsumerEvent(this.logger, "attach", attribution.kind, this.numericDeviceId, this.encodedCallbacks.size + this.rtpVideoCallbacks.size);
14090
14411
  if (this.streamingDebug || CH_RTP.on) this.logger?.info("rtp video subscriber added", { meta: {
14091
14412
  total: this.rtpVideoCallbacks.size,
14092
14413
  subscriberId: entry.id,
@@ -14096,6 +14417,7 @@ var StreamBroker = class StreamBroker {
14096
14417
  this.checkDemand();
14097
14418
  return () => {
14098
14419
  this.rtpVideoCallbacks.delete(callback);
14420
+ reportConsumerEvent(this.logger, "detach", attribution.kind, this.numericDeviceId, this.encodedCallbacks.size + this.rtpVideoCallbacks.size);
14099
14421
  if (this.streamingDebug || CH_RTP.on) this.logger?.info("rtp video subscriber removed", { meta: {
14100
14422
  total: this.rtpVideoCallbacks.size,
14101
14423
  subscriberId: entry.id
@@ -14155,8 +14477,8 @@ var StreamBroker = class StreamBroker {
14155
14477
  index: auStart,
14156
14478
  ts: auPacket.readUInt32BE(4)
14157
14479
  });
14480
+ this.trimRtpRingToPreRollWindow();
14158
14481
  }
14159
- this.trimRtpRingToPreRollWindow();
14160
14482
  this.rtpRingHasKeyframe = true;
14161
14483
  }
14162
14484
  this.rtpRingPrevMarker = marker;
@@ -14425,10 +14747,10 @@ var StreamBroker = class StreamBroker {
14425
14747
  *
14426
14748
  * `push-rtp` providers deliver AnnexB-equivalents separately via
14427
14749
  * `pushEncodedPacket()` (see `IStreamBroker` contract) — so this path
14428
- * does NOT re-emit EncodedPackets; it only derives the per-packet
14429
- * keyframe/param-set flags the pre-buffer capture needs (via a
14430
- * flag-only depacketizer that never touches the AnnexB plane), keeping
14431
- * the AnnexB consumers (pipe, restreamer) single-fed by the provider.
14750
+ * does NOT re-emit EncodedPackets; it only classifies each packet for the
14751
+ * keyframe/param-set flags the pre-buffer capture needs
14752
+ * (`classifyRtpPacket`, no reassembly), keeping the AnnexB consumers
14753
+ * (pipe, restreamer) single-fed by the provider.
14432
14754
  *
14433
14755
  * The pre-buffer is captured even with zero subscribers attached — a
14434
14756
  * viewer that connects mid-GOP must be able to replay the GOP that
@@ -14438,9 +14760,9 @@ var StreamBroker = class StreamBroker {
14438
14760
  pushVideoRtp(rtpData) {
14439
14761
  if (this.stopping) return;
14440
14762
  this.kickRtpActivityWatchdog();
14441
- this._lastNalKeyframe = false;
14442
- this._lastNalParamSet = false;
14443
- this.ensurePushRtpFlagDepacketizer().processPacket(rtpData);
14763
+ const carried = classifyRtpPacket(this.detectedCodec === "h265" ? "h265" : "h264", rtpData);
14764
+ this._lastNalKeyframe = carried?.keyframe ?? false;
14765
+ this._lastNalParamSet = carried?.parameterSet ?? false;
14444
14766
  this.captureRtpForPreBuffer(rtpData);
14445
14767
  if (this.rtpVideoCallbacks.size === 0) return;
14446
14768
  for (const [cb, entry] of this.rtpVideoCallbacks) try {
@@ -14450,27 +14772,6 @@ var StreamBroker = class StreamBroker {
14450
14772
  this.logger?.warn("rtp video subscriber threw", { meta: { error: errMsg(err) } });
14451
14773
  }
14452
14774
  }
14453
- /**
14454
- * Lazily build a flag-only RTP depacketizer for the `push-rtp` source
14455
- * path. Unlike the native reader's depacketizer (which emits AnnexB
14456
- * EncodedPackets via `handleDepacketizedNal`), this callback ONLY sets
14457
- * `_lastNalKeyframe`/`_lastNalParamSet` — the push provider owns the
14458
- * AnnexB plane through `pushEncodedPacket`, so re-emitting here would
14459
- * double-feed the pipe/restreamer. push-rtp has no SDP `onVideoTrack`,
14460
- * so the codec comes from the source declaration (`detectedCodec`, seeded
14461
- * in `start()`). Dropped on teardown (`resetRtpPipeline`) so a redial
14462
- * starts with clean FU reassembly state.
14463
- */
14464
- ensurePushRtpFlagDepacketizer() {
14465
- if (!this.pushRtpFlagDepacketizer) {
14466
- const codec = this.detectedCodec === "h265" ? "h265" : "h264";
14467
- this.pushRtpFlagDepacketizer = new RtpDepacketizer(codec, ({ keyframe, parameterSet }) => {
14468
- if (keyframe) this._lastNalKeyframe = true;
14469
- if (parameterSet) this._lastNalParamSet = true;
14470
- });
14471
- }
14472
- return this.pushRtpFlagDepacketizer;
14473
- }
14474
14775
  /** Toggle the per-device streaming debug flag. */
14475
14776
  setStreamingDebug(enabled) {
14476
14777
  this.streamingDebug = enabled;
@@ -14517,6 +14818,7 @@ var StreamBroker = class StreamBroker {
14517
14818
  tag,
14518
14819
  accept: options?.accept
14519
14820
  });
14821
+ reportConsumerEvent(this.logger, "attach", "audio-chunks", this.numericDeviceId, this.audioChunkPlane.subscriberCount);
14520
14822
  this.checkDemand();
14521
14823
  return result;
14522
14824
  }
@@ -14527,7 +14829,10 @@ var StreamBroker = class StreamBroker {
14527
14829
  /** Release an audio-chunk subscription. Returns `true` when it existed. */
14528
14830
  unsubscribeAudioChunks(subscriptionId) {
14529
14831
  const released = this.audioChunkPlane.unsubscribe(subscriptionId);
14530
- if (released) this.checkDemand();
14832
+ if (released) {
14833
+ reportConsumerEvent(this.logger, "detach", "audio-chunks", this.numericDeviceId, this.audioChunkPlane.subscriberCount);
14834
+ this.checkDemand();
14835
+ }
14531
14836
  return released;
14532
14837
  }
14533
14838
  getStats() {
@@ -14913,10 +15218,17 @@ var StreamBroker = class StreamBroker {
14913
15218
  seq: this.videoRtpSeen,
14914
15219
  bytes: rtpData.length
14915
15220
  } });
14916
- this._lastNalKeyframe = false;
14917
- this._lastNalParamSet = false;
14918
- const carried = this.rtpDepacketizer?.processPacket(rtpData) ?? null;
15221
+ const wantPlane = this.needsAnnexBPlane();
15222
+ if (!wantPlane && this.annexBPlaneActive) this.idleAnnexBPlane();
15223
+ const carried = this.annexBPlaneActive ? this.rtpDepacketizer?.processPacket(rtpData) ?? null : this.rtpDepacketizer?.classifyPacket(rtpData) ?? null;
15224
+ this._lastNalKeyframe = carried?.keyframe ?? false;
15225
+ this._lastNalParamSet = carried?.parameterSet ?? false;
14919
15226
  const auFlags = carried ? this.rtpAuTracker.classify(carried) : null;
15227
+ if (wantPlane && !this.annexBPlaneActive && auFlags?.keyframeAuStart === true) {
15228
+ this.armAnnexBPlane();
15229
+ this.rtpDepacketizer?.processPacket(rtpData);
15230
+ }
15231
+ if (auFlags) this.recordVideoStats(rtpData.length, auFlags.pictureStart, auFlags.keyframeAu);
14920
15232
  this.rtspRestreamer.pushRtpPassthrough(rtpData, auFlags?.keyframeAu ?? false, auFlags?.parameterSet ?? false);
14921
15233
  this.captureRtpForPreBuffer(rtpData);
14922
15234
  if (this.rtpVideoCallbacks.size > 0) for (const [cb, entry] of this.rtpVideoCallbacks) try {
@@ -15132,11 +15444,83 @@ var StreamBroker = class StreamBroker {
15132
15444
  * not fed from here.
15133
15445
  */
15134
15446
  handleDepacketizedNal(depacketized) {
15135
- if (depacketized.keyframe) this._lastNalKeyframe = true;
15136
- if (depacketized.parameterSet) this._lastNalParamSet = true;
15137
15447
  this.annexBAssembler.push(depacketized);
15138
15448
  }
15139
15449
  /**
15450
+ * Does anything consume the Annex-B plane right now? An encoded subscriber
15451
+ * (WebRTC Annex-B, fragments, a decode session), a pipe client, a clip ring
15452
+ * (it retains access units continuously, so it needs them built even with
15453
+ * nobody attached), or a restreamer still in Annex-B mode (no camera SDP yet).
15454
+ * The RTSP restream and the WebRTC RTP path are served from the source RTP
15455
+ * and never count. Cheap — four field reads — because it runs per packet.
15456
+ */
15457
+ needsAnnexBPlane() {
15458
+ return this.encodedCallbacks.size > 0 || this.clipBuffer !== null || this.pipeServer.getClientCount() > 0 || !this.rtspRestreamer.rtpPassthroughMode;
15459
+ }
15460
+ /** The consumers that want the plane, for the arm/idle lines. */
15461
+ annexBPlaneConsumers() {
15462
+ return {
15463
+ encodedSubscribers: this.encodedCallbacks.size,
15464
+ clipRing: this.clipBuffer !== null,
15465
+ pipeClients: this.pipeServer.getClientCount(),
15466
+ restreamerAnnexB: !this.rtspRestreamer.rtpPassthroughMode
15467
+ };
15468
+ }
15469
+ /**
15470
+ * Start building access units from the keyframe AU the current packet opens.
15471
+ * The depacketizer and the assembler are clean here (reset when the plane
15472
+ * went idle, or never used), so nothing stale precedes that keyframe.
15473
+ */
15474
+ armAnnexBPlane() {
15475
+ this.annexBPlaneActive = true;
15476
+ this.logger?.info("annex-b plane armed at a keyframe access unit", {
15477
+ tags: { deviceId: this.numericDeviceId },
15478
+ meta: this.annexBPlaneConsumers()
15479
+ });
15480
+ }
15481
+ /**
15482
+ * Stop building access units: the last consumer left. The picture in
15483
+ * progress is dropped, not flushed — there is nobody to hand it to, and a
15484
+ * partial picture must never reach the next consumer's clean start. Logged,
15485
+ * once per transition: a plane that is idle on purpose must not read as a
15486
+ * stream that stopped.
15487
+ */
15488
+ idleAnnexBPlane() {
15489
+ this.annexBPlaneActive = false;
15490
+ this.rtpDepacketizer?.reset();
15491
+ this.annexBAssembler.reset();
15492
+ this.logger?.info("annex-b plane idle — no consumer, NAL reassembly skipped", { tags: { deviceId: this.numericDeviceId } });
15493
+ }
15494
+ /**
15495
+ * One place for the input stats, fed from whichever path owns the pictures:
15496
+ * the source RTP packets while the native reader is up (`onVideoRtp`), the
15497
+ * `EncodedPacket`s otherwise (`pushEncodedPacket`). `bytes` accumulate per
15498
+ * call; `inputFps`, `packetCount` and the IDR interval advance once per
15499
+ * picture — the window is closed on a picture start so a window of N frames
15500
+ * reads N, exactly as it did when every call was one access unit.
15501
+ */
15502
+ recordVideoStats(bytes, pictureStart, keyframe) {
15503
+ this.totalBytes += bytes;
15504
+ this.bytesInWindow += bytes;
15505
+ if (!pictureStart) return;
15506
+ this.packetCount++;
15507
+ this.packetsInWindow++;
15508
+ const now = Date.now();
15509
+ this.lastPacketAt = now;
15510
+ const windowMs = now - this.windowStartMs;
15511
+ if (windowMs >= 2e3) {
15512
+ this.bitrateKbps = Math.round(this.bytesInWindow * 8 / windowMs);
15513
+ this.encodedInputFps = this.packetsInWindow / windowMs * 1e3;
15514
+ this.bytesInWindow = 0;
15515
+ this.packetsInWindow = 0;
15516
+ this.windowStartMs = now;
15517
+ }
15518
+ if (keyframe) {
15519
+ if (this.lastKeyframeMs > 0) this.idrIntervalMs = now - this.lastKeyframeMs;
15520
+ this.lastKeyframeMs = now;
15521
+ }
15522
+ }
15523
+ /**
15140
15524
  * One assembled access unit → one `EncodedPacket` for every Annex-B
15141
15525
  * consumer (prebuffer, clip ring, pipe, WebRTC). A keyframe packet is
15142
15526
  * self-contained (VPS/SPS/PPS + every IDR slice), which is what a decoder
@@ -15557,10 +15941,7 @@ var StreamBroker = class StreamBroker {
15557
15941
  this.annexBAssembler.flush();
15558
15942
  this.annexBAssembler.reset();
15559
15943
  this.rtpAuTracker.reset();
15560
- if (this.pushRtpFlagDepacketizer) {
15561
- this.pushRtpFlagDepacketizer.reset();
15562
- this.pushRtpFlagDepacketizer = null;
15563
- }
15944
+ this.annexBPlaneActive = false;
15564
15945
  this.resetRtpPreBuffer();
15565
15946
  if (this.audioRtpDecoder) {
15566
15947
  this.audioRtpDecoder.flush();
@@ -19278,6 +19659,10 @@ var StreamBrokerManager = class StreamBrokerManager {
19278
19659
  return { killed: broker.killClient(input.channel, input.handle) };
19279
19660
  }
19280
19661
  /** Raw broker instances — internal use only. */
19662
+ /** Live egress transcodes (ffmpeg children) — the `[broker-mem]` probe. */
19663
+ getEgressTranscodeCount() {
19664
+ return this.egressTranscodeManager?.activeCount() ?? 0;
19665
+ }
19281
19666
  getBrokerInstances() {
19282
19667
  return [...this.brokers.values()];
19283
19668
  }
@@ -35150,6 +35535,7 @@ var BrokerWebrtcServer = class {
35150
35535
  }
35151
35536
  };
35152
35537
  this.sessions.set(sessionId, entry);
35538
+ reportConsumerEvent(this.logger, "attach", `webrtc:${entry.consumerKind}`, entry.rebind?.deviceId, this.sessions.size);
35153
35539
  session.onClosed = () => this.cleanupSession(sessionId);
35154
35540
  const locateRecorded = this.locateRecordedSegment;
35155
35541
  const readRecorded = this.readRecordedSegmentBytes;
@@ -36367,6 +36753,7 @@ var BrokerWebrtcServer = class {
36367
36753
  const entry = this.sessions.get(sessionId);
36368
36754
  if (!entry) return;
36369
36755
  this.sessions.delete(sessionId);
36756
+ reportConsumerEvent(this.logger, "detach", `webrtc:${entry.consumerKind}`, entry.rebind?.deviceId, this.sessions.size);
36370
36757
  entry.pendingSwitch?.cancel();
36371
36758
  entry.pendingSwitch = void 0;
36372
36759
  let brokerStillServed = false;
@@ -37088,6 +37475,45 @@ function dialAnswerFromError(err) {
37088
37475
  };
37089
37476
  }
37090
37477
  //#endregion
37478
+ //#region src/stream-broker/broker-mem-report.ts
37479
+ /** Linux only: elsewhere (or on a read error) the native view is simply absent. */
37480
+ async function readProc() {
37481
+ if (process.platform !== "linux") return {
37482
+ status: null,
37483
+ maps: null
37484
+ };
37485
+ const [status, maps] = await Promise.all([readFile("/proc/self/status", "utf8").then(parseProcStatus, () => null), readFile("/proc/self/maps", "utf8").then(parseProcMaps, () => null)]);
37486
+ return {
37487
+ status,
37488
+ maps
37489
+ };
37490
+ }
37491
+ async function emitOnce(deps) {
37492
+ const now = Date.now();
37493
+ const proc = await readProc();
37494
+ const resources = process.getActiveResourcesInfo();
37495
+ const line = formatBrokerMem({
37496
+ samples: deps.manager.getBrokerInstances().map((b) => b.getMemSample(now)),
37497
+ webrtcSessions: deps.webrtcSessions(),
37498
+ egressTranscodes: deps.manager.getEgressTranscodeCount(),
37499
+ childProcesses: countActiveResources(resources, "ProcessWrap"),
37500
+ proc,
37501
+ churn: consumerChurn.drain(),
37502
+ copied: readCopyOwnedTotals()
37503
+ });
37504
+ deps.logger.info(line);
37505
+ }
37506
+ /** Start the five-minute `[broker-mem]` heartbeat; returns its stop function. */
37507
+ function startBrokerMemReport(deps) {
37508
+ const timer = setInterval(() => {
37509
+ emitOnce(deps).catch((err) => {
37510
+ deps.logger.warn("broker-mem probe failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
37511
+ });
37512
+ }, BROKER_MEM_INTERVAL_MS);
37513
+ timer.unref();
37514
+ return () => clearInterval(timer);
37515
+ }
37516
+ //#endregion
37091
37517
  //#region src/stream-broker/event-loop-stall-monitor.ts
37092
37518
  var DEFAULT_INTERVAL_MS = 100;
37093
37519
  var DEFAULT_THRESHOLD_MS = 250;
@@ -37262,6 +37688,8 @@ var StreamBrokerAddon = class extends BaseAddon {
37262
37688
  * leaks those sockets and wedges the next runner's werift ICE agent
37263
37689
  * (DTLS never connects until a full hub restart). */
37264
37690
  webrtcServer = null;
37691
+ /** Stops the five-minute `[broker-mem]` attribution heartbeat. */
37692
+ stopBrokerMemReport = null;
37265
37693
  /** Names this process's event-loop stalls — see onInitialize. */
37266
37694
  loopStallMonitor = null;
37267
37695
  /** The `log-channels` provider for this process. Stopped on shutdown so a
@@ -37619,6 +38047,11 @@ var StreamBrokerAddon = class extends BaseAddon {
37619
38047
  this.webrtcServer = webrtcServer;
37620
38048
  const webrtcSessionProvider = new WebrtcSessionProvider(webrtcServer, this.brokerManager);
37621
38049
  this.metricsSnapshotTimer = setInterval(() => this.emitBrokerMetricsSnapshot(), BROKER_METRICS_SNAPSHOT_INTERVAL_MS);
38050
+ this.stopBrokerMemReport = startBrokerMemReport({
38051
+ logger: this.ctx.logger,
38052
+ manager: this.brokerManager,
38053
+ webrtcSessions: () => webrtcServer.getSessionCount()
38054
+ });
37622
38055
  const widgetsProvider = { listWidgets: async () => [{
37623
38056
  tab: "device-tab",
37624
38057
  label: "Stream Brokers",
@@ -37723,6 +38156,8 @@ var StreamBrokerAddon = class extends BaseAddon {
37723
38156
  async onShutdown() {
37724
38157
  this.logChannels?.stop();
37725
38158
  this.logChannels = null;
38159
+ this.stopBrokerMemReport?.();
38160
+ this.stopBrokerMemReport = null;
37726
38161
  this.loopStallMonitor?.stop();
37727
38162
  this.loopStallMonitor = null;
37728
38163
  this.flushProbeSnapshots(Date.now(), true);