@camstack/addon-pipeline 1.2.310 → 1.2.312

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.
@@ -3885,6 +3885,24 @@ function isParkedDetailFresh(parked, nowMs) {
3885
3885
  function buildDetailNotAttachedError(deviceId) {
3886
3886
  return /* @__PURE__ */ new Error(`${DETAIL_NOT_ATTACHED_MARKER} — camera ${deviceId} is not attached and no detail state is parked within the grace window`);
3887
3887
  }
3888
+ /**
3889
+ * The wire marker for the typed handle-expired refusal (D688): the frame handle
3890
+ * a detail dispatch named is past the native lease age bound, so every later
3891
+ * ask for it is refused too. Twin constant in
3892
+ * `addon-post-analysis/src/pipeline-analytics/detail-refusal.ts` — keep in sync.
3893
+ * An error rather than a result field for the same reason as
3894
+ * {@link DETAIL_NOT_ATTACHED_MARKER}: the cap output schema strips unknown keys.
3895
+ */
3896
+ var DETAIL_HANDLE_EXPIRED_MARKER = "runDetailSubtree-refused:handle-expired";
3897
+ /**
3898
+ * The error the cap method throws when the dispatch's only pixel source was a
3899
+ * frame handle the lease registry refused as expired. The dispatcher recognises
3900
+ * the marker, retires the handle for that track and falls back to its parked
3901
+ * frame — it never names the same handle again.
3902
+ */
3903
+ function buildDetailHandleExpiredError(deviceId, handleAgeMs) {
3904
+ return /* @__PURE__ */ new Error(`${DETAIL_HANDLE_EXPIRED_MARKER} — camera ${deviceId}: the frame handle is past the native lease age bound (age ${handleAgeMs ?? "unknown"} ms)`);
3905
+ }
3888
3906
  //#endregion
3889
3907
  //#region src/pipeline-runner/face-parent-geometry.ts
3890
3908
  /** Is the centre of `face` inside `parent` (edges inclusive)? */
@@ -3970,7 +3988,11 @@ var FaceOutsideParentLog = class {
3970
3988
  //#region src/pipeline-runner/detail-subtree.ts
3971
3989
  /** Did the subtree run refuse because the camera is not attached (park lapsed)? */
3972
3990
  function isDetailNotAttachedRefusal(outcome) {
3973
- return "refusal" in outcome;
3991
+ return "refusal" in outcome && outcome.refusal === "not-attached";
3992
+ }
3993
+ /** Did the subtree run refuse because the named frame handle is past its lease age bound? */
3994
+ function isDetailHandleExpiredRefusal(outcome) {
3995
+ return "refusal" in outcome && outcome.refusal === "handle-expired";
3974
3996
  }
3975
3997
  /** Did the native-lease fetch report a miss (rather than return pixels)? */
3976
3998
  function isNativeCropMiss(fetch) {
@@ -4272,6 +4294,10 @@ async function runDetailSubtree(deps, input) {
4272
4294
  nativeWorkerReason: nativeMiss?.workerReason ?? null
4273
4295
  }
4274
4296
  });
4297
+ if (nativeMiss?.miss === "handle-expired") return {
4298
+ refusal: "handle-expired",
4299
+ handleAgeMs: nativeMiss.handleAgeMs
4300
+ };
4275
4301
  return null;
4276
4302
  }
4277
4303
  const nativeCropRef = input.frameHandle !== void 0 && crop.source === "native" ? {
@@ -10071,6 +10097,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10071
10097
  recordFaceOutsideParent: (drop) => this.faceOutsideParentLog.record(drop)
10072
10098
  }, input);
10073
10099
  if (outcome !== null && isDetailNotAttachedRefusal(outcome)) throw buildDetailNotAttachedError(input.deviceId);
10100
+ if (outcome !== null && isDetailHandleExpiredRefusal(outcome)) throw buildDetailHandleExpiredError(input.deviceId, outcome.handleAgeMs);
10074
10101
  return outcome;
10075
10102
  }
10076
10103
  /**
@@ -3877,6 +3877,24 @@ function isParkedDetailFresh(parked, nowMs) {
3877
3877
  function buildDetailNotAttachedError(deviceId) {
3878
3878
  return /* @__PURE__ */ new Error(`${DETAIL_NOT_ATTACHED_MARKER} — camera ${deviceId} is not attached and no detail state is parked within the grace window`);
