@camstack/addon-pipeline 1.2.295 → 1.2.297

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 (40) hide show
  1. package/THIRD_PARTY_MODELS.md +8 -0
  2. package/dist/audio-analyzer/index.js +2 -2
  3. package/dist/audio-analyzer/index.mjs +2 -2
  4. package/dist/{default-detection-model-wNEISVA9.js → default-detection-model-B2n_SFXt.js} +442 -50
  5. package/dist/{default-detection-model-CAUBVbgK.mjs → default-detection-model-D6DypCkI.mjs} +413 -51
  6. package/dist/detection-pipeline/index.js +1516 -269
  7. package/dist/detection-pipeline/index.mjs +1515 -268
  8. package/dist/{dist-CuxSNLKW.mjs → dist-CxHIpsQs.mjs} +834 -398
  9. package/dist/{dist-BsVcf5wO.js → dist-DUqr1zyq.js} +845 -409
  10. package/dist/motion-wasm/index.js +1 -1
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-UFk6I2f6.js → node-Dx6DLQ1j.js} +1 -1
  13. package/dist/{node-Cqb1QiXk.mjs → node-DyeWu78a.mjs} +1 -1
  14. package/dist/pipeline-runner/index.js +906 -310
  15. package/dist/pipeline-runner/index.mjs +906 -310
  16. package/dist/{process-memory-D0Nvs9rr.mjs → process-memory-BVR4592X.mjs} +1 -1
  17. package/dist/{process-memory-CJV29sPf.js → process-memory-CMJ3NY-s.js} +1 -1
  18. package/dist/recorder/index.js +4 -6
  19. package/dist/recorder/index.mjs +4 -6
  20. package/dist/{segment-demux-js-DGwmf5hu.js → segment-demux-js-CmGIF_uK.js} +1 -1
  21. package/dist/{segment-demux-js-DIDWw1iE.mjs → segment-demux-js-Dzga-XgE.mjs} +1 -1
  22. package/dist/session-decode/{decode-worker-child.js → decode-worker-main.js} +481 -72
  23. package/dist/session-decode/{decode-worker-child.mjs → decode-worker-main.mjs} +482 -71
  24. package/dist/stream-broker/_stub.js +2 -2
  25. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B7nBFqva.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-P71ze8cu.mjs} +2 -2
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CZkpFU-J.mjs +26 -0
  27. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Dti8Ex88.mjs +26 -0
  28. package/dist/stream-broker/demux-worker-child.js +1 -1
  29. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  30. package/dist/stream-broker/{hostInit-BKlb3qac.mjs → hostInit-CRlStbz4.mjs} +2 -2
  31. package/dist/stream-broker/index.js +4 -4
  32. package/dist/stream-broker/index.mjs +4 -4
  33. package/dist/stream-broker/remoteEntry.js +1 -1
  34. package/dist/{worker-protocol-B2MfQLlu.js → worker-protocol-C-G8qmye.js} +3 -1
  35. package/dist/{worker-protocol-C_W-P_g-.mjs → worker-protocol-D_NzPcnh.mjs} +3 -1
  36. package/package.json +1 -1
  37. package/python/inference_pool.py +422 -64
  38. package/python/test_inference_pool_compile_off_loop.py +414 -0
  39. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CCIyBvRa.mjs +0 -26
  40. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DeD_UFdb.mjs +0 -26
