@camstack/system 1.2.73 → 1.2.75

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.
@@ -788,6 +788,15 @@ function parseVerifiedSnapshotLink(input) {
788
788
  * answers inside it, short enough that a dead camera cannot stall the batch.
789
789
  */
790
790
  var LINK_MINT_DEADLINE_MS = 2500;
791
+ /**
792
+ * A links RPC is one Viewer-wide batch. Never let one slow camera hold every
793
+ * fast camera behind its individual capture deadline: wait briefly for the
794
+ * common fast path, then mint from the cache as it stands. Slow captures keep
795
+ * running and their new generation is returned by the next 5 s poll.
796
+ */
797
+ var LINK_BATCH_WAIT_MS = 750;
798
+ /** Frames older than this are never represented as current by the Viewer. */
799
+ var LINK_CURRENT_MAX_AGE_MS = 15e3;
791
800
  /** Default cache window for non-battery cams (seconds). 10s feels live. */
792
801
  var NON_BATTERY_DEFAULT_MAX_AGE_S = 10;
793
802
  /** Default cache window for battery cams (seconds). 1h ≈ "don't wake the cam unless asked". */
@@ -1033,8 +1042,10 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1033
1042
  *
1034
1043
  * So: note every target (creating a subscription, which `getSnapshotOverview`
1035
1044
  * deliberately cannot), then join the ordinary non-forced capture path and
1036
- * wait a BOUNDED time for it. What comes back is the identity of the frame the
1037
- * link will actually serve.
1045
+ * wait a BOUNDED time for it. The Viewer never presents a frame older than
1046
+ * 15 seconds as current, so this path tightens a looser per-device cache
1047
+ * window to that contract without using `force` (and its battery-wake
1048
+ * semantics). What comes back is the identity of the frame the link serves.
1038
1049
  *
1039
1050
  * The bound matters more than its exact value. A cold fleet is paced by
1040
1051
  * `MAX_CONCURRENT_GRABS`, so sixteen cameras cannot all capture at once and
@@ -1049,12 +1060,18 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1049
1060
  const expMs = snapshotLinkExpiry(now, SNAPSHOT_LINK_TTL_MS);
1050
1061
  const served = this.linkDataPlane !== null && secret !== null;
1051
1062
  const prefix = this.linkRoutePrefix();
1052
- const rows = await Promise.all(input.targets.map(async (target) => {
1063
+ const sleepingByDevice = /* @__PURE__ */ new Map();
1064
+ const refreshes = input.targets.map(async (target) => {
1065
+ const width = target.width === void 0 ? void 0 : snapSnapshotWidth(target.width);
1066
+ this.subscriptions.note(target.deviceId, void 0, width, now);
1067
+ const sleeping = await this.isSleepingBatteryDevice(target.deviceId);
1068
+ sleepingByDevice.set(target.deviceId, sleeping);
1069
+ if (!sleeping) await this.awaitFreshEnough(target.deviceId);
1070
+ });
1071
+ await raceForResult(Promise.allSettled(refreshes), LINK_BATCH_WAIT_MS);
1072
+ const rows = input.targets.map((target) => {
1053
1073
  const { deviceId } = target;
1054
1074
  const width = target.width === void 0 ? void 0 : snapSnapshotWidth(target.width);
1055
- this.subscriptions.note(deviceId, void 0, width, now);
1056
- const sleeping = await this.isSleepingBatteryDevice(deviceId);
1057
- if (!sleeping) await this.awaitFreshEnough(deviceId);
1058
1075
  const capturedAt = this.cache.latest(deviceId)?.ts ?? null;
1059
1076
  const answeredAt = Date.now();
1060
1077
  return {
@@ -1072,9 +1089,9 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1072
1089
  expiresAt: served ? expMs : null,
1073
1090
  width: width ?? null,
1074
1091
  neverCaptured: capturedAt === null,
1075
- sleeping
1092
+ sleeping: sleepingByDevice.get(deviceId) ?? false
1076
1093
  };
1077
- }));
1094
+ });
1078
1095
  this.ctx.logger.debug("snapshot: minted links", { meta: {
1079
1096
  targets: input.targets.length,
1080
1097
  served,
@@ -1085,19 +1102,25 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1085
1102
  /**
1086
1103
  * Join the ordinary capture path for one device and wait, bounded, for it.
1087
1104
  *
1088
- * Deliberately `getSnapshot` with `force: false` and nothing else: every gate
1089
- * that already owns this decision the per-device max-age, the single flight,
1090
- * the grab pool, the sleeping-battery refusal — applies unchanged. A fresh
1091
- * cache returns immediately; a stale one starts the refresh. There is no
1092
- * second freshness policy here, which is the mistake D93 called out.
1105
+ * Deliberately `getSnapshot` with `force: false`: the single flight, grab
1106
+ * pool, and sleeping-battery refusal apply unchanged. The only override is
1107
+ * the Viewer's 15-second currency contract and the link-mint wait bound.
1093
1108
  */
1094
1109
  async awaitFreshEnough(deviceId) {
1095
- if (!(await raceForResult(this.getSnapshot({
1110
+ const before = this.cache.latest(deviceId)?.ts ?? null;
1111
+ const alreadyCurrent = before !== null && Date.now() - before <= LINK_CURRENT_MAX_AGE_MS;
1112
+ await this.getSnapshot({
1096
1113
  deviceId,
1097
1114
  force: false
1098
- }).catch(() => null), LINK_MINT_DEADLINE_MS)).settled) this.ctx.logger.debug("snapshot: link mint gave up waiting; serving the older frame", {
1115
+ }, 0, LINK_MINT_DEADLINE_MS, LINK_CURRENT_MAX_AGE_MS).catch(() => null);
1116
+ const current = this.cache.latest(deviceId)?.ts ?? null;
1117
+ if (alreadyCurrent || current !== null && (before === null || current > before)) return;
1118
+ this.ctx.logger.debug("snapshot: link mint gave up waiting; serving the older frame", {
1099
1119
  tags: { deviceId },
1100
- meta: { deadlineMs: LINK_MINT_DEADLINE_MS }
1120
+ meta: {
1121
+ deadlineMs: LINK_MINT_DEADLINE_MS,
1122
+ previousCapturedAt: before
1123
+ }
1101
1124
  });
1102
1125
  }
1103
1126
  /** True only for a BATTERY device that is currently asleep. Kept as one
@@ -1346,7 +1369,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1346
1369
  })]
1347
1370
  }] });
1348
1371
  }
1349
- async getSnapshot(input, warmAheadMs = 0) {
1372
+ async getSnapshot(input, warmAheadMs = 0, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY) {
1350
1373
  const { deviceId } = input;
1351
1374
  const force = input.force === true;
1352
1375
  const meta = await this.lookupDeviceMeta(deviceId);
@@ -1365,7 +1388,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1365
1388
  meta: { stream: effectiveStreamId ?? "auto" }
1366
1389
  });
1367
1390
  const hit = this.cache.get(deviceId, effectiveStreamId);
1368
- const effectiveMaxAgeMs = Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3 - warmAheadMs);
1391
+ const effectiveMaxAgeMs = Math.min(maximumCacheAgeMs, Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3 - warmAheadMs));
1369
1392
  const decision = decideSnapshotServe({
1370
1393
  now,
1371
1394
  cachedAt: hit?.ts ?? null,
@@ -1402,7 +1425,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1402
1425
  log
1403
1426
  }));
1404
1427
  flight.catch(() => void 0);
1405
- const raced = await raceForResult(flight, decision.waitMs);
1428
+ const raced = await raceForResult(flight, Math.max(decision.waitMs, minimumCaptureWaitMs));
1406
1429
  if (raced.settled) return this.resolveOutcome(raced.value, deviceId, hit, log);
1407
1430
  if (decision.staleFallback && hit) {
1408
1431
  if (prefs.snapshotDebug) log.debug("snapshot: SWR — returning stale frame; refresh continues in background", {
@@ -783,6 +783,15 @@ function parseVerifiedSnapshotLink(input) {
783
783
  * answers inside it, short enough that a dead camera cannot stall the batch.
784
784
  */
785
785
  var LINK_MINT_DEADLINE_MS = 2500;
786
+ /**
787
+ * A links RPC is one Viewer-wide batch. Never let one slow camera hold every
788
+ * fast camera behind its individual capture deadline: wait briefly for the
789
+ * common fast path, then mint from the cache as it stands. Slow captures keep
790
+ * running and their new generation is returned by the next 5 s poll.
791
+ */
792
+ var LINK_BATCH_WAIT_MS = 750;
793
+ /** Frames older than this are never represented as current by the Viewer. */
794
+ var LINK_CURRENT_MAX_AGE_MS = 15e3;
786
795
  /** Default cache window for non-battery cams (seconds). 10s feels live. */
787
796
  var NON_BATTERY_DEFAULT_MAX_AGE_S = 10;
788
797
  /** Default cache window for battery cams (seconds). 1h ≈ "don't wake the cam unless asked". */
@@ -1028,8 +1037,10 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1028
1037
  *
1029
1038
  * So: note every target (creating a subscription, which `getSnapshotOverview`
1030
1039
  * deliberately cannot), then join the ordinary non-forced capture path and
1031
- * wait a BOUNDED time for it. What comes back is the identity of the frame the
1032
- * link will actually serve.
1040
+ * wait a BOUNDED time for it. The Viewer never presents a frame older than
1041
+ * 15 seconds as current, so this path tightens a looser per-device cache
1042
+ * window to that contract without using `force` (and its battery-wake
1043
+ * semantics). What comes back is the identity of the frame the link serves.
1033
1044
  *
1034
1045
  * The bound matters more than its exact value. A cold fleet is paced by
1035
1046
  * `MAX_CONCURRENT_GRABS`, so sixteen cameras cannot all capture at once and
@@ -1044,12 +1055,18 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1044
1055
  const expMs = snapshotLinkExpiry(now, SNAPSHOT_LINK_TTL_MS);
1045
1056
  const served = this.linkDataPlane !== null && secret !== null;
1046
1057
  const prefix = this.linkRoutePrefix();
1047
- const rows = await Promise.all(input.targets.map(async (target) => {
1058
+ const sleepingByDevice = /* @__PURE__ */ new Map();
1059
+ const refreshes = input.targets.map(async (target) => {
1060
+ const width = target.width === void 0 ? void 0 : snapSnapshotWidth(target.width);
1061
+ this.subscriptions.note(target.deviceId, void 0, width, now);
1062
+ const sleeping = await this.isSleepingBatteryDevice(target.deviceId);
1063
+ sleepingByDevice.set(target.deviceId, sleeping);
1064
+ if (!sleeping) await this.awaitFreshEnough(target.deviceId);
1065
+ });
1066
+ await raceForResult(Promise.allSettled(refreshes), LINK_BATCH_WAIT_MS);
1067
+ const rows = input.targets.map((target) => {
1048
1068
  const { deviceId } = target;
1049
1069
  const width = target.width === void 0 ? void 0 : snapSnapshotWidth(target.width);
1050
- this.subscriptions.note(deviceId, void 0, width, now);
1051
- const sleeping = await this.isSleepingBatteryDevice(deviceId);
1052
- if (!sleeping) await this.awaitFreshEnough(deviceId);
1053
1070
  const capturedAt = this.cache.latest(deviceId)?.ts ?? null;
1054
1071
  const answeredAt = Date.now();
1055
1072
  return {
@@ -1067,9 +1084,9 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1067
1084
  expiresAt: served ? expMs : null,
1068
1085
  width: width ?? null,
1069
1086
  neverCaptured: capturedAt === null,
1070
- sleeping
1087
+ sleeping: sleepingByDevice.get(deviceId) ?? false
1071
1088
  };
1072
- }));
1089
+ });
1073
1090
  this.ctx.logger.debug("snapshot: minted links", { meta: {
1074
1091
  targets: input.targets.length,
1075
1092
  served,
@@ -1080,19 +1097,25 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1080
1097
  /**
1081
1098
  * Join the ordinary capture path for one device and wait, bounded, for it.
1082
1099
  *
1083
- * Deliberately `getSnapshot` with `force: false` and nothing else: every gate
1084
- * that already owns this decision the per-device max-age, the single flight,
1085
- * the grab pool, the sleeping-battery refusal — applies unchanged. A fresh
1086
- * cache returns immediately; a stale one starts the refresh. There is no
1087
- * second freshness policy here, which is the mistake D93 called out.
1100
+ * Deliberately `getSnapshot` with `force: false`: the single flight, grab
1101
+ * pool, and sleeping-battery refusal apply unchanged. The only override is
1102
+ * the Viewer's 15-second currency contract and the link-mint wait bound.
1088
1103
  */
1089
1104
  async awaitFreshEnough(deviceId) {
1090
- if (!(await raceForResult(this.getSnapshot({
1105
+ const before = this.cache.latest(deviceId)?.ts ?? null;
1106
+ const alreadyCurrent = before !== null && Date.now() - before <= LINK_CURRENT_MAX_AGE_MS;
1107
+ await this.getSnapshot({
1091
1108
  deviceId,
1092
1109
  force: false
1093
- }).catch(() => null), LINK_MINT_DEADLINE_MS)).settled) this.ctx.logger.debug("snapshot: link mint gave up waiting; serving the older frame", {
1110
+ }, 0, LINK_MINT_DEADLINE_MS, LINK_CURRENT_MAX_AGE_MS).catch(() => null);
1111
+ const current = this.cache.latest(deviceId)?.ts ?? null;
1112
+ if (alreadyCurrent || current !== null && (before === null || current > before)) return;
1113
+ this.ctx.logger.debug("snapshot: link mint gave up waiting; serving the older frame", {
1094
1114
  tags: { deviceId },
1095
- meta: { deadlineMs: LINK_MINT_DEADLINE_MS }
1115
+ meta: {
1116
+ deadlineMs: LINK_MINT_DEADLINE_MS,
1117
+ previousCapturedAt: before
1118
+ }
1096
1119
  });
1097
1120
  }
1098
1121
  /** True only for a BATTERY device that is currently asleep. Kept as one
@@ -1341,7 +1364,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1341
1364
  })]
1342
1365
  }] });
1343
1366
  }
1344
- async getSnapshot(input, warmAheadMs = 0) {
1367
+ async getSnapshot(input, warmAheadMs = 0, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY) {
1345
1368
  const { deviceId } = input;
1346
1369
  const force = input.force === true;
1347
1370
  const meta = await this.lookupDeviceMeta(deviceId);
@@ -1360,7 +1383,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1360
1383
  meta: { stream: effectiveStreamId ?? "auto" }
1361
1384
  });
1362
1385
  const hit = this.cache.get(deviceId, effectiveStreamId);
1363
- const effectiveMaxAgeMs = Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3 - warmAheadMs);
1386
+ const effectiveMaxAgeMs = Math.min(maximumCacheAgeMs, Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3 - warmAheadMs));
1364
1387
  const decision = decideSnapshotServe({
1365
1388
  now,
1366
1389
  cachedAt: hit?.ts ?? null,
@@ -1397,7 +1420,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1397
1420
  log
1398
1421
  }));
1399
1422
  flight.catch(() => void 0);
1400
- const raced = await raceForResult(flight, decision.waitMs);
1423
+ const raced = await raceForResult(flight, Math.max(decision.waitMs, minimumCaptureWaitMs));
1401
1424
  if (raced.settled) return this.resolveOutcome(raced.value, deviceId, hit, log);
1402
1425
  if (decision.staleFallback && hit) {
1403
1426
  if (prefs.snapshotDebug) log.debug("snapshot: SWR — returning stale frame; refresh continues in background", {
@@ -146,8 +146,10 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
146
146
  *
147
147
  * So: note every target (creating a subscription, which `getSnapshotOverview`
148
148
  * deliberately cannot), then join the ordinary non-forced capture path and
149
- * wait a BOUNDED time for it. What comes back is the identity of the frame the
150
- * link will actually serve.
149
+ * wait a BOUNDED time for it. The Viewer never presents a frame older than
150
+ * 15 seconds as current, so this path tightens a looser per-device cache
151
+ * window to that contract without using `force` (and its battery-wake
152
+ * semantics). What comes back is the identity of the frame the link serves.
151
153
  *
152
154
  * The bound matters more than its exact value. A cold fleet is paced by
153
155
  * `MAX_CONCURRENT_GRABS`, so sixteen cameras cannot all capture at once and
@@ -160,11 +162,9 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
160
162
  /**
161
163
  * Join the ordinary capture path for one device and wait, bounded, for it.
162
164
  *
163
- * Deliberately `getSnapshot` with `force: false` and nothing else: every gate
164
- * that already owns this decision the per-device max-age, the single flight,
165
- * the grab pool, the sleeping-battery refusal — applies unchanged. A fresh
166
- * cache returns immediately; a stale one starts the refresh. There is no
167
- * second freshness policy here, which is the mistake D93 called out.
165
+ * Deliberately `getSnapshot` with `force: false`: the single flight, grab
166
+ * pool, and sleeping-battery refusal apply unchanged. The only override is
167
+ * the Viewer's 15-second currency contract and the link-mint wait bound.
168
168
  */
169
169
  private awaitFreshEnough;
170
170
  /** True only for a BATTERY device that is currently asleep. Kept as one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/system",
3
- "version": "1.2.73",
3
+ "version": "1.2.75",
4
4
  "description": "Core addon for CamStack — builtins, pipeline, process management, auth, logging, events",
5
5
  "keywords": [
6
6
  "camstack",