3879
3879
  }
3880
+ /**
3881
+ * The wire marker for the typed handle-expired refusal (D688): the frame handle
3882
+ * a detail dispatch named is past the native lease age bound, so every later
3883
+ * ask for it is refused too. Twin constant in
3884
+ * `addon-post-analysis/src/pipeline-analytics/detail-refusal.ts` — keep in sync.
3885
+ * An error rather than a result field for the same reason as
3886
+ * {@link DETAIL_NOT_ATTACHED_MARKER}: the cap output schema strips unknown keys.
3887
+ */
3888
+ var DETAIL_HANDLE_EXPIRED_MARKER = "runDetailSubtree-refused:handle-expired";
3889
+ /**
3890
+ * The error the cap method throws when the dispatch's only pixel source was a
3891
+ * frame handle the lease registry refused as expired. The dispatcher recognises
3892
+ * the marker, retires the handle for that track and falls back to its parked
3893
+ * frame — it never names the same handle again.
3894
+ */
3895
+ function buildDetailHandleExpiredError(deviceId, handleAgeMs) {
3896
+ return /* @__PURE__ */ new Error(`${DETAIL_HANDLE_EXPIRED_MARKER} — camera ${deviceId}: the frame handle is past the native lease age bound (age ${handleAgeMs ?? "unknown"} ms)`);
3897
+ }
3880
3898
  //#endregion
3881
3899
  //#region src/pipeline-runner/face-parent-geometry.ts
3882
3900
  /** Is the centre of `face` inside `parent` (edges inclusive)? */
@@ -3962,7 +3980,11 @@ var FaceOutsideParentLog = class {
3962
3980
  //#region src/pipeline-runner/detail-subtree.ts
3963
3981
  /** Did the subtree run refuse because the camera is not attached (park lapsed)? */
3964
3982
  function isDetailNotAttachedRefusal(outcome) {
3965
- return "refusal" in outcome;
3983
+ return "refusal" in outcome && outcome.refusal === "not-attached";
3984
+ }
3985
+ /** Did the subtree run refuse because the named frame handle is past its lease age bound? */
3986
+ function isDetailHandleExpiredRefusal(outcome) {
3987
+ return "refusal" in outcome && outcome.refusal === "handle-expired";
3966
3988
  }
3967
3989
  /** Did the native-lease fetch report a miss (rather than return pixels)? */
3968
3990
  function isNativeCropMiss(fetch) {
@@ -4264,6 +4286,10 @@ async function runDetailSubtree(deps, input) {
4264
4286
  nativeWorkerReason: nativeMiss?.workerReason ?? null
4265
4287
  }
4266
4288
  });
4289
+ if (nativeMiss?.miss === "handle-expired") return {
4290
+ refusal: "handle-expired",
4291
+ handleAgeMs: nativeMiss.handleAgeMs
4292
+ };
4267
4293
  return null;
4268
4294
  }
4269
4295
  const nativeCropRef = input.frameHandle !== void 0 && crop.source === "native" ? {
@@ -10063,6 +10089,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10063
10089
  recordFaceOutsideParent: (drop) => this.faceOutsideParentLog.record(drop)
10064
10090
  }, input);
10065
10091
  if (outcome !== null && isDetailNotAttachedRefusal(outcome)) throw buildDetailNotAttachedError(input.deviceId);
10092
+ if (outcome !== null && isDetailHandleExpiredRefusal(outcome)) throw buildDetailHandleExpiredError(input.deviceId, outcome.handleAgeMs);
10066
10093
  return outcome;
10067
10094
  }