@@ -1,7 +1,7 @@
1
- import { An as lazy, D as MOTION_CLOSE_AFTER_MS, Dn as array, En as _enum, In as string, Mn as number, Mt as pickNativeLeaseOverride, Nn as object, O as NativeLeaseAdmissionSchema, On as boolean, Pt as pipelineRunnerCapability, Rn as EventCategory, en as errMsg, h as DEFAULT_DETAIL_CROP_CONVENTION, hn as defineCustomActions, i as BackgroundRunRegistry, jn as literal, jt as pickDetailCropConvention, k as NativeLeaseSettingsSchema, mn as customAction, nt as deriveDetailCropRect, pn as createEvent, pt as failureContributionCapability, sn as BaseAddon, v as DEFAULT_NATIVE_LEASE_SETTINGS, vt as loadContributionCapability, w as FailureCounters, xn as nodePin, y as DEVICE_BACKEND_TO_FORMAT } from "../dist-CuxSNLKW.mjs";
2
- import { i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-Cqb1QiXk.mjs";
3
- import { a as localFrameRegistry, d as getStepDefinition, i as rootObjectGate, o as resolveFrameViewGeometry, p as RETIRED_MODEL_IDS, r as macroGateVerdict } from "../default-detection-model-CAUBVbgK.mjs";
4
- import { t as isWorkerReply } from "../worker-protocol-C_W-P_g-.mjs";
1
+ import { An as lazy, At as pickDetailCropConvention, D as MOTION_CLOSE_AFTER_MS, Dn as array, En as _enum, In as string, Mn as number, Nn as object, Nt as pipelineRunnerCapability, O as NativeLeaseAdmissionSchema, On as boolean, Rn as EventCategory, en as errMsg, h as DEFAULT_DETAIL_CROP_CONVENTION, hn as defineCustomActions, i as BackgroundRunRegistry, jn as literal, jt as pickNativeLeaseOverride, k as NativeLeaseSettingsSchema, mn as customAction, nt as deriveDetailCropRect, pn as createEvent, pt as failureContributionCapability, sn as BaseAddon, v as DEFAULT_NATIVE_LEASE_SETTINGS, vt as loadContributionCapability, w as FailureCounters, xn as nodePin } from "../dist-CxHIpsQs.mjs";
2
+ import { i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-DyeWu78a.mjs";
3
+ import { c as macroGateVerdict, d as resolveFrameViewGeometry, g as getStepDefinition, l as rootObjectGate, o as placeDetailStep, r as CustomModelCatalog, s as stepsForElectedPool, u as localFrameRegistry, v as RETIRED_MODEL_IDS } from "../default-detection-model-D6DypCkI.mjs";
4
+ import { t as isWorkerReply } from "../worker-protocol-D_NzPcnh.mjs";
5
5
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
6
6
  import { t as getSharp } from "../lazy-sharp-DXsqwpph.mjs";
7
7
  import { c as withDetectionIntent, o as preRollRequestOf, t as DETECTION_PRE_ROLL_REQUEST_MS } from "../restream-intent-DyGmLAU9.mjs";
@@ -14,7 +14,7 @@ import { PerformanceObserver } from "node:perf_hooks";
14
14
  import { attributeStall } from "@camstack/system/addon-utils";
15
15
  //#region src/session-decode/fork-decode-worker.ts
16
16
  /** Absolute path to the built decode-worker child entry (a vite build entry). */
17
- var CHILD_ENTRY_PATH = fileURLToPath(new URL("../session-decode/decode-worker-child.js", import.meta.url));
17
+ var CHILD_ENTRY_PATH = fileURLToPath(new URL("../session-decode/decode-worker-main.js", import.meta.url));
18
18
  /**
19
19
  * Known kill signals the coordinator may pass, mapped to their `NodeJS.Signals`
20
20
  * literal so `ChildProcess.kill` receives a correctly-typed signal without a
@@ -35,21 +35,36 @@ var KILL_SIGNALS = {
35
35
  * every listener added out here is one somebody can forget to remove.
36
36
  */
37
37
  function wrapChild(child, onExit) {
38
- child.on("error", () => void 0);
39
- child.on("exit", onExit);
38
+ let spawned = false;
39
+ let exitDelivered = false;
40
+ const exitListeners = [];
41
+ const deliverExit = (info) => {
42
+ if (exitDelivered) return;
43
+ exitDelivered = true;
44
+ onExit();
45
+ for (const listener of exitListeners) listener(info);
46
+ };
47
+ child.on("spawn", () => {
48
+ spawned = true;
49
+ });
50
+ child.on("exit", (code, signal) => deliverExit({
51
+ code: code ?? null,
52
+ signal: signal ?? null
53
+ }));
54
+ child.on("error", () => {
55
+ if (!spawned) deliverExit({
56
+ code: null,
57
+ signal: null
58
+ });
59
+ });
40
60
  return {
41
61
  send: (msg) => {
42
62
  if (!child.connected) return;
43
63
  child.send(msg, () => void 0);
44
64
  },
45
65
  on: (ev, cb) => {
46
- if (ev === "exit") {
47
- const toInfo = (code, signal) => ({
48
- code,
49
- signal
50
- });
51
- child.on("exit", (code, signal) => cb(toInfo(code ?? null, signal ?? null)));
52
- } else child.on("message", (m) => cb(m));
66
+ if (ev === "exit") exitListeners.push(cb);
67
+ else child.on("message", (m) => cb(m));
53
68
  },
54
69
  kill: (signal) => {
55
70
  if (signal === void 0) {
@@ -250,58 +265,77 @@ var DEFAULT_ACQUIRE_RETRY_DELAYS_MS = [
250
265
  15e3,
251
266
  3e4
252
267
  ];
253
- /**
254
- * Guards the in-flight restream-acquire against a detach/unsubscribe race,
255
- * and retries an acquire that fails.
256
- *
257
- * `acquireSessionDecodeRestream` + forking the decode worker both take real
258
- * time (a broker round-trip, then a process fork). If the caller's teardown
259
- * runs while that acquire is still in flight, the closure that would release
260
- * the restream and stop the pump does not exist yet by the time the caller
261
- * stores it — a naive `await acquire(); return () => {...}` construction
262
- * leaks the acquired restream refcount and, once the acquire resolves, forks
263
- * an orphan decode worker nobody will ever stop.
264
- *
265
- * This helper returns the teardown SYNCHRONOUSLY. The acquire + pump-start
266
- * run in the background; an `aborted` flag recorded by the (possibly
267
- * already-called) teardown is checked right after each acquire resolves —
268
- * if set, any restream is released immediately and the pump is NEVER
269
- * started (no orphan worker). Otherwise the pump starts and the teardown,
270
- * once called, stops the pump then releases the restream.
271
- *
272
- * **A failed acquire is retried, not swallowed.** The teardown handed back
273
- * here is stored by the caller (as `attachment.detectionUnsubscribe`) the
274
- * instant this returns, so a `return` on failure left that handle permanently
275
- * non-null with no pump behind it: `onEnded` could never fire because nothing
276
- * had ever started, the camera's phase stayed `active`, and not one frame
277
- * arrived again. The motion path was hardened against this shape by a frame
278
- * freshness window (`MOTION_FRESHNESS_WINDOW_MS`); this is the detection
279
- * path's half.
280
- */
268
+ /** A restream release that runs once however many paths reach it. */
269
+ function onceRelease(release) {
270
+ let done = false;
271
+ return () => {
272
+ if (done) return Promise.resolve();
273
+ done = true;
274
+ return release();
275
+ };
276
+ }
281
277
  function startGuardedSessionDecode(deps) {
282
278
  let aborted = false;
279
+ /** The mode the teardown asked for — replayed if it raced `startPump`. */
280
+ let abortMode = "drain";
283
281
  let stopPump = null;
284
282
  let release = null;
285
283
  let retryTimer = null;
286
284
  /** The live-worker slot held by the RUNNING session, released on teardown. */
287
285
  let workerLease = null;
288
286
  const delays = deps.acquireRetryDelaysMs && deps.acquireRetryDelaysMs.length > 0 ? deps.acquireRetryDelaysMs : DEFAULT_ACQUIRE_RETRY_DELAYS_MS;
289
- /** Resolves after the backoff for `attempt`, or immediately once aborted. */
290
- const waitBackoff = (attempt) => new Promise((resolve) => {
287
+ /** Ends a parked start's wait when the session is torn down. */
288
+ let abortWait = null;
289
+ /**
290
+ * This start's place in the ceiling queue — taken on its first refusal, kept
291
+ * to the end so a re-park keeps the FIFO position (D652).
292
+ */
293
+ let waiter = null;
294
+ /**
295
+ * The start was handed a returned slot and has not yet ASKED for it. Set on
296
+ * a wake, cleared by the next `admitWorker` call; a start torn down while
297
+ * this is set passes the slot on (`SlotWaiter.leave(true)`), or nobody gets
298
+ * it and every other parked start waits out its full timer.
299
+ */
300
+ let wakeInHand = false;
301
+ /**
302
+ * Resolves after the backoff for `attempt` (`'timer'`) — or, when `parked`,
303
+ * as soon as the node hands this start a returned slot (`'woken'`) or the
304
+ * session is torn down (`'torn-down'`), whichever is first. The start leaves
305
+ * the queue on every exit, so a torn-down start leaves no listener behind.
306
+ */
307
+ const waitBackoff = (attempt, parked) => new Promise((resolve) => {
291
308
  const ms = delays[Math.min(attempt, delays.length - 1)] ?? delays[delays.length - 1] ?? 2e3;
292
- retryTimer = setTimeout(() => {
293
- retryTimer = null;
294
- resolve();
295
- }, ms);
309
+ const done = (why) => {
310
+ if (retryTimer !== null) {
311
+ clearTimeout(retryTimer);
312
+ retryTimer = null;
313
+ }
314
+ parked?.cancel();
315
+ abortWait = null;
316
+ resolve(why);
317
+ };
318
+ retryTimer = setTimeout(() => done("timer"), ms);
296
319
  retryTimer.unref?.();
320
+ abortWait = () => done("torn-down");
321
+ if (parked) parked.wait(() => done("woken"));
297
322
  });
298
- (async () => {
323
+ const releaseWorkerSlot = () => {
324
+ workerLease?.release();
325
+ workerLease = null;
326
+ };
327
+ const startLoop = async () => {
299
328
  for (let attempt = 0; !aborted; attempt++) {
300
329
  let lease = null;
301
330
  if (deps.admitWorker) {
331
+ wakeInHand = false;
302
332
  lease = deps.admitWorker();
303
333
  if (!lease) {
304
- await waitBackoff(attempt);
334
+ waiter ??= deps.slotWaiter?.() ?? null;
335
+ if (await waitBackoff(attempt, waiter) === "woken") {
336
+ attempt -= 1;
337
+ wakeInHand = true;
338
+ }
305
339
  continue;
306
340
  }
307
341
  }
@@ -314,7 +348,7 @@ function startGuardedSessionDecode(deps) {
314
348
  attempt: attempt + 1,
315
349
  error: errMsg(err)
316
350
  } });
317
- await waitBackoff(attempt);
351
+ await waitBackoff(attempt, null);
318
352
  continue;
319
353
  }
320
354
  if (aborted) {
@@ -325,27 +359,47 @@ function startGuardedSessionDecode(deps) {
325
359
  if (!acquired) {
326
360
  lease?.release();
327
361
  (attempt === 0 ? deps.logger.warn : deps.logger.debug).call(deps.logger, "session-decode: no broker profile bound yet — retrying", { meta: { attempt: attempt + 1 } });
328
- await waitBackoff(attempt);
362
+ await waitBackoff(attempt, null);
329
363
  continue;
330
364
  }
331
365
  if (attempt > 0) deps.logger.info("session-decode: acquire succeeded after retry", { meta: { attempts: attempt + 1 } });
332
- release = acquired.release;
366
+ release = onceRelease(acquired.release);
333
367
  workerLease = lease;
334
- stopPump = deps.startPump(acquired.source);
368
+ try {
369
+ stopPump = deps.startPump(acquired.source, { onWorkerGone: releaseWorkerSlot });
370
+ } catch (err) {
371
+ releaseWorkerSlot();
372
+ const restream = release;
373
+ release = null;
374
+ restream?.();
375
+ throw err;
376
+ }
377
+ if (aborted) stopPump(abortMode);
335
378
  return;
336
379
  }
337
- })().catch((err) => {
380
+ };
381
+ startLoop().catch((err) => {
338
382
  deps.logger.warn("session-decode: guarded start failed", { meta: { error: errMsg(err) } });
383
+ }).finally(() => {
384
+ waiter?.leave(wakeInHand);
385
+ waiter = null;
339
386
  });
340
- return () => {
387
+ return (mode = "drain") => {
388
+ if (aborted) {
389
+ if (mode === "immediate" && abortMode !== "immediate") {
390
+ abortMode = mode;
391
+ stopPump?.(mode);
392
+ }
393
+ return;
394
+ }
341
395
  aborted = true;
396
+ abortMode = mode;
342
397
  if (retryTimer !== null) {
343
398
  clearTimeout(retryTimer);
344
399
  retryTimer = null;
345
400
  }
346
- stopPump?.();
347
- workerLease?.release();
348
- workerLease = null;
401
+ abortWait?.();
402
+ stopPump?.(mode);
349
403
  if (release) release();
350
404
  };
351
405
  }
@@ -414,6 +468,21 @@ var LiveDecodeWorkerAdmission = class {
414
468
  limit;
415
469
  logger;
416
470
  live = 0;
471
+ /**
472
+ * Starts the ceiling refused, waiting to be told a slot came back — FIFO by
473
+ * the start's FIRST park (D652): the key's `seq` is fixed when the start
474
+ * takes its {@link SlotWaiter}, so a re-park keeps the place.
475
+ */
476
+ parked = /* @__PURE__ */ new Map();
477
+ nextSeq = 0;
478
+ /**
479
+ * Cameras currently refused. The refusal WARN fires on the transition into
480
+ * this set, not on every retry: a start woken or retried while the node is
481
+ * still full says so at debug (D652 — a wake must not become a WARN storm).
482
+ * Cleared on admit AND when a parked start leaves (`SlotWaiter.leave`): a
483
+ * camera torn down while refused must be news again next time (D391).
484
+ */
485
+ refusedDevices = /* @__PURE__ */ new Set();
417
486
  nodeBudgetMb;
418
487
  workerFootprintMb;
419
488
  constructor(options) {
@@ -422,6 +491,48 @@ var LiveDecodeWorkerAdmission = class {
422
491
  this.nodeBudgetMb = positiveOrUndefined(options.nodeBudgetMb);
423
492
  this.workerFootprintMb = positiveOrUndefined(options.workerFootprintMb);
424
493
  }
494
+ /** Starts parked on a {@link SlotWaiter} right now — diagnostics and tests. */
495
+ get slotWaiters() {
496
+ return this.parked.size;
497
+ }
498
+ /**
499
+ * This start's place in the queue. A refused start waits on it instead of a
500
+ * fixed timer, so a slot freed by a draining worker's exit is taken at once
501
+ * (D652). Take it once per start, on the first refusal; keep it to the end.
502
+ */
503
+ slotWaiter(deviceId) {
504
+ const key = {
505
+ seq: this.nextSeq,
506
+ deviceId
507
+ };
508
+ this.nextSeq += 1;
509
+ return {
510
+ wait: (listener) => {
511
+ this.parked.set(key, listener);
512
+ },
513
+ cancel: () => {
514
+ this.parked.delete(key);
515
+ },
516
+ leave: (unusedWake) => {
517
+ this.parked.delete(key);
518
+ this.refusedDevices.delete(deviceId);
519
+ if (unusedWake) this.wakeNext();
520
+ }
521
+ };
522
+ }
523
+ /**
524
+ * ONE slot, ONE wake: the longest-parked start (lowest `seq`). Waking all N
525
+ * produced N−1 refusals per freed slot. The woken start leaves the queue; if
526
+ * something else took the slot first it parks again, at the same place.
527
+ */
528
+ wakeNext() {
529
+ let first;
530
+ for (const key of this.parked.keys()) if (first === void 0 || key.seq < first.seq) first = key;
531
+ if (first === void 0) return;
532
+ const wake = this.parked.get(first);
533
+ this.parked.delete(first);
534
+ wake?.();
535
+ }
425
536
  /** Workers currently holding a slot. */
426
537
  get inFlight() {
427
538
  return this.live;
@@ -478,7 +589,9 @@ var LiveDecodeWorkerAdmission = class {
478
589
  admit(deviceId, label) {
479
590
  const ceiling = this.ceiling;
480
591
  if (this.live >= ceiling) {
481
- this.logger.warn("session-decode: REFUSED — node live decode-worker ceiling reached, will retry", {
592
+ const firstRefusal = !this.refusedDevices.has(deviceId);
593
+ this.refusedDevices.add(deviceId);
594
+ (firstRefusal ? this.logger.warn : this.logger.debug).call(this.logger, "session-decode: REFUSED — node live decode-worker ceiling reached, will retry", {
482
595
  tags: { deviceId },
483
596
  meta: {
484
597
  label,
@@ -494,6 +607,7 @@ var LiveDecodeWorkerAdmission = class {
494
607
  return null;
495
608
  }
496
609
  this.live += 1;
610
+ this.refusedDevices.delete(deviceId);
497
611
  this.logger.debug("session-decode: live decode-worker slot taken", {
498
612
  tags: { deviceId },
499
613
  meta: {
@@ -507,6 +621,7 @@ var LiveDecodeWorkerAdmission = class {
507
621
  if (released) return;
508
622
  released = true;
509
623
  this.live -= 1;
624
+ this.wakeNext();
510
625
  } };
511
626
  }
512
627
  };
@@ -516,6 +631,28 @@ function positiveOrUndefined(value) {
516
631
  return Number.isFinite(value) && value > 0 ? value : void 0;
517
632
  }
518
633
  //#endregion
634
+ //#region src/pipeline-runner/retained-reader-hold.ts
635
+ /**
636
+ * Run `read` with `handle`'s parked frame held as a CONSUMER for its whole
637
+ * duration (D656).
638
+ *
639
+ * `getNativeCrop` has two rungs: a native crop, then — on a miss — the retained
640
+ * full-frame fallback. Between them nothing is in flight: the crop's reply
641
+ * settles the worker's last pending request in the same turn, and the fallback's
642
+ * `toBuffer` is only sent on a later microtask. A parked frame does not keep a
643
+ * draining worker alive, so without this hold the drain killed the worker in
644
+ * that gap and the fallback was lost. One rule instead of a timing trick: a
645
+ * read in progress is a consumer.
646
+ */
647
+ async function withRetainedReader(store, handle, read) {
648
+ const release = store?.holdReader(handle) ?? null;
649
+ try {
650
+ return await read();
651
+ } finally {
652
+ release?.();
653
+ }
654
+ }
655
+ //#endregion
519
656
  //#region src/session-decode/native-crop-lease-age.ts
520
657
  /**
521
658
  * THE age bound on a native crop lease (ADR-0419).
@@ -858,6 +995,8 @@ function createRetainedContentFallback(registry, ref) {
858
995
  timestamp: ref.timestamp,
859
996
  ...ref.capturedAt !== void 0 ? { capturedAt: ref.capturedAt } : {}
860
997
  };
998
+ } catch {
999
+ return null;
861
1000
  } finally {
862
1001
  lease.release(reason);
863
1002
  }
@@ -1300,6 +1439,14 @@ var RetainedFrameStore = class RetainedFrameStore {
1300
1439
  if (current) current.hydrating = void 0;
1301
1440
  }
1302
1441
  }
1442
+ /**
1443
+ * Hold `handle`'s parked frame as a reader until the returned release runs
1444
+ * (D656). `null` when the handle has no un-hydrated fallback — bytes already
1445
+ * in RAM need no worker.
1446
+ */
1447
+ holdReader(handle) {
1448
+ return this.entries.get(keyOf(handle))?.fallback?.holdReader?.() ?? null;
1449
+ }
1303
1450
  /** Drop every retained frame (node/addon shutdown). */
1304
1451
  clear() {
1305
1452
  for (const entry of this.entries.values()) entry.fallback?.release?.();
@@ -1443,6 +1590,48 @@ function cropRgb(frame, bbox, maxWidth) {
1443
1590
  /** How long to wait after `kill()` before escalating to `kill('SIGKILL')`. */
1444
1591
  var SIGKILL_FALLBACK_MS = 1e3;
1445
1592
  /**
1593
+ * The longest a planned teardown keeps a session's worker alive so the frames
1594
+ * it already handed out can be read (D652).
1595
+ *
1596
+ * What is being waited for is the REST of a frame's trip through the runner —
1597
+ * semaphore wait + inference + result — for frames that were admitted before
1598
+ * the detach. Measured on the live hub 2026-09-26 over one hour of
1599
+ * `pipeline stats` (2 254 per-camera 10 s windows): the WORST enqueue→emit in a
1600
+ * window was ≤1 000 ms in 96.7% of windows and ≤1 500 ms in 98.0%; going to
1601
+ * 5 000 ms buys only another 0.9%, and that tail is the inference-pool GPU hang,
1602
+ * which no wait here can rescue. The cost of waiting is one idle worker (its
1603
+ * decode already stopped by `drain`) for at most this long, per detach.
1604
+ *
1605
+ * A frame still held when this expires is not dropped silently: the
1606
+ * coordinator logs the count, tagged with the camera, and then kills.
1607
+ */
1608
+ var SESSION_DECODE_DRAIN_BOUND_MS = 1500;
1609
+ /**
1610
+ * The miss reason for a native crop requested after its session's teardown.
1611
+ * Only crops already SENT when the drain began are waited for (D652); a later
1612
+ * one would ask a worker that is about to be killed.
1613
+ */
1614
+ var NATIVE_CROP_REFUSED_DRAINING = "session draining — crop requested after teardown";
1615
+ /**
1616
+ * The refusal for a `toBuffer` asked for once the worker has been told to exit
1617
+ * (D656). A read during a DRAIN is served — that is what the drain is for — but
1618
+ * after the kill no reply can come, and a raw rejection read as a worker fault.
1619
+ */
1620
+ var TO_BUFFER_REFUSED_KILLED = "session killed — read requested after the worker was told to exit";
1621
+ /** The refusal for a `toBuffer` the worker is no longer there to answer. */
1622
+ var TO_BUFFER_REFUSED_EXITED = "worker already exited";
1623
+ /** A read the session declined to send, by name — never a worker fault. */
1624
+ var SessionDecodeReadRefusedError = class extends Error {
1625
+ reason;
1626
+ frameId;
1627
+ constructor(reason, frameId) {
1628
+ super(`session-decode-coordinator: ${reason} (frameId=${frameId})`);
1629
+ this.reason = reason;
1630
+ this.frameId = frameId;
1631
+ this.name = "SessionDecodeReadRefusedError";
1632
+ }
1633
+ };
1634
+ /**
1446
1635
  * How often the cold-start wait REPORTS that it is still waiting. Task 0
1447
1636
  * live-measured cold-start (restream dial + hwaccel context + keyframe wait) at
1448
1637
  * 5-8s, so this must tolerate a slow-but-healthy start.
@@ -1514,30 +1703,45 @@ var SessionDecodeSession = class {
1514
1703
  firstFrameTimeoutMs;
1515
1704
  frameTimeoutMs;
1516
1705
  firstFrameMaxWaitMs;
1517
- onDialTiming;
1518
- onPreRollReport;
1706
+ hooks;
1519
1707
  pendingPull = null;
1520
1708
  pendingToBuffers = /* @__PURE__ */ new Map();
1521
1709
  /** Outstanding `nativeCrop` requests, keyed by the monotonic `requestId`. */
1522
1710
  pendingNativeCrops = /* @__PURE__ */ new Map();
1523
1711
  nextNativeCropId = 1;
1524
1712
  exited = false;
1525
- /** Set by teardown() so handleExit can tell an intentional stop from a death. */
1713
+ /** Set by teardown() — the session is ending, whether or not it drains first. */
1526
1714
  stopRequested = false;
1715
+ /** Set when `kill()` is sent, so handleExit can tell an intentional stop from a death. */
1716
+ killSent = false;
1717
+ /**
1718
+ * Images yielded and not yet closed that a CONSUMER still holds — queued,
1719
+ * in flight, or being read. Each dies with the worker and somebody was going
1720
+ * to read it first. This is what a drain waits on.
1721
+ */
1722
+ consumerImages = 0;
1723
+ /**
1724
+ * Images yielded and not yet closed that only a RETENTION holder keeps —
1725
+ * parked for a read that may come later (the on-demand JPEG fallback, D656).
1726
+ * They die with the worker too, but nobody reads them inside any bound worth
1727
+ * holding a worker for, so a drain does NOT wait on them.
1728
+ */
1729
+ retainedImages = 0;
1730
+ /** Armed while a drain waits; its expiry kills with frames still held. */
1731
+ drainTimer = null;
1527
1732
  sigkillTimer = null;
1528
1733
  pullTimeoutTimer = null;
1529
1734
  firstFrameReceived = false;
1530
1735
  /** `Date.now()` of the FIRST `pull` — the origin of the cold-start ceiling. */
1531
1736
  firstPullAt = 0;
1532
- constructor(child, logger, deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs, onDialTiming, onPreRollReport) {
1737
+ constructor(child, logger, deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs, hooks) {
1533
1738
  this.child = child;
1534
1739
  this.logger = logger;
1535
1740
  this.deviceId = deviceId;
1536
1741
  this.firstFrameTimeoutMs = firstFrameTimeoutMs;
1537
1742
  this.frameTimeoutMs = frameTimeoutMs;
1538
1743
  this.firstFrameMaxWaitMs = firstFrameMaxWaitMs;
1539
- this.onDialTiming = onDialTiming;
1540
- this.onPreRollReport = onPreRollReport;
1744
+ this.hooks = hooks;
1541
1745
  this.child.on("message", (m) => this.handleMessage(m));
1542
1746
  this.child.on("exit", (m) => this.handleExit(toWorkerExitInfo(m)));
1543
1747
  }
@@ -1576,7 +1780,8 @@ var SessionDecodeSession = class {
1576
1780
  }
1577
1781
  /** Send `toBuffer` for `frameId` and resolve with the matching `buffer` reply's bytes. */
1578
1782
  requestBuffer(frameId, opts) {
1579
- if (this.exited) return Promise.reject(/* @__PURE__ */ new Error(`session-decode-coordinator: worker already exited (frameId=${frameId})`));
1783
+ if (this.exited) return Promise.reject(new SessionDecodeReadRefusedError(TO_BUFFER_REFUSED_EXITED, frameId));
1784
+ if (this.killSent) return Promise.reject(new SessionDecodeReadRefusedError(TO_BUFFER_REFUSED_KILLED, frameId));
1580
1785
  return new Promise((resolve, reject) => {
1581
1786
  const waiter = {
1582
1787
  resolve,
@@ -1602,6 +1807,10 @@ var SessionDecodeSession = class {
1602
1807
  */
1603
1808
  requestNativeCrop(frameId, bbox, maxWidth) {
1604
1809
  if (this.exited) return Promise.resolve({ kind: "miss" });
1810
+ if (this.stopRequested) return Promise.resolve({
1811
+ kind: "miss",
1812
+ workerReason: NATIVE_CROP_REFUSED_DRAINING
1813
+ });
1605
1814
  const requestId = this.nextNativeCropId++;
1606
1815
  return new Promise((resolve) => {
1607
1816
  this.pendingNativeCrops.set(requestId, { resolve });
@@ -1616,6 +1825,7 @@ var SessionDecodeSession = class {
1616
1825
  } catch {
1617
1826
  this.pendingNativeCrops.delete(requestId);
1618
1827
  resolve({ kind: "miss" });
1828
+ this.settleDrainIfDone();
1619
1829
  }
1620
1830
  });
1621
1831
  }
@@ -1660,20 +1870,169 @@ var SessionDecodeSession = class {
1660
1870
  frameId
1661
1871
  });
1662
1872
  }
1663
- /** Teardown: `stop`, `kill()`, arm the SIGKILL fallback unless already exited. */
1664
- teardown() {
1873
+ /** A yielded image the consumer now holds — see {@link consumerImages}. */
1874
+ noteImageOpened() {
1875
+ this.consumerImages += 1;
1876
+ }
1877
+ /**
1878
+ * An open image moved between a consumer and a retention-only holder
1879
+ * (D656). Losing its last consumer may complete a waiting drain.
1880
+ */
1881
+ noteImageHolder(pendingConsumer) {
1882
+ if (pendingConsumer) {
1883
+ this.retainedImages = Math.max(0, this.retainedImages - 1);
1884
+ this.consumerImages += 1;
1885
+ return;
1886
+ }
1887
+ this.consumerImages = Math.max(0, this.consumerImages - 1);
1888
+ this.retainedImages += 1;
1889
+ this.settleDrainIfDone();
1890
+ }
1891
+ /** A yielded image was closed; a waiting drain may now be done. */
1892
+ noteImageClosed(pendingConsumer) {
1893
+ if (pendingConsumer) this.consumerImages = Math.max(0, this.consumerImages - 1);
1894
+ else this.retainedImages = Math.max(0, this.retainedImages - 1);
1895
+ this.settleDrainIfDone();
1896
+ }
1897
+ /**
1898
+ * End the session.
1899
+ *
1900
+ * `'drain'` with frames still held: tell the worker to stop decoding (it
1901
+ * keeps every frame it holds), and kill only once every yielded image has
1902
+ * been closed and every read answered — or when
1903
+ * {@link SESSION_DECODE_DRAIN_BOUND_MS} expires, which is logged. Nothing
1904
+ * held: `stop` + SIGTERM now. `'immediate'`: SIGKILL now.
1905
+ *
1906
+ * A SECOND call that says `'immediate'` while a drain is in progress
1907
+ * ESCALATES (D652): addon shutdown lands on sessions already draining from
1908
+ * a detach, and the runner exits right after — a drain it does not wait for
1909
+ * is an orphaned worker holding its GPU pool. Any other repeat is ignored.
1910
+ */
1911
+ teardown(mode) {
1912
+ if (this.stopRequested) {
1913
+ if (mode === "immediate") this.escalateToImmediate();
1914
+ return;
1915
+ }
1665
1916
  this.stopRequested = true;
1666
1917
  this.clearPullTimeout();
1918
+ if (this.exited) return;
1919
+ if (mode === "drain" && this.unsettledCount() > 0) {
1920
+ this.trySend({ kind: "drain" });
1921
+ this.drainTimer = setTimeout(() => this.handleDrainBoundExpired(), SESSION_DECODE_DRAIN_BOUND_MS);
1922
+ this.drainTimer.unref?.();
1923
+ return;
1924
+ }
1925
+ this.killNow(mode);
1926
+ }
1927
+ /** The drain (or a SIGTERM still waiting on its fallback) gives way to SIGKILL now. */
1928
+ escalateToImmediate() {
1929
+ if (this.exited) return;
1930
+ if (this.drainTimer !== null) {
1931
+ clearTimeout(this.drainTimer);
1932
+ this.drainTimer = null;
1933
+ }
1934
+ if (!this.killSent) {
1935
+ this.killNow("immediate");
1936
+ return;
1937
+ }
1938
+ if (this.sigkillTimer !== null) {
1939
+ clearTimeout(this.sigkillTimer);
1940
+ this.sigkillTimer = null;
1941
+ }
1942
+ this.tryKill("SIGKILL");
1943
+ }
1944
+ /**
1945
+ * Frames a CONSUMER still holds, reads still waiting on the worker, and
1946
+ * native crops already REQUESTED. A crop asked for before the teardown
1947
+ * request is waited for; one asked for after it is refused by name — post-analysis's
1948
+ * ordinary crop arrives 2.2–9.9 s after the frame, past any bound worth
1949
+ * holding a worker for, and is served from subject tiles that die with the
1950
+ * worker either way.
1951
+ *
1952
+ * A frame only PARKED for such a late read ({@link retainedImages}) is not
1953
+ * counted, for the same reason (D656): counting it made nearly every
1954
+ * teardown wait the full bound for frames nobody was going to read. Its read,
1955
+ * once STARTED, is a pending read like any other and is waited for.
1956
+ */
1957
+ unsettledCount() {
1958
+ return this.consumerImages + this.pendingReadCount() + this.pendingNativeCrops.size;
1959
+ }
1960
+ /** What the session still holds, per category — every drain line carries it. */
1961
+ heldCounts() {
1962
+ return {
1963
+ consumerFrames: this.consumerImages,
1964
+ retainedFrames: this.retainedImages,
1965
+ pendingReads: this.pendingReadCount(),
1966
+ pendingCrops: this.pendingNativeCrops.size
1967
+ };
1968
+ }
1969
+ pendingReadCount() {
1970
+ let n = 0;
1971
+ for (const waiters of this.pendingToBuffers.values()) n += waiters.length;
1972
+ return n;
1973
+ }
1974
+ settleDrainIfDone() {
1975
+ if (this.drainTimer === null || this.unsettledCount() > 0) return;
1976
+ clearTimeout(this.drainTimer);
1977
+ this.drainTimer = null;
1978
+ this.killNow("drain");
1979
+ }
1980
+ /**
1981
+ * The drain ran out of time with frames still held. Those frames die with
1982
+ * the worker; that is a branch that DROPS work (D391), so it says how many,
1983
+ * on which camera.
1984
+ */
1985
+ handleDrainBoundExpired() {
1986
+ this.drainTimer = null;
1987
+ if (this.exited) return;
1988
+ this.logger.warn("session-decode-coordinator: drain bound expired — killing the worker with frames still held", {
1989
+ tags: { deviceId: this.deviceId },
1990
+ meta: {
1991
+ ...this.heldCounts(),
1992
+ boundMs: SESSION_DECODE_DRAIN_BOUND_MS
1993
+ }
1994
+ });
1995
+ this.killNow("drain");
1996
+ }
1997
+ /**
1998
+ * `stop`, then the kill — unless already exited.
1999
+ *
2000
+ * `'drain'`: SIGTERM, so the child flushes its last lines (`bounded-exit.ts`),
2001
+ * and a SIGKILL fallback. `'immediate'`: SIGKILL outright. Since D652 gave the
2002
+ * child a SIGTERM handler, SIGTERM is no longer instant death: a worker
2003
+ * blocked in a native call (a VAAPI hang) never runs the handler, and the
2004
+ * fallback was an `unref`'d timer the exiting parent did not wait for — so
2005
+ * on shutdown that worker was orphaned with its GPU pool. The fallback timer
2006
+ * is now ref'd: it exists only for a drain while the parent is alive (a
2007
+ * shutdown escalates to SIGKILL and clears it), and a timer that keeps the
2008
+ * process up for at most one second is the price of a kill that lands.
2009
+ */
2010
+ killNow(mode) {
2011
+ if (this.killSent) return;
2012
+ this.killSent = true;
2013
+ if (this.retainedImages > 0) this.logger.debug("session-decode-coordinator: retained frames die with the worker", {
2014
+ tags: { deviceId: this.deviceId },
2015
+ meta: {
2016
+ mode,
2017
+ ...this.heldCounts()
2018
+ }
2019
+ });
1667
2020
  this.trySend({ kind: "stop" });
2021
+ if (mode === "immediate") {
2022
+ this.tryKill("SIGKILL");
2023
+ return;
2024
+ }
1668
2025
  this.tryKill(void 0);
1669
2026
  if (this.exited) return;
1670
2027
  this.sigkillTimer = setTimeout(() => {
1671
2028
  this.sigkillTimer = null;
1672
2029
  if (this.exited) return;
1673
- this.logger.warn("session-decode-coordinator: child did not exit within SIGKILL fallback window — forcing", { meta: { fallbackMs: SIGKILL_FALLBACK_MS } });
2030
+ this.logger.warn("session-decode-coordinator: child did not exit within SIGKILL fallback window — forcing", {
2031
+ tags: { deviceId: this.deviceId },
2032
+ meta: { fallbackMs: SIGKILL_FALLBACK_MS }
2033
+ });
1674
2034
  this.tryKill("SIGKILL");
1675
2035
  }, SIGKILL_FALLBACK_MS);
1676
- this.sigkillTimer.unref?.();
1677
2036
  }
1678
2037
  handleMessage(raw) {
1679
2038
  if (!isWorkerReply(raw)) {
@@ -1722,7 +2081,7 @@ var SessionDecodeSession = class {
1722
2081
  this.logger[raw.level](raw.message);
1723
2082
  return;
1724
2083
  case "dialTiming":
1725
- this.onDialTiming?.({
2084
+ this.hooks.onDialTiming?.({
1726
2085
  hwContextMs: raw.hwContextMs,
1727
2086
  demuxerOpenMs: raw.demuxerOpenMs,
1728
2087
  decoderCreateMs: raw.decoderCreateMs,
@@ -1730,25 +2089,43 @@ var SessionDecodeSession = class {
1730
2089
  streamInfoProbed: raw.streamInfoProbed
1731
2090
  });
1732
2091
  return;
2092
+ case "preRollSettled":
2093
+ this.hooks.onPreRollSettled?.({
2094
+ read: raw.read,
2095
+ released: raw.released,
2096
+ lostByReason: raw.lostByReason,
2097
+ unsettled: raw.unsettled
2098
+ });
2099
+ return;
1733
2100
  case "preRollReport":
1734
- this.onPreRollReport?.({
2101
+ this.hooks.onPreRollReport?.({
1735
2102
  offered: raw.offered,
1736
2103
  inferred: raw.inferred,
1737
2104
  dropped: raw.dropped,
1738
2105
  droppedByReason: raw.droppedByReason,
1739
2106
  strideMs: raw.strideMs,
1740
2107
  subsampled: raw.subsampled,
1741
- spanMs: raw.spanMs
2108
+ spanMs: raw.spanMs,
2109
+ budgetFrames: raw.budgetFrames
1742
2110
  });
1743
2111
  return;
1744
2112
  }
1745
2113
  }
1746
2114
  handleExit(info) {
1747
- if (!this.stopRequested && !this.exited) this.logger.warn("session-decode worker exited unexpectedly", { meta: {
1748
- code: info?.code ?? null,
1749
- signal: info?.signal ?? null
1750
- } });
2115
+ if (!this.exited) this.hooks.onWorkerExit?.();
2116
+ if (!this.killSent && !this.exited) this.logger.warn(this.stopRequested ? "session-decode worker exited unexpectedly while draining" : "session-decode worker exited unexpectedly", {
2117
+ tags: { deviceId: this.deviceId },
2118
+ meta: {
2119
+ code: info?.code ?? null,
2120
+ signal: info?.signal ?? null,
2121
+ ...this.stopRequested ? this.heldCounts() : {}
2122
+ }
2123
+ });
1751
2124
  this.exited = true;
2125
+ if (this.drainTimer) {
2126
+ clearTimeout(this.drainTimer);
2127
+ this.drainTimer = null;
2128
+ }
1752
2129
  if (this.sigkillTimer) {
1753
2130
  clearTimeout(this.sigkillTimer);
1754
2131
  this.sigkillTimer = null;
@@ -1770,6 +2147,7 @@ var SessionDecodeSession = class {
1770
2147
  if (!waiter) return;
1771
2148
  this.pendingNativeCrops.delete(requestId);
1772
2149
  waiter.resolve(attempt);
2150
+ this.settleDrainIfDone();
1773
2151
  }
1774
2152
  settlePendingPull(outcome) {
1775
2153
  const waiter = this.pendingPull;
@@ -1865,6 +2243,7 @@ var SessionDecodeSession = class {
1865
2243
  const waiter = queue.shift();
1866
2244
  if (queue.length === 0) this.pendingToBuffers.delete(frameId);
1867
2245
  waiter?.resolve(bytes);
2246
+ this.settleDrainIfDone();
1868
2247
  }
1869
2248
  /**
1870
2249
  * `error` replies MAY carry a `frameId` (a `toBuffer`-scoped miss, e.g. a
@@ -1884,6 +2263,7 @@ var SessionDecodeSession = class {
1884
2263
  if (queue && queue.length > 0) {
1885
2264
  for (const waiter of queue) waiter.reject(err);
1886
2265
  this.pendingToBuffers.delete(frameId);
2266
+ this.settleDrainIfDone();
1887
2267
  return;
1888
2268
  }
1889
2269
  this.logger.debug("session-decode-coordinator: dropping toBuffer error for unknown frameId", { meta: { frameId } });
@@ -1898,6 +2278,7 @@ var SessionDecodeSession = class {
1898
2278
  }
1899
2279
  for (const waiters of this.pendingToBuffers.values()) for (const waiter of waiters) waiter.reject(err);
1900
2280
  this.pendingToBuffers.clear();
2281
+ this.settleDrainIfDone();
1901
2282
  }
1902
2283
  queueToBufferWaiter(frameId, waiter) {
1903
2284
  const queue = this.pendingToBuffers.get(frameId);
@@ -1935,6 +2316,8 @@ var SessionDecodeSession = class {
1935
2316
  /** Build the {@link FrameImage} adapter for one `frame` reply. */
1936
2317
  function buildFrameImage(session, reply) {
1937
2318
  let closed = false;
2319
+ let pendingConsumer = true;
2320
+ session.noteImageOpened();
1938
2321
  return {
1939
2322
  width: reply.width,
1940
2323
  height: reply.height,
@@ -1948,8 +2331,15 @@ function buildFrameImage(session, reply) {
1948
2331
  cutTiles: (bboxes) => session.cutTiles(reply.frameId, bboxes),
1949
2332
  releaseNativeLease: () => session.releaseLease(reply.frameId),
1950
2333
  markInferred: () => session.markInferred(reply.frameId),
2334
+ setPendingConsumer: (pending) => {
2335
+ if (closed || pending === pendingConsumer) return;
2336
+ pendingConsumer = pending;
2337
+ session.noteImageHolder(pending);
2338
+ },
1951
2339
  close: () => {
2340
+ if (closed) return;
1952
2341
  closed = true;
2342
+ session.noteImageClosed(pendingConsumer);
1953
2343
  }
1954
2344
  };
1955
2345
  }
@@ -1958,15 +2348,23 @@ function buildFrameImage(session, reply) {
1958
2348
  * `generateVideoFrames()` call, drives it with `pull`/`toBuffer` requests
1959
2349
  * one at a time (no pre-fetch — back-pressure is the async generator's
1960
2350
  * natural suspend-on-yield), and guarantees the child is torn down
1961
- * (`stop` + `kill()` + SIGKILL fallback) in every exit path via `finally`.
2351
+ * (bounded drain, then `stop` + `kill()` + SIGKILL fallback) in every exit
2352
+ * path via `finally`.
1962
2353
  */
1963
2354
  function createSessionDecodeCoordinator(deps) {
1964
2355
  const firstFrameTimeoutMs = deps.firstFrameTimeoutMs ?? DEFAULT_FIRST_FRAME_TIMEOUT_MS;
1965
2356
  const frameTimeoutMs = deps.frameTimeoutMs ?? DEFAULT_FRAME_TIMEOUT_MS;
1966
2357
  const firstFrameMaxWaitMs = Math.max(deps.firstFrameMaxWaitMs ?? DEFAULT_FIRST_FRAME_MAX_WAIT_MS, firstFrameTimeoutMs);
1967
2358
  return { async *generateVideoFrames(source, opts) {
1968
- const session = new SessionDecodeSession(deps.fork(source), deps.logger, source.deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs, deps.onDialTiming, deps.onPreRollReport);
2359
+ const session = new SessionDecodeSession(deps.fork(source), deps.logger, source.deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs, {
2360
+ ...deps.onDialTiming ? { onDialTiming: deps.onDialTiming } : {},
2361
+ ...deps.onPreRollReport ? { onPreRollReport: deps.onPreRollReport } : {},
2362
+ ...deps.onPreRollSettled ? { onPreRollSettled: deps.onPreRollSettled } : {},
2363
+ ...deps.onWorkerExit ? { onWorkerExit: deps.onWorkerExit } : {}
2364
+ });
1969
2365
  session.start(source, opts, deps.lease());
2366
+ deps.onSessionTeardown?.((mode) => session.teardown(mode));
2367
+ let failed = false;
1970
2368
  try {
1971
2369
  while (true) {
1972
2370
  const outcome = await session.pullNext();
@@ -1979,8 +2377,11 @@ function createSessionDecodeCoordinator(deps) {
1979
2377
  ...reply.preRoll === true ? { preRoll: true } : {}
1980
2378
  };
1981
2379
  }
2380
+ } catch (err) {
2381
+ failed = true;
2382
+ throw err;
1982
2383
  } finally {
1983
- session.teardown();
2384
+ session.teardown(failed ? "immediate" : deps.teardownMode?.() ?? "drain");
1984
2385
  }
1985
2386
  } };
1986
2387
  }
@@ -2024,17 +2425,39 @@ async function toDecodedFrame(frame, format, viewSpec) {
2024
2425
  * Start pulling frames from a per-session decode worker and feed each decoded
2025
2426
  * frame to `onDecodedFrame`. Returns an idempotent teardown closure that stops
2026
2427
  * the async generator (`it.return()`) — which triggers the coordinator's
2027
- * `finally` → `stop` + `kill()` + 1s SIGKILL fallback → worker exit → the OS
2028
- * reclaims the VAAPI/GPU decode pool. Fire-and-forget: the returned closure is
2029
- * synchronous; the frame loop and generator teardown run on their own tasks.
2428
+ * `finally` → a bounded DRAIN of the frames already handed out (D652), then
2429
+ * `stop` + SIGTERM + 1s SIGKILL fallback → worker exit → the OS reclaims the
2430
+ * VAAPI/GPU decode pool. `teardown('immediate')` SIGKILLs at once (shutdown),
2431
+ * and said a second time during a drain it escalates that drain to the kill.
2432
+ * Fire-and-forget: the returned closure is synchronous; the frame loop and
2433
+ * generator teardown run on their own tasks.
2030
2434
  */
2031
2435
  function startSessionDecodePump(deps) {
2032
2436
  const format = deps.opts.format ?? "rgb";
2033
2437
  let dialTiming = null;
2438
+ let teardownMode = "drain";
2439
+ let escalateSession = null;
2440
+ let forked = false;
2441
+ let workerGone = false;
2442
+ const noteWorkerGone = () => {
2443
+ if (workerGone) return;
2444
+ workerGone = true;
2445
+ deps.onWorkerGone?.();
2446
+ };
2034
2447
  const iterator = createSessionDecodeCoordinator({
2035
- fork: deps.fork,
2448
+ fork: (source) => {
2449
+ const child = deps.fork(source);
2450
+ forked = true;
2451
+ return child;
2452
+ },
2036
2453
  lease: deps.lease,
2037
2454
  logger: deps.logger,
2455
+ teardownMode: () => teardownMode,
2456
+ onSessionTeardown: (teardown) => {
2457
+ escalateSession = teardown;
2458
+ },
2459
+ onWorkerExit: noteWorkerGone,
2460
+ ...deps.onPreRollSettled ? { onPreRollSettled: deps.onPreRollSettled } : {},
2038
2461
  onDialTiming: (timing) => {
2039
2462
  dialTiming = timing;
2040
2463
  },
@@ -2140,13 +2563,21 @@ function startSessionDecodePump(deps) {
2140
2563
  });
2141
2564
  } finally {
2142
2565
  releaseSpawnSlotOnce();
2566
+ if (!forked) noteWorkerGone();
2143
2567
  if (!stopped) deps.onEnded?.();
2144
2568
  }
2145
2569
  };
2146
2570
  pump();
2147
- return () => {
2148
- if (stopped) return;
2571
+ return (mode = "drain") => {
2572
+ if (stopped) {
2573
+ if (mode === "immediate" && teardownMode !== "immediate") {
2574
+ teardownMode = "immediate";
2575
+ escalateSession?.("immediate");
2576
+ }
2577
+ return;
2578
+ }
2149
2579
  stopped = true;
2580
+ teardownMode = mode;
2150
2581
  releaseSpawnSlotOnce();
2151
2582
  iterator.return().catch(() => {});
2152
2583
  };
@@ -3576,115 +4007,14 @@ async function runDetailSubtree(deps, input) {
3576
4007
  } : void 0;
3577
4008
  const details = [];
3578
4009
  for (const step of steps) {
3579
- const deviceKey = deps.resolveStepDevice?.(input.deviceId, step);
3580
- const result = await deps.runPipeline([step], crop.jpeg, input.deviceId, deviceKey, nativeCropRef);
4010
+ const placement = deps.placeStep?.(input.deviceId, step);
4011
+ const result = await deps.runPipeline([placement?.step ?? step], crop.jpeg, input.deviceId, placement?.deviceKey, nativeCropRef);
3581
4012
  if (!result) continue;
3582
4013
  for (const detection of result.detections) details.push(toDetailResult(detection, step.addonId, crop));
3583
4014
  }
3584
4015
  return { details };
3585
4016
  }
3586
4017
  //#endregion
3587
- //#region src/pipeline-runner/device-jump.ts
3588
- /**
3589
- * Step-tree device jump (same-node, phase 1) — the PURE resolution core.
3590
- *
3591
- * Given the camera's elected ("effective") inference device and the node's
3592
- * enabled-device roster, decide which device ONE enrichment step should run on:
3593
- *
3594
- * 1. A manual `jumpDeviceKey` override wins when it names a candidate that can
3595
- * run the step (bypasses the auto choice; operator intent).
3596
- * 2. Otherwise, if the effective device's format can run the step's model →
3597
- * NO jump (the step inherits the effective device — the common case).
3598
- * 3. Otherwise AUTO-jump: among the node's OTHER enabled devices whose format
3599
- * can run the step (and that are under their `maxSessions` cap), pick the
3600
- * weighted-least-loaded one — mirroring the balancer's per-device core.
3601
- * 4. If nothing on the node can run it → `no-candidate` (the caller emits the
3602
- * loud once-per-(camera,step) warn that replaces today's silent degrade).
3603
- *
3604
- * Framework-free + side-effect-free so the whole decision table is unit-testable
3605
- * without a live runner/catalog. The `canRun(format)` predicate is injected by
3606
- * the runner (which owns the model catalog); this module never imports it.
3607
- */
3608
- /** The `ModelFormat` of a `<backend>:<device>` key, or `undefined` for an unknown backend. */
3609
- function formatOf(deviceKey) {
3610
- const colon = deviceKey.indexOf(":");
3611
- return DEVICE_BACKEND_TO_FORMAT[colon >= 0 ? deviceKey.slice(0, colon) : deviceKey];
3612
- }
3613
- /** True when the step's model has a build for `deviceKey`'s format. */
3614
- function canRunOn(deviceKey, canRun) {
3615
- const format = formatOf(deviceKey);
3616
- return format !== void 0 && canRun(format);
3617
- }
3618
- /** Effective weighted load of a device (session count ÷ weight); lower = preferred. */
3619
- function weightedLoad(d) {
3620
- return d.attachedCameras / (d.weight > 0 ? d.weight : 1);
3621
- }
3622
- /**
3623
- * Weighted-least-loaded pick with a deterministic `deviceKey` tie-break —
3624
- * mirrors the node/device balancer's decision core (least `load/weight`, ties
3625
- * broken stably). `devices` is assumed non-empty.
3626
- */
3627
- function pickWeightedLeastLoaded(devices) {
3628
- return devices.reduce((best, d) => {
3629
- const dScore = weightedLoad(d);
3630
- const bScore = weightedLoad(best);
3631
- if (dScore < bScore) return d;
3632
- if (dScore > bScore) return best;
3633
- return d.deviceKey < best.deviceKey ? d : best;
3634
- });
3635
- }
3636
- /**
3637
- * Build the `canRun` predicate for ONE enrichment dispatch. The step and its
3638
- * enabled descendants ride the SAME `runPipeline` call — one pool, one format
3639
- * (detail-subtree contract: "its inherited children ride the same call → same
3640
- * device") — so a format can run the dispatch only when EVERY catalog-known
3641
- * step in the subtree ships a build for it. Gating only the subtree TOP let a
3642
- * `face-detection` with a tflite build stay on the Coral while its
3643
- * `face-embedding` child (no tflite build anywhere) doomed the call.
3644
- * Unknown/custom addonIds (`getStepDef` → null) have no catalog to gate on
3645
- * and never block. `getStepDef` is injected — this module stays catalog-free.
3646
- */
3647
- function buildSubtreeCanRun(step, getStepDef) {
3648
- const defs = [];
3649
- const walk = (s) => {
3650
- if (s.enabled === false) return;
3651
- const def = getStepDef(s.addonId);
3652
- if (def !== null) defs.push(def);
3653
- for (const child of s.children ?? []) walk(child);
3654
- };
3655
- walk(step);
3656
- return (format) => defs.every((def) => def.models.some((m) => m.formats[format]));
3657
- }
3658
- /**
3659
- * Resolve the device one enrichment step runs on. See the module header for the
3660
- * decision table. Pure — never throws, no I/O.
3661
- */
3662
- function resolveStepDevice(input) {
3663
- const { effectiveDeviceKey, manualJumpDeviceKey, canRun } = input;
3664
- if (manualJumpDeviceKey !== void 0 && manualJumpDeviceKey !== effectiveDeviceKey) {
3665
- const target = input.devices.find((d) => d.deviceKey === manualJumpDeviceKey);
3666
- if (target && canRunOn(target.deviceKey, canRun)) return {
3667
- kind: "jumped",
3668
- deviceKey: target.deviceKey,
3669
- reason: "manual"
3670
- };
3671
- }
3672
- if (canRunOn(effectiveDeviceKey, canRun)) return {
3673
- kind: "no-jump",
3674
- deviceKey: effectiveDeviceKey
3675
- };
3676
- const runnable = input.devices.filter((d) => d.deviceKey !== effectiveDeviceKey && (d.maxSessions === null || d.attachedCameras < d.maxSessions) && canRunOn(d.deviceKey, canRun));
3677
- if (runnable.length === 0) return {
3678
- kind: "no-candidate",
3679
- deviceKey: effectiveDeviceKey
3680
- };
3681
- return {
3682
- kind: "jumped",
3683
- deviceKey: pickWeightedLeastLoaded(runnable).deviceKey,
3684
- reason: "auto"
3685
- };
3686
- }
3687
- //#endregion
3688
4018
  //#region src/pipeline-runner/device-load.ts
3689
4019
  /** The backend prefix of a `<backend>:<device>` key (bare `cpu` → `cpu`). */
3690
4020
  function backendOf(deviceKey) {
@@ -3867,6 +4197,74 @@ function release(cutter) {
3867
4197
  } catch {}
3868
4198
  }
3869
4199
  //#endregion
4200
+ //#region src/pipeline-runner/occupancy-burst-ledger.ts
4201
+ var OccupancyBurstLedger = class {
4202
+ options;
4203
+ frames = 0;
4204
+ inferred = 0;
4205
+ superseded = 0;
4206
+ settled = 0;
4207
+ closed = false;
4208
+ completed = false;
4209
+ boundTimer = null;
4210
+ constructor(options) {
4211
+ this.options = options;
4212
+ }
4213
+ /**
4214
+ * Count one collected frame and hand back its settler. A frame the runner
4215
+ * REFUSED is settled `'lost'` by the caller at once — it is a frame the
4216
+ * verdict did not see, which is what `lost` counts.
4217
+ */
4218
+ admit() {
4219
+ this.frames += 1;
4220
+ let done = false;
4221
+ return (outcome) => {
4222
+ if (done || this.completed) return;
4223
+ done = true;
4224
+ this.settled += 1;
4225
+ if (outcome === "inferred") this.inferred += 1;
4226
+ else if (outcome === "superseded") this.superseded += 1;
4227
+ this.completeIfSettled();
4228
+ };
4229
+ }
4230
+ /**
4231
+ * No more frames will be collected (count reached, safety timeout, or the
4232
+ * decode ended). Completes now when everything admitted has settled, else
4233
+ * arms the bound. Idempotent.
4234
+ */
4235
+ close() {
4236
+ if (this.closed || this.completed) return;
4237
+ this.closed = true;
4238
+ if (this.completeIfSettled()) return;
4239
+ this.boundTimer = setTimeout(() => {
4240
+ this.boundTimer = null;
4241
+ this.complete(true);
4242
+ }, this.options.settleBoundMs);
4243
+ this.boundTimer.unref?.();
4244
+ }
4245
+ completeIfSettled() {
4246
+ if (!this.closed || this.settled < this.frames) return false;
4247
+ this.complete(false);
4248
+ return true;
4249
+ }
4250
+ complete(timedOut) {
4251
+ if (this.completed) return;
4252
+ this.completed = true;
4253
+ if (this.boundTimer !== null) {
4254
+ clearTimeout(this.boundTimer);
4255
+ this.boundTimer = null;
4256
+ }
4257
+ this.options.onComplete({
4258
+ frames: this.frames,
4259
+ inferred: this.inferred,
4260
+ superseded: this.superseded,
4261
+ lost: this.frames - this.inferred - this.superseded,
4262
+ unsettled: this.frames - this.settled,
4263
+ timedOut
4264
+ });
4265
+ }
4266
+ };
4267
+ //#endregion
3870
4268
  //#region src/pipeline-runner/frame-lazy-metrics.ts
3871
4269
  function emptyFrameLazyCounters() {
3872
4270
  return {
@@ -4900,7 +5298,7 @@ var ON_DEMAND_JPEG_VIEW = {
4900
5298
  format: "jpeg"
4901
5299
  };
4902
5300
  function createOnDemandJpegFallback(registry, ref) {
4903
- const lease = registry.acquire(ref);
5301
+ const lease = registry.acquire(ref, "retention");
4904
5302
  let cached;
4905
5303
  let released = false;
4906
5304
  const releaseLease = (reason) => {
@@ -4940,6 +5338,11 @@ function createOnDemandJpegFallback(registry, ref) {
4940
5338
  },
4941
5339
  release() {
4942
5340
  releaseLease("eviction");
5341
+ },
5342
+ holdReader() {
5343
+ if (released || cached !== void 0) return null;
5344
+ const reader = registry.acquire(ref);
5345
+ return reader ? () => reader.release("success") : null;
4943
5346
  }
4944
5347
  };
4945
5348
  }
@@ -5279,41 +5682,6 @@ var FrameGovernor = class {
5279
5682
  };
5280
5683
  //#endregion
5281
5684
  //#region src/pipeline-runner/frame-queue.ts
5282
- /**
5283
- * Latest-frame-only buffer. Keeps only the most recent item, dropping all
5284
- * older items immediately. This ensures inference always runs on the
5285
- * freshest available frame, never accumulating a backlog regardless of
5286
- * inference latency.
5287
- *
5288
- * ## Buffer ownership (Phase 5 / D9 Task 7b)
5289
- *
5290
- * Frames arrive from the shm frame plane via a `FrameRingReader`, whose
5291
- * `FrameRead.pixels` is **borrowed** — a view onto the reader's reusable
5292
- * scratch buffer, valid only until that reader's next read (see
5293
- * `@camstack/shm-ring`'s `FrameRead` contract). Both queue consumers retain
5294
- * the frame PAST the next read:
5295
- *
5296
- * - the **motion** queue is drained by the scheduler's `tick`, not by the
5297
- * poller that read the frame — and the poller drains a burst of handles
5298
- * per poll, so even handle #2's read would clobber handle #1's pixels;
5299
- * - the **detection** queue is drained asynchronously after a semaphore wait
5300
- * and a 10s–100s ms inference.
5301
- *
5302
- * In both cases holding the borrowed buffer is silent corruption. So
5303
- * `enqueue` runs the queue's injected `clone` function which copies the
5304
- * pixels into queue-owned storage at the retention boundary. The queue is
5305
- * latest-only (capacity 1), so this is exactly one allocation per *queued*
5306
- * frame — bounded by the scheduler/inference cadence, NOT per *read* frame.
5307
- * The high-frequency per-read allocation that failed the D9 perf gate is
5308
- * eliminated; this bounded copy-on-retain is the correct cost.
5309
- *
5310
- * The generic parameter `T` lets the detection path queue a
5311
- * `{ frame, handle }` entry (Task 7 — propagating the `FrameHandle`
5312
- * through to the inference-result event), while the motion path keeps
5313
- * queueing bare `DecodedFrame`s. Each instance provides its own
5314
- * `clone` so the inner pixel buffer is detached at the same retention
5315
- * boundary regardless of the wrapping entry shape.
5316
- */
5317
5685
  var FrameQueue = class {
5318
5686
  maxSize;
5319
5687
  latest = null;
@@ -5334,7 +5702,7 @@ var FrameQueue = class {
5334
5702
  enqueue(item) {
5335
5703
  if (this.latest !== null) {
5336
5704
  this._droppedFrames++;
5337
- this.dispose?.(this.latest);
5705
+ this.dispose?.(this.latest, "superseded");
5338
5706
  }
5339
5707
  this.latest = this.clone(item);
5340
5708
  }
@@ -5349,8 +5717,12 @@ var FrameQueue = class {
5349
5717
  get droppedFrames() {
5350
5718
  return this._droppedFrames;
5351
5719
  }
5720
+ /** The queued item, without taking it. */
5721
+ peek() {
5722
+ return this.latest ?? void 0;
5723
+ }
5352
5724
  clear() {
5353
- if (this.latest !== null) this.dispose?.(this.latest);
5725
+ if (this.latest !== null) this.dispose?.(this.latest, "cleared");
5354
5726
  this.latest = null;
5355
5727
  }
5356
5728
  };
@@ -5750,7 +6122,8 @@ function ownDetectionEntry(entry) {
5750
6122
  return {
5751
6123
  frame: ownFrame(entry.frame),
5752
6124
  handle: entry.handle,
5753
- occasion: entry.occasion
6125
+ occasion: entry.occasion,
6126
+ ...entry.onSettled ? { onSettled: entry.onSettled } : {}
5754
6127
  };
5755
6128
  }
5756
6129
  /**
@@ -5892,9 +6265,10 @@ var PipelineRunner = class {
5892
6265
  }
5893
6266
  registerCamera(deviceId, registration) {
5894
6267
  const motionQueue = new FrameQueue(this.config.maxQueueDepth, ownMotionEntry);
5895
- const detectionQueue = new FrameQueue(this.config.maxQueueDepth, ownDetectionEntry, (entry) => {
6268
+ const detectionQueue = new FrameQueue(this.config.maxQueueDepth, ownDetectionEntry, (entry, reason) => {
5896
6269
  if (entry.handle) this.config.releaseRetention?.(entry.handle, "drop");
5897
6270
  if (entry.frame.frameRef) this.config.releaseFrameRef?.(entry.frame.frameRef, "drop");
6271
+ entry.onSettled?.(reason === "superseded" ? "superseded" : "lost");
5898
6272
  });
5899
6273
  const initialPhase = registration.detectionMode === "disabled" ? "idle" : registration.detectionMode === "always-on" ? "active" : "watching";
5900
6274
  const preRollQueue = new PreRollLane({
@@ -5903,6 +6277,7 @@ var PipelineRunner = class {
5903
6277
  dispose: (entry) => {
5904
6278
  if (entry.handle) this.config.releaseRetention?.(entry.handle, "drop");
5905
6279
  if (entry.frame.frameRef) this.config.releaseFrameRef?.(entry.frame.frameRef, "drop");
6280
+ entry.onSettled?.("lost");
5906
6281
  }
5907
6282
  });
5908
6283
  this.logger?.info("camera registered: root detectors", {
@@ -5963,8 +6338,7 @@ var PipelineRunner = class {
5963
6338
  }
5964
6339
  if (state.phase === "active") this.detectionStreamHandler?.(deviceId, "unsubscribe");
5965
6340
  state.motionQueue.clear();
5966
- state.detectionQueue.clear();
5967
- state.preRollQueue.clear();
6341
+ this.discardQueuedDetectionFrames(deviceId, state, "detach");
5968
6342
  this.timingSampler.clearDroppedFramesSource(deviceId);
5969
6343
  this.cameras.delete(deviceId);
5970
6344
  this.lastAdmittedAt.delete(deviceId);
@@ -6088,9 +6462,14 @@ var PipelineRunner = class {
6088
6462
  preRollQueueDepth(deviceId) {
6089
6463
  return this.cameras.get(deviceId)?.preRollQueue.size ?? 0;
6090
6464
  }
6091
- /** Frames this camera lane REFUSED because it was full. */
6465
+ /**
6466
+ * Frames this camera lane REFUSED because it was full — or `null` when the
6467
+ * camera is no longer registered and the count cannot be read (a report
6468
+ * that lands after a detach). A failed read is `null`, never `0` (D393):
6469
+ * zero would say "nothing refused" about a lane nobody looked at.
6470
+ */
6092
6471
  preRollRefusedCount(deviceId) {
6093
- return this.cameras.get(deviceId)?.preRollQueue.refused ?? 0;
6472
+ return this.cameras.get(deviceId)?.preRollQueue.refused ?? null;
6094
6473
  }
6095
6474
  /**
6096
6475
  * Whether the node has a permit to spare for history right now — i.e. one
@@ -6119,7 +6498,7 @@ var PipelineRunner = class {
6119
6498
  * decode worker). `runInference` already falls back to the pixel path
6120
6499
  * when the handle is absent.
6121
6500
  */
6122
- enqueueOccupancyFrame(deviceId, frame, handle) {
6501
+ enqueueOccupancyFrame(deviceId, frame, handle, onSettled) {
6123
6502
  const state = this.cameras.get(deviceId);
6124
6503
  if (!state) return false;
6125
6504
  if (state.phase !== "watching") return false;
@@ -6127,7 +6506,8 @@ var PipelineRunner = class {
6127
6506
  state.detectionQueue.enqueue({
6128
6507
  frame,
6129
6508
  handle,
6130
- occasion: "burst"
6509
+ occasion: "burst",
6510
+ ...onSettled ? { onSettled } : {}
6131
6511
  });
6132
6512
  return true;
6133
6513
  }
@@ -6335,7 +6715,7 @@ var PipelineRunner = class {
6335
6715
  }
6336
6716
  transitionToWatching(deviceId, state, cooldownMs) {
6337
6717
  state.phase = "watching";
6338
- state.detectionQueue.clear();
6718
+ this.discardQueuedDetectionFrames(deviceId, state, "session-close");
6339
6719
  state.occupancyArmed = state.lastResultDetections > 0;
6340
6720
  state.emptyBurstStreak = 0;
6341
6721
  this.logger?.info("motion gate closed — phase=watching", {
@@ -6356,6 +6736,41 @@ var PipelineRunner = class {
6356
6736
  state.lastArmedSource = null;
6357
6737
  state.lastArmedRegions = void 0;
6358
6738
  }
6739
+ /**
6740
+ * Throw away the frames still QUEUED for a camera whose session is ending.
6741
+ *
6742
+ * A branch that drops work says so (D391, D652). These are frames the runner
6743
+ * admitted and will now never infer — the live queue's latest frame and, on a
6744
+ * detach, whatever history is left in the pre-roll lane. Frames already in
6745
+ * flight are not touched here: they finish, and the decode worker they live
6746
+ * in drains before it is killed (`SESSION_DECODE_DRAIN_BOUND_MS`).
6747
+ */
6748
+ discardQueuedDetectionFrames(deviceId, state, reason) {
6749
+ const queued = state.detectionQueue.size;
6750
+ const awaited = state.detectionQueue.peek()?.onSettled !== void 0 ? 1 : 0;
6751
+ const preRoll = reason === "detach" ? state.preRollQueue.size : 0;
6752
+ state.detectionQueue.clear();
6753
+ if (reason === "detach") state.preRollQueue.clear();
6754
+ if (queued + preRoll === 0) return;
6755
+ const meta = {
6756
+ reason,
6757
+ queued,
6758
+ awaited,
6759
+ preRoll,
6760
+ inflight: state.inflight
6761
+ };
6762
+ if (awaited + preRoll > 0) {
6763
+ this.logger?.info("detection frames discarded with the session", {
6764
+ tags: { deviceId },
6765
+ meta
6766
+ });
6767
+ return;
6768
+ }
6769
+ this.logger?.debug("detection frames discarded with the session", {
6770
+ tags: { deviceId },
6771
+ meta
6772
+ });
6773
+ }
6359
6774
  tick() {
6360
6775
  this.drainMotionQueues();
6361
6776
  if (this.semaphore.available <= 0) {
@@ -6443,6 +6858,7 @@ var PipelineRunner = class {
6443
6858
  } catch {}
6444
6859
  if (handle) this.config.releaseRetention?.(handle, frameReleaseReason);
6445
6860
  if (frame.frameRef) this.config.releaseFrameRef?.(frame.frameRef, frameReleaseReason);
6861
+ entry.onSettled?.(frameReleaseReason === "success" ? "inferred" : "lost");
6446
6862
  }
6447
6863
  }
6448
6864
  /**
@@ -6524,6 +6940,7 @@ var PipelineRunner = class {
6524
6940
  } catch {}
6525
6941
  if (handle) this.config.releaseRetention?.(handle, frameReleaseReason);
6526
6942
  if (frame.frameRef) this.config.releaseFrameRef?.(frame.frameRef, frameReleaseReason);
6943
+ entry.onSettled?.(frameReleaseReason === "success" ? "inferred" : "lost");
6527
6944
  }
6528
6945
  }
6529
6946
  /**
@@ -6771,11 +7188,14 @@ async function runStatelessStep(deps, input) {
6771
7188
  return refuse(deps, input, "execution-failed", errorMessage(err));
6772
7189
  }
6773
7190
  if (result === null) return refuse(deps, input, "execution-failed", "the executor returned no frame result for the crop");
7191
+ const ranModelId = result.rootModelId;
7192
+ if (ranModelId === void 0 || ranModelId === "") return refuse(deps, input, "execution-failed", `the executor result names no model — refusing rather than assuming "${input.modelId}" ran`);
7193
+ if (ranModelId !== input.modelId) return refuse(deps, input, "model-not-servable", `the executor ran "${ranModelId}" for a call pinned to "${input.modelId}" — its output would not be comparable with the pass`);
6774
7194
  const details = result.detections.map((detection) => toDetailResult(detection, input.stepId, crop));
6775
7195
  return {
6776
7196
  kind: "ran",
6777
7197
  nodeId: deps.nodeId,
6778
- modelId: input.modelId,
7198
+ modelId: ranModelId,
6779
7199
  details
6780
7200
  };
6781
7201
  }
@@ -7067,6 +7487,19 @@ function writeRetainedDetectionFrame(store, deviceId, frame, retention) {
7067
7487
  */
7068
7488
  var OCCUPANCY_BURST_SAFETY_TIMEOUT_MS = 8e3;
7069
7489
  /**
7490
+ * How long a burst whose collection has ended waits for the frames it admitted
7491
+ * to be INFERRED before it reports anyway (D652).
7492
+ *
7493
+ * A burst's verdict is read off its frames' results, so it completes when they
7494
+ * settle, not when they are enqueued. This bound is only for a frame that never
7495
+ * settles. Measured on the live hub 2026-09-26, the worst enqueue→emit of a
7496
+ * 10 s window was ≤1.5 s in 98.0% of 2 254 windows and ≤5 s in 98.9%; what lies
7497
+ * beyond is the inference-pool GPU hang, which the runner's own 20 s inference
7498
+ * timeout already ends. A timed-out burst counts its unsettled frames as lost
7499
+ * and says so on its `burst complete` line.
7500
+ */
7501
+ var OCCUPANCY_BURST_SETTLE_BOUND_MS = 5e3;
7502
+ /**
7070
7503
  * Freshness window for `lastMotionFrameAt` (Task 10 hardening). A non-null
7071
7504
  * `attachment.motionUnsubscribe` only proves a subscription handle exists —
7072
7505
  * NOT that frames are actually flowing (guarded-session-decode can return
@@ -7171,6 +7604,15 @@ function tryGetStepDefinition(stepId) {
7171
7604
  }
7172
7605
  }
7173
7606
  /**
7607
+ * A detail-plane step: its catalog definition consumes a parent class
7608
+ * (`inputClasses` non-null). Unknown/custom addonIds are root-plane — the
7609
+ * same reading as the executor's frame-plane skip and `deriveDetailSteps`.
7610
+ */
7611
+ function isDetailPlaneStepId(stepId) {
7612
+ const def = tryGetStepDefinition(stepId);
7613
+ return def !== null && def.inputClasses !== null;
7614
+ }
7615
+ /**
7174
7616
  * Precomputes the `detailSteps` announce (two-plane design) for a camera's
7175
7617
  * configured step tree: every ENABLED step whose catalog definition is a
7176
7618
  * detail/child step (`inputClasses !== null` — a root step like
@@ -7876,8 +8318,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
7876
8318
  this.runner = null;
7877
8319
  }
7878
8320
  for (const attachment of this.attached.values()) {
7879
- attachment.motionUnsubscribe?.();
7880
- attachment.detectionUnsubscribe?.();
8321
+ attachment.motionUnsubscribe?.("immediate");
8322
+ attachment.detectionUnsubscribe?.("immediate");
7881
8323
  }
7882
8324
  for (const deviceId of [...this.parkedDetail.keys()]) this.dropParkedDetail(deviceId);
7883
8325
  this.attached.clear();
@@ -8003,14 +8445,16 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
8003
8445
  height: cached.height,
8004
8446
  format: cached.format
8005
8447
  }, pin)).frameId;
8006
- await api.pipelineExecutor.runPipeline.mutate({ ...buildBenchRunPipelineInput({
8448
+ const benchModelId = (await api.pipelineExecutor.runPipeline.mutate({ ...buildBenchRunPipelineInput({
8007
8449
  steps: stepsToRun,
8008
8450
  frame: sharedFrame
8009
- }) }, pin);
8451
+ }) }, pin)).rootModelId;
8452
+ if (benchModelId === void 0 || benchModelId === "") throw new Error(`synthetic bench: the warmup of "${rootStep.addonId}" named no model — refusing to time an unnamed variant`);
8010
8453
  const warmupCount = input.warmup ?? 1;
8011
8454
  for (let w = 0; w < warmupCount; w++) await api.pipelineExecutor.inferCached.mutate({
8012
8455
  stepId: rootStep.addonId,
8013
- frameId: poolFrameId
8456
+ frameId: poolFrameId,
8457
+ modelId: benchModelId
8014
8458
  }, pin);
8015
8459
  const wallTimings = [];
8016
8460
  const inferTimings = [];
@@ -8028,7 +8472,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
8028
8472
  const t0 = performance.now();
8029
8473
  const result = await api.pipelineExecutor.inferCached.mutate({
8030
8474
  stepId: rootStep.addonId,
8031
- frameId: poolFrameId
8475
+ frameId: poolFrameId,
8476
+ modelId: benchModelId
8032
8477
  }, pin);
8033
8478
  const wallMs = performance.now() - t0;
8034
8479
  const r = result;
@@ -8703,6 +9148,10 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
8703
9148
  * resolve legitimately want the lower-RES honest frame).
8704
9149
  */
8705
9150
  async getNativeCrop(input) {
9151
+ return withRetainedReader(this.retainedFrames, input.handle, () => this.resolveNativeCropBothRungs(input));
9152
+ }
9153
+ /** {@link getNativeCrop}'s two rungs, run under its reader hold. */
9154
+ async resolveNativeCropBothRungs(input) {
8706
9155
  const deviceId = this.nativeCropRegistry.deviceIdFor(input.handle) ?? 0;
8707
9156
  const isFullFrame = input.bbox.x <= .001 && input.bbox.y <= .001 && input.bbox.w >= .999 && input.bbox.h >= .999;
8708
9157
  this.frameLazy.noteOnDemandRequest({
@@ -9218,11 +9667,12 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
9218
9667
  */
9219
9668
  async runDetailSubtree(input) {
9220
9669
  const convention = await this.cropConvention.refresh(this.ctxIfReady?.api);
9670
+ await this.customModels.refresh(this.ctxIfReady?.api);
9221
9671
  const outcome = await runDetailSubtree({
9222
9672
  resolveChildSteps: (deviceId, parentClassName, filter) => this.resolveDetailChildSteps(deviceId, parentClassName, filter),
9223
9673
  getNativeCrop: (deviceId, handle, paddedBbox) => this.getNativeCropForDetail(deviceId, handle, paddedBbox),
9224
9674
  runPipeline: (steps, imageJpeg, deviceId, deviceKey, nativeCropRef) => this.runDetailPipeline(steps, imageJpeg, deviceId, deviceKey, nativeCropRef),
9225
- resolveStepDevice: (deviceId, step) => this.resolveDetailStepDevice(deviceId, step),
9675
+ placeStep: (deviceId, step) => this.placeDetailStepForCamera(deviceId, step),
9226
9676
  resolveCropConvention: () => convention,
9227
9677
  logger: this.detailSubtreeLogger()
9228
9678
  }, input);
@@ -9346,6 +9796,13 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
9346
9796
  * global settings, NOT from the attach config. See `crop-convention-source.ts`
9347
9797
  * for why eleven attach builders make the attach config the wrong carrier.
9348
9798
  */
9799
+ /**
9800
+ * The custom-model registry, merged into the catalog the device jump judges
9801
+ * (D657): a custom model with a tflite build, pinned on a Coral, runs there.
9802
+ * Refreshed on the async boundaries (`runInference`, `runDetailSubtree`),
9803
+ * read synchronously by the placement.
9804
+ */
9805
+ customModels = new CustomModelCatalog({ warn: (message, extras) => this.ctxIfReady?.logger.warn(message, extras) });
9349
9806
  cropConvention = new CropConventionSource({ logger: {
9350
9807
  info: (message, extras) => this.ctxIfReady?.logger.info(message, extras),
9351
9808
  warn: (message, extras) => this.ctxIfReady?.logger.warn(message, extras)
@@ -9436,39 +9893,94 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
9436
9893
  return { warn: (message, extras) => this.ctxIfReady?.logger.warn(message, extras) };
9437
9894
  }
9438
9895
  /**
9439
- * Step-tree device jump (phase 1) — pick which same-node inference device an
9440
- * enrichment `step` runs on. AUTO-jumps only when the camera's elected device
9441
- * cannot run the step's model (its format has no build); an operator
9442
- * `step.jumpDeviceKey` overrides. Returns the resolved `<backend>:<device>`
9443
- * key, or `undefined` (⇒ the runner's per-camera default pool) when there is
9444
- * nothing to route: no elected device, or no jump roster on the attach.
9896
+ * Step-tree device jump — place one enrichment subtree on a same-node
9897
+ * inference device ({@link placeDetailStep}). AUTO-jumps only when the
9898
+ * camera's elected device cannot run the subtree (its format has no build);
9899
+ * an operator `step.jumpDeviceKey` overrides. A jumped subtree runs with the
9900
+ * TARGET device's provisioning carried on the roster (D650), never the
9901
+ * elected device's. `deviceKey: undefined` ⇒ the runner's per-camera default
9902
+ * pool (no elected device).
9445
9903
  *
9446
- * When NO enabled device on the node can run the step, logs a LOUD warn once
9447
- * per (camera, step) — killing the historical SILENT per-child degrade (an
9448
- * `edgetpu` root would otherwise drop faces/plates/clip with no signal).
9904
+ * Logs the jump ONCE per (camera, step, target), and — when NO enabled device
9905
+ * on the node can run the subtree — a LOUD warn once per (camera, step).
9906
+ */
9907
+ placeDetailStepForCamera(deviceId, step) {
9908
+ const config = this.attached.get(deviceId)?.config;
9909
+ const placement = this.placementOf(config, step, this.perDeviceAttachedCounts());
9910
+ const decision = placement.decision;
9911
+ if (decision?.kind === "no-candidate") this.warnStepHasNoRunnableDevice(deviceId, step.addonId, decision.deviceKey);
9912
+ else if (decision?.kind === "jumped" && config?.deviceKey) this.logStepJumped(deviceId, config.deviceKey, decision, placement);
9913
+ return placement;
9914
+ }
9915
+ /**
9916
+ * Where one detail subtree of this camera runs — the ONE decision both the
9917
+ * detail plane (which dispatches it) and the frame plane (which decides what
9918
+ * the elected pool pre-warms, D657) read. Pure: nothing is logged here.
9919
+ */
9920
+ placementOf(config, step, attachedByKey) {
9921
+ return placeDetailStep({
9922
+ step,
9923
+ effectiveDeviceKey: config?.deviceKey,
9924
+ roster: config?.inferenceDevices,
9925
+ attachedByKey,
9926
+ getStepDef: this.customModels.stepDefinition
9927
+ });
9928
+ }
9929
+ /**
9930
+ * The tree this camera's frame-plane dispatch carries (D657): a detail
9931
+ * subtree the placement sends to another device (or to none) is left out,
9932
+ * so its model is never loaded on the elected pool — it loads on its
9933
+ * target, with the target's provisioning, when the detail plane dispatches
9934
+ * it there. Said once per (camera, elected device, set left out).
9449
9935
  */
9450
- resolveDetailStepDevice(deviceId, step) {
9936
+ stepsForElectedPoolOf(deviceId, steps) {
9451
9937
  const config = this.attached.get(deviceId)?.config;
9452
- const effectiveDeviceKey = config?.deviceKey;
9453
- if (!effectiveDeviceKey) return void 0;
9454
- const roster = config?.inferenceDevices;
9455
- if (!roster || roster.length < 2) return effectiveDeviceKey;
9938
+ if (config?.deviceKey === void 0 || (config.inferenceDevices?.length ?? 0) < 2) {
9939
+ this.noteElectedPoolDelegation(deviceId, void 0, []);
9940
+ return steps;
9941
+ }
9456
9942
  const attachedByKey = this.perDeviceAttachedCounts();
9457
- const devices = roster.map((d) => ({
9458
- deviceKey: d.deviceKey,
9459
- weight: d.weight,
9460
- maxSessions: d.maxSessions,
9461
- attachedCameras: attachedByKey.get(d.deviceKey) ?? 0
9462
- }));
9463
- const canRun = buildSubtreeCanRun(step, tryGetStepDefinition);
9464
- const result = resolveStepDevice({
9465
- effectiveDeviceKey,
9466
- devices,
9467
- ...step.jumpDeviceKey !== void 0 ? { manualJumpDeviceKey: step.jumpDeviceKey } : {},
9468
- canRun
9943
+ const result = stepsForElectedPool(steps, isDetailPlaneStepId, (step) => this.placementOf(config, step, attachedByKey));
9944
+ this.noteElectedPoolDelegation(deviceId, config.deviceKey, result.delegated);
9945
+ return result.delegated.length === 0 ? steps : result.steps;
9946
+ }
9947
+ /**
9948
+ * Say once per STATE CHANGE which detail steps a camera's elected pool no
9949
+ * longer loads: a camera moving A → B → A says so again on A.
9950
+ */
9951
+ noteElectedPoolDelegation(deviceId, electedDeviceKey, delegated) {
9952
+ const state = `${electedDeviceKey ?? ""}|${delegated.join(",")}`;
9953
+ if (this.electedPoolDelegationState.get(deviceId) === state) return;
9954
+ this.electedPoolDelegationState.set(deviceId, state);
9955
+ if (delegated.length === 0) return;
9956
+ this.ctx?.logger.info("detail steps not loaded on the elected pool — they run elsewhere", {
9957
+ tags: { deviceId },
9958
+ meta: {
9959
+ electedDeviceKey: electedDeviceKey ?? null,
9960
+ steps: [...delegated]
9961
+ }
9962
+ });
9963
+ }
9964
+ /** Per camera, the last (elected device, steps left out) state that was logged. */
9965
+ electedPoolDelegationState = /* @__PURE__ */ new Map();
9966
+ /** `${deviceId}:${addonId}:${target}` triples whose jump has been logged. */
9967
+ loggedStepJumps = /* @__PURE__ */ new Set();
9968
+ logStepJumped(deviceId, electedDeviceKey, decision, placement) {
9969
+ const step = placement.step;
9970
+ const key = `${deviceId}:${step.addonId}:${decision.deviceKey}`;
9971
+ if (this.loggedStepJumps.has(key)) return;
9972
+ this.loggedStepJumps.add(key);
9973
+ this.ctx?.logger.info("enrichment step runs on another device of this node", {
9974
+ tags: { deviceId },
9975
+ meta: {
9976
+ step: step.addonId,
9977
+ electedDeviceKey,
9978
+ targetDeviceKey: decision.deviceKey,
9979
+ reason: decision.reason,
9980
+ provisioned: placement.provisioned,
9981
+ modelId: step.modelId ?? null
9982
+ }
9469
9983
  });
9470
- if (result.kind === "no-candidate") this.warnStepHasNoRunnableDevice(deviceId, step.addonId, effectiveDeviceKey);
9471
- return result.deviceKey;
9472
9984
  }
9473
9985
  /** Live attached-camera count per inference `deviceKey` on this runner. */
9474
9986
  perDeviceAttachedCounts() {
@@ -9687,6 +10199,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
9687
10199
  const warnPrefix = `${deviceId}:`;
9688
10200
  for (const key of this.warnedNoJumpDevice) if (key.startsWith(warnPrefix)) this.warnedNoJumpDevice.delete(key);
9689
10201
  this.attached.delete(deviceId);
10202
+ this.electedPoolDelegationState.delete(deviceId);
9690
10203
  this.lastMotionAt.delete(deviceId);
9691
10204
  this.lastEmittedCameraMetrics.delete(deviceId);
9692
10205
  this.retention?.forgetDevice(deviceId);
@@ -10027,6 +10540,16 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10027
10540
  if (!admission) return void 0;
10028
10541
  return () => admission.admit(deviceId, label);
10029
10542
  }
10543
+ /**
10544
+ * The start's place in the node's queue of refused starts (D652) — taken by
10545
+ * the guarded start on its first refusal, kept until it is over. Spread into
10546
+ * `startGuardedSessionDecode`'s deps; empty when this node runs no ceiling.
10547
+ */
10548
+ liveWorkerSlotWaiter(deviceId) {
10549
+ const admission = this.liveWorkers;
10550
+ if (!admission) return {};
10551
+ return { slotWaiter: () => admission.slotWaiter(deviceId) };
10552
+ }
10030
10553
  buildDecodeSpawnAcquire(log, label) {
10031
10554
  return async () => {
10032
10555
  const gate = this.spawnGate;
@@ -10055,8 +10578,9 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10055
10578
  const teardown = startGuardedSessionDecode({
10056
10579
  logger: log,
10057
10580
  admitWorker: this.buildLiveWorkerAdmit(config.deviceId, "detect"),
10581
+ ...this.liveWorkerSlotWaiter(config.deviceId),
10058
10582
  acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:detect:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config), true),
10059
- startPump: (source) => {
10583
+ startPump: (source, hooks) => {
10060
10584
  const dialMs = Date.now() - requestedAt;
10061
10585
  const useLocalFrameRef = this.executorCoLocated() && process.env["CAMSTACK_DISABLE_LOCAL_FRAME_REF"] !== "1";
10062
10586
  log.info("session-decode: detection routed through per-session decode worker (rgb)", { meta: {
@@ -10068,6 +10592,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10068
10592
  return startSessionDecodePump({
10069
10593
  fork: this.forkDecodeWorkerWithLease,
10070
10594
  lease: this.resolveSessionLeaseKnobs,
10595
+ onWorkerGone: hooks.onWorkerGone,
10071
10596
  logger: log,
10072
10597
  source,
10073
10598
  opts: {
@@ -10149,10 +10674,30 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10149
10674
  strideMs: report.strideMs,
10150
10675
  subsampled: report.subsampled,
10151
10676
  laneRefused: runner.preRollRefusedCount(config.deviceId),
10152
- budgetFrames: this.preRollBudgetFor(config)
10677
+ budgetFrames: this.preRollBudgetFor(config),
10678
+ effectiveBudgetFrames: report.budgetFrames
10153
10679
  }
10154
10680
  });
10155
10681
  },
10682
+ onPreRollSettled: (summary) => {
10683
+ const lost = summary.lostByReason.evicted + summary.lostByReason.cleared + summary.lostByReason["download-failed"] + summary.lostByReason.miss;
10684
+ const meta = {
10685
+ streamId: config.detectionStreamId,
10686
+ ...summary,
10687
+ lost
10688
+ };
10689
+ if (lost > 0 || summary.unsettled > 0) {
10690
+ log.info("session-decode: pre-roll frames settled", {
10691
+ tags: { deviceId: config.deviceId },
10692
+ meta
10693
+ });
10694
+ return;
10695
+ }
10696
+ log.debug("session-decode: pre-roll frames settled", {
10697
+ tags: { deviceId: config.deviceId },
10698
+ meta
10699
+ });
10700
+ },
10156
10701
  onFirstFrame: (timing) => {
10157
10702
  const phaseActiveAt = this.lastMotionAt.get(config.deviceId);
10158
10703
  log.info("session-decode: detection first frame", {
@@ -10196,8 +10741,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10196
10741
  });
10197
10742
  }
10198
10743
  });
10199
- return () => {
10200
- teardown();
10744
+ return (mode) => {
10745
+ teardown(mode);
10201
10746
  };
10202
10747
  }
10203
10748
  /**
@@ -10218,8 +10763,9 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10218
10763
  return startGuardedSessionDecode({
10219
10764
  logger: log,
10220
10765
  admitWorker: this.buildLiveWorkerAdmit(config.deviceId, "motion"),
10766
+ ...this.liveWorkerSlotWaiter(config.deviceId),
10221
10767
  acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.motionStreamId, `session-decode:motion:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config)),
10222
- startPump: (source) => {
10768
+ startPump: (source, hooks) => {
10223
10769
  log.info("session-decode: motion routed through per-session decode worker (gray)", { meta: {
10224
10770
  streamId: config.motionStreamId,
10225
10771
  codec: source.codec
@@ -10227,6 +10773,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10227
10773
  return startSessionDecodePump({
10228
10774
  fork: this.forkDecodeWorkerWithLease,
10229
10775
  lease: this.resolveSessionLeaseKnobs,
10776
+ onWorkerGone: hooks.onWorkerGone,
10230
10777
  logger: log,
10231
10778
  source,
10232
10779
  opts: {
@@ -10363,6 +10910,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10363
10910
  }
10364
10911
  const camConfig = this.attached.get(deviceId)?.config;
10365
10912
  const steps = camConfig?.steps;
10913
+ await this.customModels.refresh(api);
10366
10914
  if (!steps) {
10367
10915
  log.warn("runInference: no steps in attach config — skipping frame (legacy attach?)");
10368
10916
  return null;
@@ -10397,6 +10945,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10397
10945
  });
10398
10946
  }
10399
10947
  }
10948
+ dispatchSteps = this.stepsForElectedPoolOf(deviceId, dispatchSteps);
10400
10949
  let image;
10401
10950
  const executorCoLocated = this.executorCoLocated();
10402
10951
  if (!executorCoLocated) {
@@ -10526,6 +11075,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10526
11075
  const cropResult = await api.pipelineExecutor.runPipeline.mutate({
10527
11076
  steps: [step],
10528
11077
  image: view.jpeg,
11078
+ deviceId,
10529
11079
  plane: "frame",
10530
11080
  ...deviceKey ? { deviceKey } : {}
10531
11081
  });
@@ -10894,15 +11444,36 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10894
11444
  * found/report semantics can't drift between the two admission
10895
11445
  * mechanisms.
10896
11446
  */
10897
- finishOccupancyBurst(deviceId, log, collected) {
11447
+ finishOccupancyBurst(deviceId, log, summary) {
10898
11448
  this.occupancyBurstInFlight.delete(deviceId);
10899
11449
  const found = this.occupancyBurstFound.has(deviceId);
10900
11450
  this.occupancyBurstFound.delete(deviceId);
11451
+ if (summary.inferred === 0) {
11452
+ log.info("occupancy re-check: burst complete — nothing inferred, no verdict", {
11453
+ tags: { deviceId },
11454
+ meta: {
11455
+ ...summary,
11456
+ verdict: null
11457
+ }
11458
+ });
11459
+ return;
11460
+ }
10901
11461
  this.runner?.reportOccupancyBurstOutcome(deviceId, found);
10902
- log.debug("occupancy re-check: burst complete", { meta: {
10903
- collected,
11462
+ const meta = {
11463
+ ...summary,
10904
11464
  found
10905
- } });
11465
+ };
11466
+ if (summary.lost > 0) {
11467
+ log.info("occupancy re-check: burst complete", {
11468
+ tags: { deviceId },
11469
+ meta
11470
+ });
11471
+ return;
11472
+ }
11473
+ log.debug("occupancy re-check: burst complete", {
11474
+ tags: { deviceId },
11475
+ meta
11476
+ });
10906
11477
  }
10907
11478
  /**
10908
11479
  * Task 10: arm a TAP-based occupancy burst for a device whose analyzer
@@ -10938,21 +11509,30 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10938
11509
  log.debug("occupancy re-check: tap safety timeout reached", { meta: { collected } });
10939
11510
  this.finishOccupancyTap(deviceId);
10940
11511
  }, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
11512
+ const ledger = new OccupancyBurstLedger({
11513
+ settleBoundMs: OCCUPANCY_BURST_SETTLE_BOUND_MS,
11514
+ onComplete: resolve
11515
+ });
10941
11516
  this.occupancyTaps.set(deviceId, {
10942
11517
  remaining: frames,
10943
11518
  collected: 0,
10944
11519
  safetyTimer,
11520
+ ledger,
10945
11521
  resolve
10946
11522
  });
10947
11523
  });
10948
11524
  }
10949
- /** Idempotent: a no-op once the tap has already settled (timer fired, or the budget already drained). */
11525
+ /**
11526
+ * Close the tap's COLLECTION. Idempotent: a no-op once the tap has already
11527
+ * closed (timer fired, or the budget already drained). The tap's promise
11528
+ * resolves later, from its ledger, once the frames it admitted have settled.
11529
+ */
10950
11530
  finishOccupancyTap(deviceId) {
10951
11531
  const tap = this.occupancyTaps.get(deviceId);
10952
11532
  if (!tap) return;
10953
11533
  clearTimeout(tap.safetyTimer);
10954
11534
  this.occupancyTaps.delete(deviceId);
10955
- tap.resolve(tap.collected);
11535
+ tap.ledger.close();
10956
11536
  }
10957
11537
  /**
10958
11538
  * Tee an arriving analyzer motion frame into occupancy-burst detection
@@ -10967,7 +11547,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10967
11547
  teeOccupancyTapFrame(deviceId, frame, handle) {
10968
11548
  const tap = this.occupancyTaps.get(deviceId);
10969
11549
  if (!tap) return;
10970
- this.runner?.enqueueOccupancyFrame(deviceId, frame, handle);
11550
+ const settle = tap.ledger.admit();
11551
+ if (!this.runner?.enqueueOccupancyFrame(deviceId, frame, handle, settle)) settle("lost");
10971
11552
  tap.collected++;
10972
11553
  tap.remaining--;
10973
11554
  if (tap.remaining <= 0) this.finishOccupancyTap(deviceId);
@@ -10987,9 +11568,9 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10987
11568
  const lastFrameAt = this.lastMotionFrameAt.get(deviceId);
10988
11569
  const motionFresh = lastFrameAt !== void 0 && Date.now() - lastFrameAt < MOTION_FRESHNESS_WINDOW_MS;
10989
11570
  if (attachment.motionUnsubscribe !== null && motionFresh) {
10990
- const collected = await this.startOccupancyTap(deviceId, frames, log);
10991
- if (collected > 0) {
10992
- this.finishOccupancyBurst(deviceId, log, collected);
11571
+ const summary = await this.startOccupancyTap(deviceId, frames, log);
11572
+ if (summary.frames > 0) {
11573
+ this.finishOccupancyBurst(deviceId, log, summary);
10993
11574
  return;
10994
11575
  }
10995
11576
  log.debug("occupancy re-check: tap collected 0 frames, falling back to dial", { meta: { frames } });
@@ -11020,76 +11601,91 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
11020
11601
  fallback: alreadyInFlight
11021
11602
  } });
11022
11603
  let count = 0;
11604
+ /** Collection is over: no further frame is admitted to this burst. */
11605
+ let admissionClosed = false;
11606
+ /** The burst has reported its verdict (or its lack of one). */
11023
11607
  let settled = false;
11024
11608
  let teardown = null;
11025
- const cleanup = () => {
11609
+ const cleanup = (summary) => {
11026
11610
  if (settled) return;
11027
11611
  settled = true;
11028
- teardown?.();
11029
- this.finishOccupancyBurst(deviceId, log, count);
11612
+ this.finishOccupancyBurst(deviceId, log, summary);
11613
+ };
11614
+ const ledger = new OccupancyBurstLedger({
11615
+ settleBoundMs: OCCUPANCY_BURST_SETTLE_BOUND_MS,
11616
+ onComplete: cleanup
11617
+ });
11618
+ const closeAdmission = () => {
11619
+ if (admissionClosed) return;
11620
+ admissionClosed = true;
11621
+ clearTimeout(safetyTimer);
11622
+ teardown?.("drain");
11623
+ ledger.close();
11624
+ };
11625
+ /** Offer one collected frame to the burst; `true` = the runner admitted it. */
11626
+ const admit = (frame, handle) => {
11627
+ const settle = ledger.admit();
11628
+ const admitted = runner.enqueueOccupancyFrame(deviceId, frame, handle, settle);
11629
+ if (!admitted) settle("lost");
11630
+ count++;
11631
+ if (count >= frames) closeAdmission();
11632
+ return admitted;
11030
11633
  };
11031
11634
  const safetyTimer = setTimeout(() => {
11032
11635
  log.debug("occupancy re-check: safety timeout reached", { meta: { collected: count } });
11033
- cleanup();
11636
+ closeAdmission();
11034
11637
  }, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
11035
11638
  teardown = startGuardedSessionDecode({
11036
11639
  logger: log,
11037
11640
  admitWorker: this.buildLiveWorkerAdmit(deviceId, "occupancy"),
11641
+ ...this.liveWorkerSlotWaiter(deviceId),
11038
11642
  acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:occupancy:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config)),
11039
- startPump: (source) => {
11643
+ startPump: (source, hooks) => {
11040
11644
  const useLocalFrameRef = this.executorCoLocated() && process.env["CAMSTACK_DISABLE_LOCAL_FRAME_REF"] !== "1";
11041
11645
  return startSessionDecodePump({
11042
11646
  fork: this.forkDecodeWorkerWithLease,
11043
11647
  lease: this.resolveSessionLeaseKnobs,
11648
+ onWorkerGone: hooks.onWorkerGone,
11044
11649
  logger: log,
11045
11650
  source,
11046
11651
  opts: {
11047
11652
  fps: config.detectionFps,
11048
11653
  format: "rgb"
11049
11654
  },
11655
+ shouldMaterializeFrame: () => !admissionClosed,
11050
11656
  ...useLocalFrameRef ? {
11051
11657
  frameRegistry: localFrameRegistry,
11052
11658
  inlineWhen: () => runner.detectionBackpressure(config.deviceId),
11053
11659
  inlineViewSpec: SPLIT_DETECTION_VIEW,
11054
11660
  onFrameRef: (frame, nativeCrop) => {
11055
- if (settled) return false;
11661
+ if (admissionClosed) return false;
11056
11662
  const handle = writeRetainedDetectionFrame(retainedFrames, config.deviceId, frame, this.retention);
11057
11663
  if (handle && nativeCrop) {
11058
11664
  this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
11059
11665
  this.bindFrameCutter(handle, nativeCrop);
11060
11666
  }
11061
- const admitted = runner.enqueueOccupancyFrame(deviceId, frame, handle);
11667
+ const admitted = admit(frame, handle);
11062
11668
  if (admitted) nativeCrop?.markInferred();
11063
- count++;
11064
- if (count >= frames) {
11065
- clearTimeout(safetyTimer);
11066
- cleanup();
11067
- }
11068
11669
  return admitted;
11069
11670
  }
11070
11671
  } : { inlineViewSpec: SPLIT_DETECTION_VIEW },
11071
11672
  acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "occupancy"),
11072
- onEnded: () => cleanup(),
11673
+ onEnded: () => closeAdmission(),
11073
11674
  onDecodedFrame: (frame, nativeCrop) => {
11074
- if (settled) return;
11675
+ if (admissionClosed) return;
11075
11676
  const handle = writeRetainedDetectionFrame(retainedFrames, config.deviceId, frame, this.retention);
11076
11677
  if (handle && nativeCrop) {
11077
11678
  this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
11078
11679
  this.bindFrameCutter(handle, nativeCrop);
11079
11680
  }
11080
- if (runner.enqueueOccupancyFrame(deviceId, frame, handle)) nativeCrop?.markInferred();
11081
- count++;
11082
- if (count >= frames) {
11083
- clearTimeout(safetyTimer);
11084
- cleanup();
11085
- }
11681
+ if (admit(frame, handle)) nativeCrop?.markInferred();
11086
11682
  }
11087
11683
  });
11088
11684
  }
11089
11685
  });
11090
- if (settled) {
11686
+ if (admissionClosed) {
11091
11687
  clearTimeout(safetyTimer);
11092
- teardown();
11688
+ teardown("drain");
11093
11689
  }
11094
11690
  }
11095
11691
  emitInferenceResult(deviceId, frame, result, handle, source, occasion) {