10068
10095
  /**
@@ -8498,6 +8498,41 @@ function resolveSourceParser(setting, sourceType) {
8498
8498
  }
8499
8499
  }
8500
8500
  //#endregion
8501
+ //#region src/stream-broker/catalog-absence.ts
8502
+ function judgeCatalogAbsence(input) {
8503
+ const { marks, listed, candidates, nowMs, confirmMinMs } = input;
8504
+ const nextMarks = /* @__PURE__ */ new Map();
8505
+ const marked = [];
8506
+ const cleared = [];
8507
+ const retract = [];
8508
+ for (const camStreamId of candidates) {
8509
+ const markedAt = marks.get(camStreamId);
8510
+ if (listed.has(camStreamId)) {
8511
+ if (markedAt !== void 0) cleared.push(camStreamId);
8512
+ continue;
8513
+ }
8514
+ if (markedAt === void 0) {
8515
+ nextMarks.set(camStreamId, nowMs);
8516
+ marked.push(camStreamId);
8517
+ continue;
8518
+ }
8519
+ if (nowMs - markedAt < confirmMinMs) {
8520
+ nextMarks.set(camStreamId, markedAt);
8521
+ continue;
8522
+ }
8523
+ retract.push({
8524
+ camStreamId,
8525
+ absentForMs: nowMs - markedAt
8526
+ });
8527
+ }
8528
+ return {
8529
+ nextMarks,
8530
+ marked,
8531
+ cleared,
8532
+ retract
8533
+ };
8534
+ }
8535
+ //#endregion
8501
8536
  //#region src/stream-broker/rtmp/flv-parser.ts
8502
8537
  var AudioSoundFormat = /* @__PURE__ */ function(AudioSoundFormat) {
8503
8538
  AudioSoundFormat[AudioSoundFormat["PCM_BE"] = 0] = "PCM_BE";
@@ -17098,6 +17133,13 @@ var RtspEnabledSchema = require_dist.record(require_dist.string(), require_dist.
17098
17133
  * one observation.
17099
17134
  */
17100
17135
  var DEPARTURE_CONFIRM_MIN_MS = 3e4;
17136
+ /**
17137
+ * The catalog reconcile's second omission of a stream must be at least this
17138
+ * long after the first (D689) — the same one-poll-interval floor as the
17139
+ * departed-device sweep, for the same reason: an event-driven pull next to a
17140
+ * poll, or the startup pass's quick re-runs, are one observation.
17141
+ */
17142
+ var CATALOG_ABSENCE_CONFIRM_MIN_MS = DEPARTURE_CONFIRM_MIN_MS;
17101
17143
  var PUSH_KINDS = new Set(["push-annexb"]);
17102
17144
  /** Walk a profile map and return the slot that points at `camStreamId`, or null. */
17103
17145
  function findProfileForStream(map, camStreamId) {
@@ -17304,6 +17346,14 @@ var StreamBrokerManager = class StreamBrokerManager {
17304
17346
  * agrees, and a reappearance clears it (D49).
17305
17347
  */
17306
17348
  departureMarks = /* @__PURE__ */ new Map();
17349
+ /**
17350
+ * Per device: provider streams the broker holds that a successful catalog
17351
+ * pull OMITTED, and when (D689). A mark, not a verdict — see
17352
+ * `catalog-absence.ts`. Dropped when the stream is retracted by any path and
17353
+ * when the device is disabled (no pulls run then, so a mark would outlive
17354
+ * the observation it records).
17355
+ */
17356
+ catalogAbsenceMarks = /* @__PURE__ */ new Map();
17307
17357
  eventBus = null;
17308
17358
  /**
17309
17359
  * Per-broker stream health flag set by the watchdog. `undefined`
@@ -19034,6 +19084,7 @@ var StreamBrokerManager = class StreamBrokerManager {
19034
19084
  await this.releaseBroker(deviceId, camStreamId);
19035
19085
  streamMap.delete(camStreamId);
19036
19086
  if (streamMap.size === 0) this.cameraStreams.delete(deviceId);
19087
+ this.dropCatalogAbsenceMark(deviceId, camStreamId);
19037
19088
  this.emitCamStreamsChanged(deviceId);
19038
19089
  return { success: true };
19039
19090
  }
@@ -19095,13 +19146,21 @@ var StreamBrokerManager = class StreamBrokerManager {
19095
19146
  }
19096
19147
  /**
19097
19148
  * Pull one camera's `stream-catalog` and reconcile its cam-streams: upsert
19098
- * every descriptor, retract any cam-stream no longer in the catalog. A pull
19149
+ * every descriptor, retract a cam-stream the catalog no longer lists. A pull
19099
19150
  * that throws (provider mid-restart, no `stream-catalog` cap, a camera whose
19100
19151
  * session died) omits THIS DEVICE ONLY — it is logged at `warn` with the
19101
- * device's id and re-asked on the `catalog-pull-backoff.ts` ladder. An EMPTY
19102
- * catalog is also skipped rather than tearing down (a provider probe can
19103
- * transiently return none); genuine removal flows through the
19104
- * device-unregistered path.
19152
+ * device's id and re-asked on the `catalog-pull-backoff.ts` ladder.
19153
+ *
19154
+ * The retract DESTROYS work (broker, restreamer, every writer and viewer on
19155
+ * it), so it is never decided by one read (D689, D49): a pull that omits a
19156
+ * stream only marks it, and the retract needs a later successful pull, at
19157
+ * least {@link CATALOG_ABSENCE_CONFIRM_MIN_MS} after the mark, to agree. A
19158
+ * failed pull, or one that answered nothing at all (`null`), decides
19159
+ * nothing. Neither does an answered EMPTY catalog: providers answer `[]`
19160
+ * both for "not mine" and for "don't know yet" (a sleeping Reolink battery
19161
+ * camera, a rebooting Hikvision), so it is not a read that may destroy work
19162
+ * — it neither marks nor clears. Genuine removal of a whole camera is the
19163
+ * `DeviceUnregistered` path and the departed-device sweep (D685).
19105
19164
  *
19106
19165
  * `trigger` decides whether the backoff applies — see
19107
19166
  * {@link CatalogReconcileTrigger}. It defaults to `'event'` (always attempt)
@@ -19123,9 +19182,9 @@ var StreamBrokerManager = class StreamBrokerManager {
19123
19182
  });
19124
19183
  return;
19125
19184
  }
19126
- let descriptors;
19185
+ let answer;
19127
19186
  try {
19128
- descriptors = await this.withCatalogTimeout(this.api.streamCatalog.getCatalog.query({ deviceId }), deviceId) ?? [];
19187
+ answer = await this.withCatalogTimeout(this.api.streamCatalog.getCatalog.query({ deviceId }), deviceId);
19129
19188
  } catch (err) {
19130
19189
  const next = nextCatalogPullFailureState(this.catalogPullFailures.get(deviceId), Date.now());
19131
19190
  this.catalogPullFailures.set(deviceId, next);
@@ -19142,21 +19201,85 @@ var StreamBrokerManager = class StreamBrokerManager {
19142
19201
  }
19143
19202
  if (this.catalogPullFailures.delete(deviceId)) this.logger.info("stream-catalog pull recovered — device is back in the catalog", {
19144
19203
  tags: { deviceId },
19145
- meta: { descriptors: descriptors.length }
19204
+ meta: { descriptors: answer?.length ?? null }
19146
19205
  });
19147
- if (descriptors.length === 0) return;
19148
- const keep = new Set(descriptors.map((d) => d.camStreamId));
19206
+ if (answer === null || answer === void 0) {
19207
+ this.logger.debug("stream-catalog pull answered nothing — the registry is left as it is", {
19208
+ tags: { deviceId },
19209
+ meta: { trigger }
19210
+ });
19211
+ return;
19212
+ }
19213
+ const descriptors = answer;
19214
+ if (descriptors.length === 0) {
19215
+ this.logger.debug("stream-catalog pull answered empty — ignored: neither marks nor clears a stream (D689)", {
19216
+ tags: { deviceId },
19217
+ meta: {
19218
+ trigger,
19219
+ pendingMarks: this.catalogAbsenceMarks.get(deviceId)?.size ?? 0
19220
+ }
19221
+ });
19222
+ return;
19223
+ }
19149
19224
  await this.publishCatalog(deviceId, descriptors);
19225
+ await this.retractStreamsTwoPullsOmitted(deviceId, descriptors, trigger);
19226
+ }
19227
+ /**
19228
+ * The retract half of a SUCCESSFUL catalog pull (D689): mark what this pull
19229
+ * omitted, unmark what it lists again, retract what two pulls a reconcile
19230
+ * interval apart both omitted. See `catalog-absence.ts`.
19231
+ */
19232
+ async retractStreamsTwoPullsOmitted(deviceId, descriptors, trigger) {
19150
19233
  const existing = this.cameraStreams.get(deviceId);
19151
- if (existing) for (const [camStreamId, cam] of Array.from(existing.entries())) {
19152
- if (cam.brokerOwned) continue;
19153
- if (!keep.has(camStreamId)) await this.retractCameraStream({
19234
+ const candidates = existing ? Array.from(existing.values()).filter((cam) => !cam.brokerOwned).map((cam) => cam.camStreamId) : [];
19235
+ const verdict = judgeCatalogAbsence({
19236
+ marks: this.catalogAbsenceMarks.get(deviceId) ?? /* @__PURE__ */ new Map(),
19237
+ listed: new Set(descriptors.map((d) => d.camStreamId)),
19238
+ candidates,
19239
+ nowMs: Date.now(),
19240
+ confirmMinMs: CATALOG_ABSENCE_CONFIRM_MIN_MS
19241
+ });
19242
+ if (verdict.nextMarks.size > 0) this.catalogAbsenceMarks.set(deviceId, verdict.nextMarks);
19243
+ else this.catalogAbsenceMarks.delete(deviceId);
19244
+ for (const camStreamId of verdict.marked) this.logger.info("stream absent from the catalog — retracted only if a pull a reconcile interval later agrees", {
19245
+ tags: { deviceId },
19246
+ meta: {
19247
+ camStreamId,
19248
+ trigger,
19249
+ confirmAfterMs: CATALOG_ABSENCE_CONFIRM_MIN_MS
19250
+ }
19251
+ });
19252
+ for (const camStreamId of verdict.cleared) this.logger.info("stream is back in the catalog — pending retract cancelled", {
19253
+ tags: { deviceId },
19254
+ meta: {
19255
+ camStreamId,
19256
+ trigger
19257
+ }
19258
+ });
19259
+ for (const { camStreamId, absentForMs } of verdict.retract) {
19260
+ this.logger.warn("stream absent from two catalog pulls — retracted", {
19261
+ tags: { deviceId },
19262
+ meta: {
19263
+ camStreamId,
19264
+ absentForMs,
19265
+ trigger
19266
+ }
19267
+ });
19268
+ await this.retractCameraStream({
19154
19269
  deviceId,
19155
19270
  camStreamId,
19156
19271
  preserveAssignment: true
19157
19272
  });
19158
19273
  }
19159
19274
  }
19275
+ /** Forget one stream's pending catalog-absence mark — it was retracted by some path. */
19276
+ dropCatalogAbsenceMark(deviceId, camStreamId) {
19277
+ const marks = this.catalogAbsenceMarks.get(deviceId);
19278
+ if (!marks?.has(camStreamId)) return;
19279
+ const next = new Map([...marks].filter(([id]) => id !== camStreamId));
19280
+ if (next.size > 0) this.catalogAbsenceMarks.set(deviceId, next);
19281
+ else this.catalogAbsenceMarks.delete(deviceId);
19282
+ }
19160
19283
  /**
19161
19284
  * Enumerate every camera and reconcile its catalog. The reconcile backstop
19162
19285
  * (poll cadence) and the start-up fetch. Per-device failures are isolated so
@@ -19176,6 +19299,7 @@ var StreamBrokerManager = class StreamBrokerManager {
19176
19299
  for (const d of cameras) if (d.disabled) {
19177
19300
  const firstSeen = !this.disabledDevices.has(d.id);
19178
19301
  this.disabledDevices.add(d.id);
19302
+ this.catalogAbsenceMarks.delete(d.id);
19179
19303
  await this.releaseDisabledDeviceBrokers(d.id, firstSeen);
19180
19304
  } else this.disabledDevices.delete(d.id);
19181
19305
  const live = cameras.filter((d) => !d.disabled);
@@ -8494,6 +8494,41 @@ function resolveSourceParser(setting, sourceType) {
8494
8494
  }
8495
8495
  }
8496
8496
  //#endregion
8497
+ //#region src/stream-broker/catalog-absence.ts
8498
+ function judgeCatalogAbsence(input) {
8499
+ const { marks, listed, candidates, nowMs, confirmMinMs } = input;
8500
+ const nextMarks = /* @__PURE__ */ new Map();
8501
+ const marked = [];
8502
+ const cleared = [];
8503
+ const retract = [];
8504
+ for (const camStreamId of candidates) {
8505
+ const markedAt = marks.get(camStreamId);
8506
+ if (listed.has(camStreamId)) {
8507
+ if (markedAt !== void 0) cleared.push(camStreamId);
8508
+ continue;
8509
+ }
8510
+ if (markedAt === void 0) {
8511
+ nextMarks.set(camStreamId, nowMs);
8512
+ marked.push(camStreamId);
8513
+ continue;
8514
+ }
8515
+ if (nowMs - markedAt < confirmMinMs) {
8516
+ nextMarks.set(camStreamId, markedAt);
8517
+ continue;
8518
+ }
8519
+ retract.push({
8520
+ camStreamId,
8521
+ absentForMs: nowMs - markedAt
8522
+ });
8523
+ }
8524
+ return {
8525
+ nextMarks,
8526
+ marked,
8527
+ cleared,
8528
+ retract
8529
+ };
8530
+ }
8531
+ //#endregion
8497
8532
  //#region src/stream-broker/rtmp/flv-parser.ts
8498
8533
  var AudioSoundFormat = /* @__PURE__ */ function(AudioSoundFormat) {
8499
8534
  AudioSoundFormat[AudioSoundFormat["PCM_BE"] = 0] = "PCM_BE";
@@ -17094,6 +17129,13 @@ var RtspEnabledSchema = record(string(), boolean());
17094
17129
  * one observation.
17095
17130
  */
17096
17131
  var DEPARTURE_CONFIRM_MIN_MS = 3e4;
17132
+ /**
17133
+ * The catalog reconcile's second omission of a stream must be at least this
17134
+ * long after the first (D689) — the same one-poll-interval floor as the
17135
+ * departed-device sweep, for the same reason: an event-driven pull next to a
17136
+ * poll, or the startup pass's quick re-runs, are one observation.
17137
+ */
17138
+ var CATALOG_ABSENCE_CONFIRM_MIN_MS = DEPARTURE_CONFIRM_MIN_MS;
17097
17139
  var PUSH_KINDS = new Set(["push-annexb"]);
17098
17140
  /** Walk a profile map and return the slot that points at `camStreamId`, or null. */
17099
17141
  function findProfileForStream(map, camStreamId) {
@@ -17300,6 +17342,14 @@ var StreamBrokerManager = class StreamBrokerManager {
17300
17342
  * agrees, and a reappearance clears it (D49).
17301
17343
  */
17302
17344
  departureMarks = /* @__PURE__ */ new Map();
17345
+ /**
17346
+ * Per device: provider streams the broker holds that a successful catalog
17347
+ * pull OMITTED, and when (D689). A mark, not a verdict — see
17348
+ * `catalog-absence.ts`. Dropped when the stream is retracted by any path and
17349
+ * when the device is disabled (no pulls run then, so a mark would outlive
17350
+ * the observation it records).
17351
+ */
17352
+ catalogAbsenceMarks = /* @__PURE__ */ new Map();
17303
17353
  eventBus = null;
17304
17354
  /**
17305
17355
  * Per-broker stream health flag set by the watchdog. `undefined`
@@ -19030,6 +19080,7 @@ var StreamBrokerManager = class StreamBrokerManager {
19030
19080
  await this.releaseBroker(deviceId, camStreamId);
19031
19081
  streamMap.delete(camStreamId);
19032
19082
  if (streamMap.size === 0) this.cameraStreams.delete(deviceId);
19083
+ this.dropCatalogAbsenceMark(deviceId, camStreamId);
19033
19084
  this.emitCamStreamsChanged(deviceId);
19034
19085
  return { success: true };
19035
19086
  }
@@ -19091,13 +19142,21 @@ var StreamBrokerManager = class StreamBrokerManager {
19091
19142
  }
19092
19143
  /**
19093
19144
  * Pull one camera's `stream-catalog` and reconcile its cam-streams: upsert
19094
- * every descriptor, retract any cam-stream no longer in the catalog. A pull
19145
+ * every descriptor, retract a cam-stream the catalog no longer lists. A pull
19095
19146
  * that throws (provider mid-restart, no `stream-catalog` cap, a camera whose
19096
19147
  * session died) omits THIS DEVICE ONLY — it is logged at `warn` with the
19097
- * device's id and re-asked on the `catalog-pull-backoff.ts` ladder. An EMPTY
19098
- * catalog is also skipped rather than tearing down (a provider probe can
19099
- * transiently return none); genuine removal flows through the
19100
- * device-unregistered path.
19148
+ * device's id and re-asked on the `catalog-pull-backoff.ts` ladder.
19149
+ *
19150
+ * The retract DESTROYS work (broker, restreamer, every writer and viewer on
19151
+ * it), so it is never decided by one read (D689, D49): a pull that omits a
19152
+ * stream only marks it, and the retract needs a later successful pull, at
19153
+ * least {@link CATALOG_ABSENCE_CONFIRM_MIN_MS} after the mark, to agree. A
19154
+ * failed pull, or one that answered nothing at all (`null`), decides
19155
+ * nothing. Neither does an answered EMPTY catalog: providers answer `[]`
19156
+ * both for "not mine" and for "don't know yet" (a sleeping Reolink battery
19157
+ * camera, a rebooting Hikvision), so it is not a read that may destroy work
19158
+ * — it neither marks nor clears. Genuine removal of a whole camera is the
19159
+ * `DeviceUnregistered` path and the departed-device sweep (D685).
19101
19160
  *
19102
19161
  * `trigger` decides whether the backoff applies — see
19103
19162
  * {@link CatalogReconcileTrigger}. It defaults to `'event'` (always attempt)
@@ -19119,9 +19178,9 @@ var StreamBrokerManager = class StreamBrokerManager {
19119
19178
  });
19120
19179
  return;
19121
19180
  }
19122
- let descriptors;
19181
+ let answer;
19123
19182
  try {
19124
- descriptors = await this.withCatalogTimeout(this.api.streamCatalog.getCatalog.query({ deviceId }), deviceId) ?? [];
19183
+ answer = await this.withCatalogTimeout(this.api.streamCatalog.getCatalog.query({ deviceId }), deviceId);
19125
19184
  } catch (err) {
19126
19185
  const next = nextCatalogPullFailureState(this.catalogPullFailures.get(deviceId), Date.now());
19127
19186
  this.catalogPullFailures.set(deviceId, next);
@@ -19138,21 +19197,85 @@ var StreamBrokerManager = class StreamBrokerManager {
19138
19197
  }
19139
19198
  if (this.catalogPullFailures.delete(deviceId)) this.logger.info("stream-catalog pull recovered — device is back in the catalog", {
19140
19199
  tags: { deviceId },
19141
- meta: { descriptors: descriptors.length }
19200
+ meta: { descriptors: answer?.length ?? null }
19142
19201
  });
19143
- if (descriptors.length === 0) return;
19144
- const keep = new Set(descriptors.map((d) => d.camStreamId));
19202
+ if (answer === null || answer === void 0) {
19203
+ this.logger.debug("stream-catalog pull answered nothing — the registry is left as it is", {
19204
+ tags: { deviceId },
19205
+ meta: { trigger }
19206
+ });
19207
+ return;
19208
+ }
19209
+ const descriptors = answer;
19210
+ if (descriptors.length === 0) {
19211
+ this.logger.debug("stream-catalog pull answered empty — ignored: neither marks nor clears a stream (D689)", {
19212
+ tags: { deviceId },
19213
+ meta: {
19214
+ trigger,
19215
+ pendingMarks: this.catalogAbsenceMarks.get(deviceId)?.size ?? 0
19216
+ }
19217
+ });
19218
+ return;
19219
+ }
19145
19220
  await this.publishCatalog(deviceId, descriptors);
19221
+ await this.retractStreamsTwoPullsOmitted(deviceId, descriptors, trigger);
19222
+ }
19223
+ /**
19224
+ * The retract half of a SUCCESSFUL catalog pull (D689): mark what this pull
19225
+ * omitted, unmark what it lists again, retract what two pulls a reconcile
19226
+ * interval apart both omitted. See `catalog-absence.ts`.
19227
+ */
19228
+ async retractStreamsTwoPullsOmitted(deviceId, descriptors, trigger) {
19146
19229
  const existing = this.cameraStreams.get(deviceId);
19147
- if (existing) for (const [camStreamId, cam] of Array.from(existing.entries())) {
19148
- if (cam.brokerOwned) continue;
19149
- if (!keep.has(camStreamId)) await this.retractCameraStream({
19230
+ const candidates = existing ? Array.from(existing.values()).filter((cam) => !cam.brokerOwned).map((cam) => cam.camStreamId) : [];
19231
+ const verdict = judgeCatalogAbsence({
19232
+ marks: this.catalogAbsenceMarks.get(deviceId) ?? /* @__PURE__ */ new Map(),
19233
+ listed: new Set(descriptors.map((d) => d.camStreamId)),
19234
+ candidates,
19235
+ nowMs: Date.now(),
19236
+ confirmMinMs: CATALOG_ABSENCE_CONFIRM_MIN_MS
19237
+ });
19238
+ if (verdict.nextMarks.size > 0) this.catalogAbsenceMarks.set(deviceId, verdict.nextMarks);
19239
+ else this.catalogAbsenceMarks.delete(deviceId);
19240
+ for (const camStreamId of verdict.marked) this.logger.info("stream absent from the catalog — retracted only if a pull a reconcile interval later agrees", {
19241
+ tags: { deviceId },
19242
+ meta: {
19243
+ camStreamId,
19244
+ trigger,
19245
+ confirmAfterMs: CATALOG_ABSENCE_CONFIRM_MIN_MS
19246
+ }
19247
+ });
19248
+ for (const camStreamId of verdict.cleared) this.logger.info("stream is back in the catalog — pending retract cancelled", {
19249
+ tags: { deviceId },
19250
+ meta: {
19251
+ camStreamId,
19252
+ trigger
19253
+ }
19254
+ });
19255
+ for (const { camStreamId, absentForMs } of verdict.retract) {
19256
+ this.logger.warn("stream absent from two catalog pulls — retracted", {
19257
+ tags: { deviceId },
19258
+ meta: {
19259
+ camStreamId,
19260
+ absentForMs,
19261
+ trigger
19262
+ }
19263
+ });
19264
+ await this.retractCameraStream({
19150
19265
  deviceId,
19151
19266
  camStreamId,
19152
19267
  preserveAssignment: true
19153
19268
  });
19154
19269
  }
19155
19270
  }
19271
+ /** Forget one stream's pending catalog-absence mark — it was retracted by some path. */
19272
+ dropCatalogAbsenceMark(deviceId, camStreamId) {
19273
+ const marks = this.catalogAbsenceMarks.get(deviceId);
19274
+ if (!marks?.has(camStreamId)) return;
19275
+ const next = new Map([...marks].filter(([id]) => id !== camStreamId));
19276
+ if (next.size > 0) this.catalogAbsenceMarks.set(deviceId, next);
19277
+ else this.catalogAbsenceMarks.delete(deviceId);
19278
+ }
19156
19279
  /**
19157
19280
  * Enumerate every camera and reconcile its catalog. The reconcile backstop
19158
19281
  * (poll cadence) and the start-up fetch. Per-device failures are isolated so
@@ -19172,6 +19295,7 @@ var StreamBrokerManager = class StreamBrokerManager {
19172
19295
  for (const d of cameras) if (d.disabled) {
19173
19296
  const firstSeen = !this.disabledDevices.has(d.id);
19174
19297
  this.disabledDevices.add(d.id);
19298
+ this.catalogAbsenceMarks.delete(d.id);
19175
19299
  await this.releaseDisabledDeviceBrokers(d.id, firstSeen);
19176
19300
  } else this.disabledDevices.delete(d.id);
19177
19301
  const live = cameras.filter((d) => !d.disabled);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline",
3
- "version": "1.2.310",
3
+ "version": "1.2.312",
4
4
  "description": "Pipeline bundle — runner, detection, motion, audio + stream broker. Multi-entry npm package shipping pipeline addons under a single bundle.",
5
5
  "keywords": [
6
6
  "camstack",