@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
@@ -3,10 +3,10 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-BsVcf5wO.js");
7
- const require_node = require("../node-UFk6I2f6.js");
8
- const require_default_detection_model = require("../default-detection-model-wNEISVA9.js");
9
- const require_worker_protocol = require("../worker-protocol-B2MfQLlu.js");
6
+ const require_dist = require("../dist-DUqr1zyq.js");
7
+ const require_node = require("../node-Dx6DLQ1j.js");
8
+ const require_default_detection_model = require("../default-detection-model-B2n_SFXt.js");
9
+ const require_worker_protocol = require("../worker-protocol-C-G8qmye.js");
10
10
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
11
11
  const require_lazy_sharp = require("../lazy-sharp-BuwKlQLR.js");
12
12
  const require_restream_intent = require("../restream-intent-7uDNqMKs.js");
@@ -21,7 +21,7 @@ let node_perf_hooks = require("node:perf_hooks");
21
21
  let _camstack_system_addon_utils = require("@camstack/system/addon-utils");
22
22
  //#region src/session-decode/fork-decode-worker.ts
23
23
  /** Absolute path to the built decode-worker child entry (a vite build entry). */
24
- var CHILD_ENTRY_PATH = (0, node_url.fileURLToPath)(new URL("../session-decode/decode-worker-child.js", require("url").pathToFileURL(__filename).href));
24
+ var CHILD_ENTRY_PATH = (0, node_url.fileURLToPath)(new URL("../session-decode/decode-worker-main.js", require("url").pathToFileURL(__filename).href));
25
25
  /**
26
26
  * Known kill signals the coordinator may pass, mapped to their `NodeJS.Signals`
27
27
  * literal so `ChildProcess.kill` receives a correctly-typed signal without a
@@ -42,21 +42,36 @@ var KILL_SIGNALS = {
42
42
  * every listener added out here is one somebody can forget to remove.
43
43
  */
44
44
  function wrapChild(child, onExit) {
45
- child.on("error", () => void 0);
46
- child.on("exit", onExit);
45
+ let spawned = false;
46
+ let exitDelivered = false;
47
+ const exitListeners = [];
48
+ const deliverExit = (info) => {
49
+ if (exitDelivered) return;
50
+ exitDelivered = true;
51
+ onExit();
52
+ for (const listener of exitListeners) listener(info);
53
+ };
54
+ child.on("spawn", () => {
55
+ spawned = true;
56
+ });
57
+ child.on("exit", (code, signal) => deliverExit({
58
+ code: code ?? null,
59
+ signal: signal ?? null
60
+ }));
61
+ child.on("error", () => {
62
+ if (!spawned) deliverExit({
63
+ code: null,
64
+ signal: null
65
+ });
66
+ });
47
67
  return {
48
68
  send: (msg) => {
49
69
  if (!child.connected) return;
50
70
  child.send(msg, () => void 0);
51
71
  },
52
72
  on: (ev, cb) => {
53
- if (ev === "exit") {
54
- const toInfo = (code, signal) => ({
55
- code,
56
- signal
57
- });
58
- child.on("exit", (code, signal) => cb(toInfo(code ?? null, signal ?? null)));
59
- } else child.on("message", (m) => cb(m));
73
+ if (ev === "exit") exitListeners.push(cb);
74
+ else child.on("message", (m) => cb(m));
60
75
  },
61
76
  kill: (signal) => {
62
77
  if (signal === void 0) {
@@ -257,58 +272,77 @@ var DEFAULT_ACQUIRE_RETRY_DELAYS_MS = [
257
272
  15e3,
258
273
  3e4
259
274
  ];
260
- /**
261
- * Guards the in-flight restream-acquire against a detach/unsubscribe race,
262
- * and retries an acquire that fails.
263
- *
264
- * `acquireSessionDecodeRestream` + forking the decode worker both take real
265
- * time (a broker round-trip, then a process fork). If the caller's teardown
266
- * runs while that acquire is still in flight, the closure that would release
267
- * the restream and stop the pump does not exist yet by the time the caller
268
- * stores it — a naive `await acquire(); return () => {...}` construction
269
- * leaks the acquired restream refcount and, once the acquire resolves, forks
270
- * an orphan decode worker nobody will ever stop.
271
- *
272
- * This helper returns the teardown SYNCHRONOUSLY. The acquire + pump-start
273
- * run in the background; an `aborted` flag recorded by the (possibly
274
- * already-called) teardown is checked right after each acquire resolves —
275
- * if set, any restream is released immediately and the pump is NEVER
276
- * started (no orphan worker). Otherwise the pump starts and the teardown,
277
- * once called, stops the pump then releases the restream.
278
- *
279
- * **A failed acquire is retried, not swallowed.** The teardown handed back
280
- * here is stored by the caller (as `attachment.detectionUnsubscribe`) the
281
- * instant this returns, so a `return` on failure left that handle permanently
282
- * non-null with no pump behind it: `onEnded` could never fire because nothing
283
- * had ever started, the camera's phase stayed `active`, and not one frame
284
- * arrived again. The motion path was hardened against this shape by a frame
285
- * freshness window (`MOTION_FRESHNESS_WINDOW_MS`); this is the detection
286
- * path's half.
287
- */
275
+ /** A restream release that runs once however many paths reach it. */
276
+ function onceRelease(release) {
277
+ let done = false;
278
+ return () => {
279
+ if (done) return Promise.resolve();
280
+ done = true;
281
+ return release();
282
+ };
283
+ }
288
284
  function startGuardedSessionDecode(deps) {
289
285
  let aborted = false;
286
+ /** The mode the teardown asked for — replayed if it raced `startPump`. */
287
+ let abortMode = "drain";
290
288
  let stopPump = null;
291
289
  let release = null;
292
290
  let retryTimer = null;
293
291
  /** The live-worker slot held by the RUNNING session, released on teardown. */
294
292
  let workerLease = null;
295
293
  const delays = deps.acquireRetryDelaysMs && deps.acquireRetryDelaysMs.length > 0 ? deps.acquireRetryDelaysMs : DEFAULT_ACQUIRE_RETRY_DELAYS_MS;
296
- /** Resolves after the backoff for `attempt`, or immediately once aborted. */
297
- const waitBackoff = (attempt) => new Promise((resolve) => {
294
+ /** Ends a parked start's wait when the session is torn down. */
295
+ let abortWait = null;
296
+ /**
297
+ * This start's place in the ceiling queue — taken on its first refusal, kept
298
+ * to the end so a re-park keeps the FIFO position (D652).
299
+ */
300
+ let waiter = null;
301
+ /**
302
+ * The start was handed a returned slot and has not yet ASKED for it. Set on
303
+ * a wake, cleared by the next `admitWorker` call; a start torn down while
304
+ * this is set passes the slot on (`SlotWaiter.leave(true)`), or nobody gets
305
+ * it and every other parked start waits out its full timer.
306
+ */
307
+ let wakeInHand = false;
308
+ /**
309
+ * Resolves after the backoff for `attempt` (`'timer'`) — or, when `parked`,
310
+ * as soon as the node hands this start a returned slot (`'woken'`) or the
311
+ * session is torn down (`'torn-down'`), whichever is first. The start leaves
312
+ * the queue on every exit, so a torn-down start leaves no listener behind.
313
+ */
314
+ const waitBackoff = (attempt, parked) => new Promise((resolve) => {
298
315
  const ms = delays[Math.min(attempt, delays.length - 1)] ?? delays[delays.length - 1] ?? 2e3;
299
- retryTimer = setTimeout(() => {
300
- retryTimer = null;
301
- resolve();
302
- }, ms);
316
+ const done = (why) => {
317
+ if (retryTimer !== null) {
318
+ clearTimeout(retryTimer);
319
+ retryTimer = null;
320
+ }
321
+ parked?.cancel();
322
+ abortWait = null;
323
+ resolve(why);
324
+ };
325
+ retryTimer = setTimeout(() => done("timer"), ms);
303
326
  retryTimer.unref?.();
327
+ abortWait = () => done("torn-down");
328
+ if (parked) parked.wait(() => done("woken"));
304
329
  });
305
- (async () => {
330
+ const releaseWorkerSlot = () => {
331
+ workerLease?.release();
332
+ workerLease = null;
333
+ };
334
+ const startLoop = async () => {
306
335
  for (let attempt = 0; !aborted; attempt++) {
307
336
  let lease = null;
308
337
  if (deps.admitWorker) {
338
+ wakeInHand = false;
309
339
  lease = deps.admitWorker();
310
340
  if (!lease) {
311
- await waitBackoff(attempt);
341
+ waiter ??= deps.slotWaiter?.() ?? null;
342
+ if (await waitBackoff(attempt, waiter) === "woken") {
343
+ attempt -= 1;
344
+ wakeInHand = true;
345
+ }
312
346
  continue;
313
347
  }
314
348
  }
@@ -321,7 +355,7 @@ function startGuardedSessionDecode(deps) {
321
355
  attempt: attempt + 1,
322
356
  error: require_dist.errMsg(err)
323
357
  } });
324
- await waitBackoff(attempt);
358
+ await waitBackoff(attempt, null);
325
359
  continue;
326
360
  }
327
361
  if (aborted) {
@@ -332,27 +366,47 @@ function startGuardedSessionDecode(deps) {
332
366
  if (!acquired) {
333
367
  lease?.release();
334
368
  (attempt === 0 ? deps.logger.warn : deps.logger.debug).call(deps.logger, "session-decode: no broker profile bound yet — retrying", { meta: { attempt: attempt + 1 } });
335
- await waitBackoff(attempt);
369
+ await waitBackoff(attempt, null);
336
370
  continue;
337
371
  }
338
372
  if (attempt > 0) deps.logger.info("session-decode: acquire succeeded after retry", { meta: { attempts: attempt + 1 } });
339
- release = acquired.release;
373
+ release = onceRelease(acquired.release);
340
374
  workerLease = lease;
341
- stopPump = deps.startPump(acquired.source);
375
+ try {
376
+ stopPump = deps.startPump(acquired.source, { onWorkerGone: releaseWorkerSlot });
377
+ } catch (err) {
378
+ releaseWorkerSlot();
379
+ const restream = release;
380
+ release = null;
381
+ restream?.();
382
+ throw err;
383
+ }
384
+ if (aborted) stopPump(abortMode);
342
385
  return;
343
386
  }
344
- })().catch((err) => {
387
+ };
388
+ startLoop().catch((err) => {
345
389
  deps.logger.warn("session-decode: guarded start failed", { meta: { error: require_dist.errMsg(err) } });
390
+ }).finally(() => {
391
+ waiter?.leave(wakeInHand);
392
+ waiter = null;
346
393
  });
347
- return () => {
394
+ return (mode = "drain") => {
395
+ if (aborted) {
396
+ if (mode === "immediate" && abortMode !== "immediate") {
397
+ abortMode = mode;
398
+ stopPump?.(mode);
399
+ }
400
+ return;
401
+ }
348
402
  aborted = true;
403
+ abortMode = mode;
349
404
  if (retryTimer !== null) {
350
405
  clearTimeout(retryTimer);
351
406
  retryTimer = null;
352
407
  }
353
- stopPump?.();
354
- workerLease?.release();
355
- workerLease = null;
408
+ abortWait?.();
409
+ stopPump?.(mode);
356
410
  if (release) release();
357
411
  };
358
412
  }
@@ -421,6 +475,21 @@ var LiveDecodeWorkerAdmission = class {
421
475
  limit;
422
476
  logger;
423
477
  live = 0;
478
+ /**
479
+ * Starts the ceiling refused, waiting to be told a slot came back — FIFO by
480
+ * the start's FIRST park (D652): the key's `seq` is fixed when the start
481
+ * takes its {@link SlotWaiter}, so a re-park keeps the place.
482
+ */
483
+ parked = /* @__PURE__ */ new Map();
484
+ nextSeq = 0;
485
+ /**
486
+ * Cameras currently refused. The refusal WARN fires on the transition into
487
+ * this set, not on every retry: a start woken or retried while the node is
488
+ * still full says so at debug (D652 — a wake must not become a WARN storm).
489
+ * Cleared on admit AND when a parked start leaves (`SlotWaiter.leave`): a
490
+ * camera torn down while refused must be news again next time (D391).
491
+ */
492
+ refusedDevices = /* @__PURE__ */ new Set();
424
493
  nodeBudgetMb;
425
494
  workerFootprintMb;
426
495
  constructor(options) {
@@ -429,6 +498,48 @@ var LiveDecodeWorkerAdmission = class {
429
498
  this.nodeBudgetMb = positiveOrUndefined(options.nodeBudgetMb);
430
499
  this.workerFootprintMb = positiveOrUndefined(options.workerFootprintMb);
431
500
  }
501
+ /** Starts parked on a {@link SlotWaiter} right now — diagnostics and tests. */
502
+ get slotWaiters() {
503
+ return this.parked.size;
504
+ }
505
+ /**
506
+ * This start's place in the queue. A refused start waits on it instead of a
507
+ * fixed timer, so a slot freed by a draining worker's exit is taken at once
508
+ * (D652). Take it once per start, on the first refusal; keep it to the end.
509
+ */
510
+ slotWaiter(deviceId) {
511
+ const key = {
512
+ seq: this.nextSeq,
513
+ deviceId
514
+ };
515
+ this.nextSeq += 1;
516
+ return {
517
+ wait: (listener) => {
518
+ this.parked.set(key, listener);
519
+ },
520
+ cancel: () => {
521
+ this.parked.delete(key);
522
+ },
523
+ leave: (unusedWake) => {
524
+ this.parked.delete(key);
525
+ this.refusedDevices.delete(deviceId);
526
+ if (unusedWake) this.wakeNext();
527
+ }
528
+ };
529
+ }
530
+ /**
531
+ * ONE slot, ONE wake: the longest-parked start (lowest `seq`). Waking all N
532
+ * produced N−1 refusals per freed slot. The woken start leaves the queue; if
533
+ * something else took the slot first it parks again, at the same place.
534
+ */
535
+ wakeNext() {
536
+ let first;
537
+ for (const key of this.parked.keys()) if (first === void 0 || key.seq < first.seq) first = key;
538
+ if (first === void 0) return;
539
+ const wake = this.parked.get(first);
540
+ this.parked.delete(first);
541
+ wake?.();
542
+ }
432
543
  /** Workers currently holding a slot. */
433
544
  get inFlight() {
434
545
  return this.live;
@@ -485,7 +596,9 @@ var LiveDecodeWorkerAdmission = class {
485
596
  admit(deviceId, label) {
486
597
  const ceiling = this.ceiling;
487
598
  if (this.live >= ceiling) {
488
- this.logger.warn("session-decode: REFUSED — node live decode-worker ceiling reached, will retry", {
599
+ const firstRefusal = !this.refusedDevices.has(deviceId);
600
+ this.refusedDevices.add(deviceId);
601
+ (firstRefusal ? this.logger.warn : this.logger.debug).call(this.logger, "session-decode: REFUSED — node live decode-worker ceiling reached, will retry", {
489
602
  tags: { deviceId },
490
603
  meta: {
491
604
  label,
@@ -501,6 +614,7 @@ var LiveDecodeWorkerAdmission = class {
501
614
  return null;
502
615
  }
503
616
  this.live += 1;
617
+ this.refusedDevices.delete(deviceId);
504
618
  this.logger.debug("session-decode: live decode-worker slot taken", {
505
619
  tags: { deviceId },
506
620
  meta: {
@@ -514,6 +628,7 @@ var LiveDecodeWorkerAdmission = class {
514
628
  if (released) return;
515
629
  released = true;
516
630
  this.live -= 1;
631
+ this.wakeNext();
517
632
  } };
518
633
  }
519
634
  };
@@ -523,6 +638,28 @@ function positiveOrUndefined(value) {
523
638
  return Number.isFinite(value) && value > 0 ? value : void 0;
524
639
  }
525
640
  //#endregion
641
+ //#region src/pipeline-runner/retained-reader-hold.ts
642
+ /**
643
+ * Run `read` with `handle`'s parked frame held as a CONSUMER for its whole
644
+ * duration (D656).
645
+ *
646
+ * `getNativeCrop` has two rungs: a native crop, then — on a miss — the retained
647
+ * full-frame fallback. Between them nothing is in flight: the crop's reply
648
+ * settles the worker's last pending request in the same turn, and the fallback's
649
+ * `toBuffer` is only sent on a later microtask. A parked frame does not keep a
650
+ * draining worker alive, so without this hold the drain killed the worker in
651
+ * that gap and the fallback was lost. One rule instead of a timing trick: a
652
+ * read in progress is a consumer.
653
+ */
654
+ async function withRetainedReader(store, handle, read) {
655
+ const release = store?.holdReader(handle) ?? null;
656
+ try {
657
+ return await read();
658
+ } finally {
659
+ release?.();
660
+ }
661
+ }
662
+ //#endregion
526
663
  //#region src/session-decode/native-crop-lease-age.ts
527
664
  /**
528
665
  * THE age bound on a native crop lease (ADR-0419).
@@ -865,6 +1002,8 @@ function createRetainedContentFallback(registry, ref) {
865
1002
  timestamp: ref.timestamp,
866
1003
  ...ref.capturedAt !== void 0 ? { capturedAt: ref.capturedAt } : {}
867
1004
  };
1005
+ } catch {
1006
+ return null;
868
1007
  } finally {
869
1008
  lease.release(reason);
870
1009
  }
@@ -1307,6 +1446,14 @@ var RetainedFrameStore = class RetainedFrameStore {
1307
1446
  if (current) current.hydrating = void 0;
1308
1447
  }
1309
1448
  }
1449
+ /**
1450
+ * Hold `handle`'s parked frame as a reader until the returned release runs
1451
+ * (D656). `null` when the handle has no un-hydrated fallback — bytes already
1452
+ * in RAM need no worker.
1453
+ */
1454
+ holdReader(handle) {
1455
+ return this.entries.get(keyOf(handle))?.fallback?.holdReader?.() ?? null;
1456
+ }
1310
1457
  /** Drop every retained frame (node/addon shutdown). */
1311
1458
  clear() {
1312
1459
  for (const entry of this.entries.values()) entry.fallback?.release?.();
@@ -1450,6 +1597,48 @@ function cropRgb(frame, bbox, maxWidth) {
1450
1597
  /** How long to wait after `kill()` before escalating to `kill('SIGKILL')`. */
1451
1598
  var SIGKILL_FALLBACK_MS = 1e3;
1452
1599
  /**
1600
+ * The longest a planned teardown keeps a session's worker alive so the frames
1601
+ * it already handed out can be read (D652).
1602
+ *
1603
+ * What is being waited for is the REST of a frame's trip through the runner —
1604
+ * semaphore wait + inference + result — for frames that were admitted before
1605
+ * the detach. Measured on the live hub 2026-09-26 over one hour of
1606
+ * `pipeline stats` (2 254 per-camera 10 s windows): the WORST enqueue→emit in a
1607
+ * window was ≤1 000 ms in 96.7% of windows and ≤1 500 ms in 98.0%; going to
1608
+ * 5 000 ms buys only another 0.9%, and that tail is the inference-pool GPU hang,
1609
+ * which no wait here can rescue. The cost of waiting is one idle worker (its
1610
+ * decode already stopped by `drain`) for at most this long, per detach.
1611
+ *
1612
+ * A frame still held when this expires is not dropped silently: the
1613
+ * coordinator logs the count, tagged with the camera, and then kills.
1614
+ */
1615
+ var SESSION_DECODE_DRAIN_BOUND_MS = 1500;
1616
+ /**
1617
+ * The miss reason for a native crop requested after its session's teardown.
1618
+ * Only crops already SENT when the drain began are waited for (D652); a later
1619
+ * one would ask a worker that is about to be killed.
1620
+ */
1621
+ var NATIVE_CROP_REFUSED_DRAINING = "session draining — crop requested after teardown";
1622
+ /**
1623
+ * The refusal for a `toBuffer` asked for once the worker has been told to exit
1624
+ * (D656). A read during a DRAIN is served — that is what the drain is for — but
1625
+ * after the kill no reply can come, and a raw rejection read as a worker fault.
1626
+ */
1627
+ var TO_BUFFER_REFUSED_KILLED = "session killed — read requested after the worker was told to exit";
1628
+ /** The refusal for a `toBuffer` the worker is no longer there to answer. */
1629
+ var TO_BUFFER_REFUSED_EXITED = "worker already exited";
1630
+ /** A read the session declined to send, by name — never a worker fault. */
1631
+ var SessionDecodeReadRefusedError = class extends Error {
1632
+ reason;
1633
+ frameId;
1634
+ constructor(reason, frameId) {
1635
+ super(`session-decode-coordinator: ${reason} (frameId=${frameId})`);
1636
+ this.reason = reason;
1637
+ this.frameId = frameId;
1638
+ this.name = "SessionDecodeReadRefusedError";
1639
+ }
1640
+ };
1641
+ /**
1453
1642
  * How often the cold-start wait REPORTS that it is still waiting. Task 0
1454
1643
  * live-measured cold-start (restream dial + hwaccel context + keyframe wait) at
1455
1644
  * 5-8s, so this must tolerate a slow-but-healthy start.
@@ -1521,30 +1710,45 @@ var SessionDecodeSession = class {
1521
1710
  firstFrameTimeoutMs;
1522
1711
  frameTimeoutMs;
1523
1712
  firstFrameMaxWaitMs;
1524
- onDialTiming;
1525
- onPreRollReport;
1713
+ hooks;
1526
1714
  pendingPull = null;
1527
1715
  pendingToBuffers = /* @__PURE__ */ new Map();
1528
1716
  /** Outstanding `nativeCrop` requests, keyed by the monotonic `requestId`. */
1529
1717
  pendingNativeCrops = /* @__PURE__ */ new Map();
1530
1718
  nextNativeCropId = 1;
1531
1719
  exited = false;
1532
- /** Set by teardown() so handleExit can tell an intentional stop from a death. */
1720
+ /** Set by teardown() — the session is ending, whether or not it drains first. */
1533
1721
  stopRequested = false;
1722
+ /** Set when `kill()` is sent, so handleExit can tell an intentional stop from a death. */
1723
+ killSent = false;
1724
+ /**
1725
+ * Images yielded and not yet closed that a CONSUMER still holds — queued,
1726
+ * in flight, or being read. Each dies with the worker and somebody was going
1727
+ * to read it first. This is what a drain waits on.
1728
+ */
1729
+ consumerImages = 0;
1730
+ /**
1731
+ * Images yielded and not yet closed that only a RETENTION holder keeps —
1732
+ * parked for a read that may come later (the on-demand JPEG fallback, D656).
1733
+ * They die with the worker too, but nobody reads them inside any bound worth
1734
+ * holding a worker for, so a drain does NOT wait on them.
1735
+ */
1736
+ retainedImages = 0;
1737
+ /** Armed while a drain waits; its expiry kills with frames still held. */
1738
+ drainTimer = null;
1534
1739
  sigkillTimer = null;
1535
1740
  pullTimeoutTimer = null;
1536
1741
  firstFrameReceived = false;
1537
1742
  /** `Date.now()` of the FIRST `pull` — the origin of the cold-start ceiling. */
1538
1743
  firstPullAt = 0;
1539
- constructor(child, logger, deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs, onDialTiming, onPreRollReport) {
1744
+ constructor(child, logger, deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs, hooks) {
1540
1745
  this.child = child;
1541
1746
  this.logger = logger;
1542
1747
  this.deviceId = deviceId;
1543
1748
  this.firstFrameTimeoutMs = firstFrameTimeoutMs;
1544
1749
  this.frameTimeoutMs = frameTimeoutMs;
1545
1750
  this.firstFrameMaxWaitMs = firstFrameMaxWaitMs;
1546
- this.onDialTiming = onDialTiming;
1547
- this.onPreRollReport = onPreRollReport;
1751
+ this.hooks = hooks;
1548
1752
  this.child.on("message", (m) => this.handleMessage(m));
1549
1753
  this.child.on("exit", (m) => this.handleExit(toWorkerExitInfo(m)));
1550
1754
  }
@@ -1583,7 +1787,8 @@ var SessionDecodeSession = class {
1583
1787
  }
1584
1788
  /** Send `toBuffer` for `frameId` and resolve with the matching `buffer` reply's bytes. */
1585
1789
  requestBuffer(frameId, opts) {
1586
- if (this.exited) return Promise.reject(/* @__PURE__ */ new Error(`session-decode-coordinator: worker already exited (frameId=${frameId})`));
1790
+ if (this.exited) return Promise.reject(new SessionDecodeReadRefusedError(TO_BUFFER_REFUSED_EXITED, frameId));
1791
+ if (this.killSent) return Promise.reject(new SessionDecodeReadRefusedError(TO_BUFFER_REFUSED_KILLED, frameId));
1587
1792
  return new Promise((resolve, reject) => {
1588
1793
  const waiter = {
1589
1794
  resolve,
@@ -1609,6 +1814,10 @@ var SessionDecodeSession = class {
1609
1814
  */
1610
1815
  requestNativeCrop(frameId, bbox, maxWidth) {
1611
1816
  if (this.exited) return Promise.resolve({ kind: "miss" });
1817
+ if (this.stopRequested) return Promise.resolve({
1818
+ kind: "miss",
1819
+ workerReason: NATIVE_CROP_REFUSED_DRAINING
1820
+ });
1612
1821
  const requestId = this.nextNativeCropId++;
1613
1822
  return new Promise((resolve) => {
1614
1823
  this.pendingNativeCrops.set(requestId, { resolve });
@@ -1623,6 +1832,7 @@ var SessionDecodeSession = class {
1623
1832
  } catch {
1624
1833
  this.pendingNativeCrops.delete(requestId);
1625
1834
  resolve({ kind: "miss" });
1835
+ this.settleDrainIfDone();
1626
1836
  }
1627
1837
  });
1628
1838
  }
@@ -1667,20 +1877,169 @@ var SessionDecodeSession = class {
1667
1877
  frameId
1668
1878
  });
1669
1879
  }
1670
- /** Teardown: `stop`, `kill()`, arm the SIGKILL fallback unless already exited. */
1671
- teardown() {
1880
+ /** A yielded image the consumer now holds — see {@link consumerImages}. */
1881
+ noteImageOpened() {
1882
+ this.consumerImages += 1;
1883
+ }
1884
+ /**
1885
+ * An open image moved between a consumer and a retention-only holder
1886
+ * (D656). Losing its last consumer may complete a waiting drain.
1887
+ */
1888
+ noteImageHolder(pendingConsumer) {
1889
+ if (pendingConsumer) {
1890
+ this.retainedImages = Math.max(0, this.retainedImages - 1);
1891
+ this.consumerImages += 1;
1892
+ return;
1893
+ }
1894
+ this.consumerImages = Math.max(0, this.consumerImages - 1);
1895
+ this.retainedImages += 1;
1896
+ this.settleDrainIfDone();
1897
+ }
1898
+ /** A yielded image was closed; a waiting drain may now be done. */
1899
+ noteImageClosed(pendingConsumer) {
1900
+ if (pendingConsumer) this.consumerImages = Math.max(0, this.consumerImages - 1);
1901
+ else this.retainedImages = Math.max(0, this.retainedImages - 1);
1902
+ this.settleDrainIfDone();
1903
+ }
1904
+ /**
1905
+ * End the session.
1906
+ *
1907
+ * `'drain'` with frames still held: tell the worker to stop decoding (it
1908
+ * keeps every frame it holds), and kill only once every yielded image has
1909
+ * been closed and every read answered — or when
1910
+ * {@link SESSION_DECODE_DRAIN_BOUND_MS} expires, which is logged. Nothing
1911
+ * held: `stop` + SIGTERM now. `'immediate'`: SIGKILL now.
1912
+ *
1913
+ * A SECOND call that says `'immediate'` while a drain is in progress
1914
+ * ESCALATES (D652): addon shutdown lands on sessions already draining from
1915
+ * a detach, and the runner exits right after — a drain it does not wait for
1916
+ * is an orphaned worker holding its GPU pool. Any other repeat is ignored.
1917
+ */
1918
+ teardown(mode) {
1919
+ if (this.stopRequested) {
1920
+ if (mode === "immediate") this.escalateToImmediate();
1921
+ return;
1922
+ }
1672
1923
  this.stopRequested = true;
1673
1924
  this.clearPullTimeout();
1925
+ if (this.exited) return;
1926
+ if (mode === "drain" && this.unsettledCount() > 0) {
1927
+ this.trySend({ kind: "drain" });
1928
+ this.drainTimer = setTimeout(() => this.handleDrainBoundExpired(), SESSION_DECODE_DRAIN_BOUND_MS);
1929
+ this.drainTimer.unref?.();
1930
+ return;
1931
+ }
1932
+ this.killNow(mode);
1933
+ }
1934
+ /** The drain (or a SIGTERM still waiting on its fallback) gives way to SIGKILL now. */
1935
+ escalateToImmediate() {
1936
+ if (this.exited) return;
1937
+ if (this.drainTimer !== null) {
1938
+ clearTimeout(this.drainTimer);
1939
+ this.drainTimer = null;
1940
+ }
1941
+ if (!this.killSent) {
1942
+ this.killNow("immediate");
1943
+ return;
1944
+ }
1945
+ if (this.sigkillTimer !== null) {
1946
+ clearTimeout(this.sigkillTimer);
1947
+ this.sigkillTimer = null;
1948
+ }
1949
+ this.tryKill("SIGKILL");
1950
+ }
1951
+ /**
1952
+ * Frames a CONSUMER still holds, reads still waiting on the worker, and
1953
+ * native crops already REQUESTED. A crop asked for before the teardown
1954
+ * request is waited for; one asked for after it is refused by name — post-analysis's
1955
+ * ordinary crop arrives 2.2–9.9 s after the frame, past any bound worth
1956
+ * holding a worker for, and is served from subject tiles that die with the
1957
+ * worker either way.
1958
+ *
1959
+ * A frame only PARKED for such a late read ({@link retainedImages}) is not
1960
+ * counted, for the same reason (D656): counting it made nearly every
1961
+ * teardown wait the full bound for frames nobody was going to read. Its read,
1962
+ * once STARTED, is a pending read like any other and is waited for.
1963
+ */
1964
+ unsettledCount() {
1965
+ return this.consumerImages + this.pendingReadCount() + this.pendingNativeCrops.size;
1966
+ }
1967
+ /** What the session still holds, per category — every drain line carries it. */
1968
+ heldCounts() {
1969
+ return {
1970
+ consumerFrames: this.consumerImages,
1971
+ retainedFrames: this.retainedImages,
1972
+ pendingReads: this.pendingReadCount(),
1973
+ pendingCrops: this.pendingNativeCrops.size
1974
+ };
1975
+ }
1976
+ pendingReadCount() {
1977
+ let n = 0;
1978
+ for (const waiters of this.pendingToBuffers.values()) n += waiters.length;
1979
+ return n;
1980
+ }
1981
+ settleDrainIfDone() {
1982
+ if (this.drainTimer === null || this.unsettledCount() > 0) return;
1983
+ clearTimeout(this.drainTimer);
1984
+ this.drainTimer = null;
1985
+ this.killNow("drain");
1986
+ }
1987
+ /**
1988
+ * The drain ran out of time with frames still held. Those frames die with
1989
+ * the worker; that is a branch that DROPS work (D391), so it says how many,
1990
+ * on which camera.
1991
+ */
1992
+ handleDrainBoundExpired() {
1993
+ this.drainTimer = null;
1994
+ if (this.exited) return;
1995
+ this.logger.warn("session-decode-coordinator: drain bound expired — killing the worker with frames still held", {
1996
+ tags: { deviceId: this.deviceId },
1997
+ meta: {
1998
+ ...this.heldCounts(),
1999
+ boundMs: SESSION_DECODE_DRAIN_BOUND_MS
2000
+ }
2001
+ });
2002
+ this.killNow("drain");
2003
+ }
2004
+ /**
2005
+ * `stop`, then the kill — unless already exited.
2006
+ *
2007
+ * `'drain'`: SIGTERM, so the child flushes its last lines (`bounded-exit.ts`),
2008
+ * and a SIGKILL fallback. `'immediate'`: SIGKILL outright. Since D652 gave the
2009
+ * child a SIGTERM handler, SIGTERM is no longer instant death: a worker
2010
+ * blocked in a native call (a VAAPI hang) never runs the handler, and the
2011
+ * fallback was an `unref`'d timer the exiting parent did not wait for — so
2012
+ * on shutdown that worker was orphaned with its GPU pool. The fallback timer
2013
+ * is now ref'd: it exists only for a drain while the parent is alive (a
2014
+ * shutdown escalates to SIGKILL and clears it), and a timer that keeps the
2015
+ * process up for at most one second is the price of a kill that lands.
2016
+ */
2017
+ killNow(mode) {
2018
+ if (this.killSent) return;
2019
+ this.killSent = true;
2020
+ if (this.retainedImages > 0) this.logger.debug("session-decode-coordinator: retained frames die with the worker", {
2021
+ tags: { deviceId: this.deviceId },
2022
+ meta: {
2023
+ mode,
2024
+ ...this.heldCounts()
2025
+ }
2026
+ });
1674
2027
  this.trySend({ kind: "stop" });
2028
+ if (mode === "immediate") {
2029
+ this.tryKill("SIGKILL");
2030
+ return;
2031
+ }
1675
2032
  this.tryKill(void 0);
1676
2033
  if (this.exited) return;
1677
2034
  this.sigkillTimer = setTimeout(() => {
1678
2035
  this.sigkillTimer = null;
1679
2036
  if (this.exited) return;
1680
- this.logger.warn("session-decode-coordinator: child did not exit within SIGKILL fallback window — forcing", { meta: { fallbackMs: SIGKILL_FALLBACK_MS } });
2037
+ this.logger.warn("session-decode-coordinator: child did not exit within SIGKILL fallback window — forcing", {
2038
+ tags: { deviceId: this.deviceId },
2039
+ meta: { fallbackMs: SIGKILL_FALLBACK_MS }
2040
+ });
1681
2041
  this.tryKill("SIGKILL");
1682
2042
  }, SIGKILL_FALLBACK_MS);
1683
- this.sigkillTimer.unref?.();
1684
2043
  }
1685
2044
  handleMessage(raw) {
1686
2045
  if (!require_worker_protocol.isWorkerReply(raw)) {
@@ -1729,7 +2088,7 @@ var SessionDecodeSession = class {
1729
2088
  this.logger[raw.level](raw.message);
1730
2089
  return;
1731
2090
  case "dialTiming":
1732
- this.onDialTiming?.({
2091
+ this.hooks.onDialTiming?.({
1733
2092
  hwContextMs: raw.hwContextMs,
1734
2093
  demuxerOpenMs: raw.demuxerOpenMs,
1735
2094
  decoderCreateMs: raw.decoderCreateMs,
@@ -1737,25 +2096,43 @@ var SessionDecodeSession = class {
1737
2096
  streamInfoProbed: raw.streamInfoProbed
1738
2097
  });
1739
2098
  return;
2099
+ case "preRollSettled":
2100
+ this.hooks.onPreRollSettled?.({
2101
+ read: raw.read,
2102
+ released: raw.released,
2103
+ lostByReason: raw.lostByReason,
2104
+ unsettled: raw.unsettled
2105
+ });
2106
+ return;
1740
2107
  case "preRollReport":
1741
- this.onPreRollReport?.({
2108
+ this.hooks.onPreRollReport?.({
1742
2109
  offered: raw.offered,
1743
2110
  inferred: raw.inferred,
1744
2111
  dropped: raw.dropped,
1745
2112
  droppedByReason: raw.droppedByReason,
1746
2113
  strideMs: raw.strideMs,
1747
2114
  subsampled: raw.subsampled,
1748
- spanMs: raw.spanMs
2115
+ spanMs: raw.spanMs,
2116
+ budgetFrames: raw.budgetFrames
1749
2117
  });
1750
2118
  return;
1751
2119
  }
1752
2120
  }
1753
2121
  handleExit(info) {
1754
- if (!this.stopRequested && !this.exited) this.logger.warn("session-decode worker exited unexpectedly", { meta: {
1755
- code: info?.code ?? null,
1756
- signal: info?.signal ?? null
1757
- } });
2122
+ if (!this.exited) this.hooks.onWorkerExit?.();
2123
+ if (!this.killSent && !this.exited) this.logger.warn(this.stopRequested ? "session-decode worker exited unexpectedly while draining" : "session-decode worker exited unexpectedly", {
2124
+ tags: { deviceId: this.deviceId },
2125
+ meta: {
2126
+ code: info?.code ?? null,
2127
+ signal: info?.signal ?? null,
2128
+ ...this.stopRequested ? this.heldCounts() : {}
2129
+ }
2130
+ });
1758
2131
  this.exited = true;
2132
+ if (this.drainTimer) {
2133
+ clearTimeout(this.drainTimer);
2134
+ this.drainTimer = null;
2135
+ }
1759
2136
  if (this.sigkillTimer) {
1760
2137
  clearTimeout(this.sigkillTimer);
1761
2138
  this.sigkillTimer = null;
@@ -1777,6 +2154,7 @@ var SessionDecodeSession = class {
1777
2154
  if (!waiter) return;
1778
2155
  this.pendingNativeCrops.delete(requestId);
1779
2156
  waiter.resolve(attempt);
2157
+ this.settleDrainIfDone();
1780
2158
  }
1781
2159
  settlePendingPull(outcome) {
1782
2160
  const waiter = this.pendingPull;
@@ -1872,6 +2250,7 @@ var SessionDecodeSession = class {
1872
2250
  const waiter = queue.shift();
1873
2251
  if (queue.length === 0) this.pendingToBuffers.delete(frameId);
1874
2252
  waiter?.resolve(bytes);
2253
+ this.settleDrainIfDone();
1875
2254
  }
1876
2255
  /**
1877
2256
  * `error` replies MAY carry a `frameId` (a `toBuffer`-scoped miss, e.g. a
@@ -1891,6 +2270,7 @@ var SessionDecodeSession = class {
1891
2270
  if (queue && queue.length > 0) {
1892
2271
  for (const waiter of queue) waiter.reject(err);
1893
2272
  this.pendingToBuffers.delete(frameId);
2273
+ this.settleDrainIfDone();
1894
2274
  return;
1895
2275
  }
1896
2276
  this.logger.debug("session-decode-coordinator: dropping toBuffer error for unknown frameId", { meta: { frameId } });
@@ -1905,6 +2285,7 @@ var SessionDecodeSession = class {
1905
2285
  }
1906
2286
  for (const waiters of this.pendingToBuffers.values()) for (const waiter of waiters) waiter.reject(err);
1907
2287
  this.pendingToBuffers.clear();
2288
+ this.settleDrainIfDone();
1908
2289
  }
1909
2290
  queueToBufferWaiter(frameId, waiter) {
1910
2291
  const queue = this.pendingToBuffers.get(frameId);
@@ -1942,6 +2323,8 @@ var SessionDecodeSession = class {
1942
2323
  /** Build the {@link FrameImage} adapter for one `frame` reply. */
1943
2324
  function buildFrameImage(session, reply) {
1944
2325
  let closed = false;
2326
+ let pendingConsumer = true;
2327
+ session.noteImageOpened();
1945
2328
  return {
1946
2329
  width: reply.width,
1947
2330
  height: reply.height,
@@ -1955,8 +2338,15 @@ function buildFrameImage(session, reply) {
1955
2338
  cutTiles: (bboxes) => session.cutTiles(reply.frameId, bboxes),
1956
2339
  releaseNativeLease: () => session.releaseLease(reply.frameId),
1957
2340
  markInferred: () => session.markInferred(reply.frameId),
2341
+ setPendingConsumer: (pending) => {
2342
+ if (closed || pending === pendingConsumer) return;
2343
+ pendingConsumer = pending;
2344
+ session.noteImageHolder(pending);
2345
+ },
1958
2346
  close: () => {
2347
+ if (closed) return;
1959
2348
  closed = true;
2349
+ session.noteImageClosed(pendingConsumer);
1960
2350
  }
1961
2351
  };
1962
2352
  }
@@ -1965,15 +2355,23 @@ function buildFrameImage(session, reply) {
1965
2355
  * `generateVideoFrames()` call, drives it with `pull`/`toBuffer` requests
1966
2356
  * one at a time (no pre-fetch — back-pressure is the async generator's
1967
2357
  * natural suspend-on-yield), and guarantees the child is torn down
1968
- * (`stop` + `kill()` + SIGKILL fallback) in every exit path via `finally`.
2358
+ * (bounded drain, then `stop` + `kill()` + SIGKILL fallback) in every exit
2359
+ * path via `finally`.
1969
2360
  */
1970
2361
  function createSessionDecodeCoordinator(deps) {
1971
2362
  const firstFrameTimeoutMs = deps.firstFrameTimeoutMs ?? DEFAULT_FIRST_FRAME_TIMEOUT_MS;
1972
2363
  const frameTimeoutMs = deps.frameTimeoutMs ?? DEFAULT_FRAME_TIMEOUT_MS;
1973
2364
  const firstFrameMaxWaitMs = Math.max(deps.firstFrameMaxWaitMs ?? DEFAULT_FIRST_FRAME_MAX_WAIT_MS, firstFrameTimeoutMs);
1974
2365
  return { async *generateVideoFrames(source, opts) {
1975
- const session = new SessionDecodeSession(deps.fork(source), deps.logger, source.deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs, deps.onDialTiming, deps.onPreRollReport);
2366
+ const session = new SessionDecodeSession(deps.fork(source), deps.logger, source.deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs, {
2367
+ ...deps.onDialTiming ? { onDialTiming: deps.onDialTiming } : {},
2368
+ ...deps.onPreRollReport ? { onPreRollReport: deps.onPreRollReport } : {},
2369
+ ...deps.onPreRollSettled ? { onPreRollSettled: deps.onPreRollSettled } : {},
2370
+ ...deps.onWorkerExit ? { onWorkerExit: deps.onWorkerExit } : {}
2371
+ });
1976
2372
  session.start(source, opts, deps.lease());
2373
+ deps.onSessionTeardown?.((mode) => session.teardown(mode));
2374
+ let failed = false;
1977
2375
  try {
1978
2376
  while (true) {
1979
2377
  const outcome = await session.pullNext();
@@ -1986,8 +2384,11 @@ function createSessionDecodeCoordinator(deps) {
1986
2384
  ...reply.preRoll === true ? { preRoll: true } : {}
1987
2385
  };
1988
2386
  }
2387
+ } catch (err) {
2388
+ failed = true;
2389
+ throw err;
1989
2390
  } finally {
1990
- session.teardown();
2391
+ session.teardown(failed ? "immediate" : deps.teardownMode?.() ?? "drain");
1991
2392
  }
1992
2393
  } };
1993
2394
  }
@@ -2031,17 +2432,39 @@ async function toDecodedFrame(frame, format, viewSpec) {
2031
2432
  * Start pulling frames from a per-session decode worker and feed each decoded
2032
2433
  * frame to `onDecodedFrame`. Returns an idempotent teardown closure that stops
2033
2434
  * the async generator (`it.return()`) — which triggers the coordinator's
2034
- * `finally` → `stop` + `kill()` + 1s SIGKILL fallback → worker exit → the OS
2035
- * reclaims the VAAPI/GPU decode pool. Fire-and-forget: the returned closure is
2036
- * synchronous; the frame loop and generator teardown run on their own tasks.
2435
+ * `finally` → a bounded DRAIN of the frames already handed out (D652), then
2436
+ * `stop` + SIGTERM + 1s SIGKILL fallback → worker exit → the OS reclaims the
2437
+ * VAAPI/GPU decode pool. `teardown('immediate')` SIGKILLs at once (shutdown),
2438
+ * and said a second time during a drain it escalates that drain to the kill.
2439
+ * Fire-and-forget: the returned closure is synchronous; the frame loop and
2440
+ * generator teardown run on their own tasks.
2037
2441
  */
2038
2442
  function startSessionDecodePump(deps) {
2039
2443
  const format = deps.opts.format ?? "rgb";
2040
2444
  let dialTiming = null;
2445
+ let teardownMode = "drain";
2446
+ let escalateSession = null;
2447
+ let forked = false;
2448
+ let workerGone = false;
2449
+ const noteWorkerGone = () => {
2450
+ if (workerGone) return;
2451
+ workerGone = true;
2452
+ deps.onWorkerGone?.();
2453
+ };
2041
2454
  const iterator = createSessionDecodeCoordinator({
2042
- fork: deps.fork,
2455
+ fork: (source) => {
2456
+ const child = deps.fork(source);
2457
+ forked = true;
2458
+ return child;
2459
+ },
2043
2460
  lease: deps.lease,
2044
2461
  logger: deps.logger,
2462
+ teardownMode: () => teardownMode,
2463
+ onSessionTeardown: (teardown) => {
2464
+ escalateSession = teardown;
2465
+ },
2466
+ onWorkerExit: noteWorkerGone,
2467
+ ...deps.onPreRollSettled ? { onPreRollSettled: deps.onPreRollSettled } : {},
2045
2468
  onDialTiming: (timing) => {
2046
2469
  dialTiming = timing;
2047
2470
  },
@@ -2147,13 +2570,21 @@ function startSessionDecodePump(deps) {
2147
2570
  });
2148
2571
  } finally {
2149
2572
  releaseSpawnSlotOnce();
2573
+ if (!forked) noteWorkerGone();
2150
2574
  if (!stopped) deps.onEnded?.();
2151
2575
  }
2152
2576
  };
2153
2577
  pump();
2154
- return () => {
2155
- if (stopped) return;
2578
+ return (mode = "drain") => {
2579
+ if (stopped) {
2580
+ if (mode === "immediate" && teardownMode !== "immediate") {
2581
+ teardownMode = "immediate";
2582
+ escalateSession?.("immediate");
2583
+ }
2584
+ return;
2585
+ }
2156
2586
  stopped = true;
2587
+ teardownMode = mode;
2157
2588
  releaseSpawnSlotOnce();
2158
2589
  iterator.return().catch(() => {});
2159
2590
  };
@@ -3584,115 +4015,14 @@ async function runDetailSubtree(deps, input) {
3584
4015
  } : void 0;
3585
4016
  const details = [];
3586
4017
  for (const step of steps) {
3587
- const deviceKey = deps.resolveStepDevice?.(input.deviceId, step);
3588
- const result = await deps.runPipeline([step], crop.jpeg, input.deviceId, deviceKey, nativeCropRef);
4018
+ const placement = deps.placeStep?.(input.deviceId, step);
4019
+ const result = await deps.runPipeline([placement?.step ?? step], crop.jpeg, input.deviceId, placement?.deviceKey, nativeCropRef);
3589
4020
  if (!result) continue;
3590
4021
  for (const detection of result.detections) details.push(toDetailResult(detection, step.addonId, crop));
3591
4022
  }
3592
4023
  return { details };
3593
4024
  }
3594
4025
  //#endregion
3595
- //#region src/pipeline-runner/device-jump.ts
3596
- /**
3597
- * Step-tree device jump (same-node, phase 1) — the PURE resolution core.
3598
- *
3599
- * Given the camera's elected ("effective") inference device and the node's
3600
- * enabled-device roster, decide which device ONE enrichment step should run on:
3601
- *
3602
- * 1. A manual `jumpDeviceKey` override wins when it names a candidate that can
3603
- * run the step (bypasses the auto choice; operator intent).
3604
- * 2. Otherwise, if the effective device's format can run the step's model →
3605
- * NO jump (the step inherits the effective device — the common case).
3606
- * 3. Otherwise AUTO-jump: among the node's OTHER enabled devices whose format
3607
- * can run the step (and that are under their `maxSessions` cap), pick the
3608
- * weighted-least-loaded one — mirroring the balancer's per-device core.
3609
- * 4. If nothing on the node can run it → `no-candidate` (the caller emits the
3610
- * loud once-per-(camera,step) warn that replaces today's silent degrade).
3611
- *
3612
- * Framework-free + side-effect-free so the whole decision table is unit-testable
3613
- * without a live runner/catalog. The `canRun(format)` predicate is injected by
3614
- * the runner (which owns the model catalog); this module never imports it.
3615
- */
3616
- /** The `ModelFormat` of a `<backend>:<device>` key, or `undefined` for an unknown backend. */
3617
- function formatOf(deviceKey) {
3618
- const colon = deviceKey.indexOf(":");
3619
- return require_dist.DEVICE_BACKEND_TO_FORMAT[colon >= 0 ? deviceKey.slice(0, colon) : deviceKey];
3620
- }
3621
- /** True when the step's model has a build for `deviceKey`'s format. */
3622
- function canRunOn(deviceKey, canRun) {
3623
- const format = formatOf(deviceKey);
3624
- return format !== void 0 && canRun(format);
3625
- }
3626
- /** Effective weighted load of a device (session count ÷ weight); lower = preferred. */
3627
- function weightedLoad(d) {
3628
- return d.attachedCameras / (d.weight > 0 ? d.weight : 1);
3629
- }
3630
- /**
3631
- * Weighted-least-loaded pick with a deterministic `deviceKey` tie-break —
3632
- * mirrors the node/device balancer's decision core (least `load/weight`, ties
3633
- * broken stably). `devices` is assumed non-empty.
3634
- */
3635
- function pickWeightedLeastLoaded(devices) {
3636
- return devices.reduce((best, d) => {
3637
- const dScore = weightedLoad(d);
3638
- const bScore = weightedLoad(best);
3639
- if (dScore < bScore) return d;
3640
- if (dScore > bScore) return best;
3641
- return d.deviceKey < best.deviceKey ? d : best;
3642
- });
3643
- }
3644
- /**
3645
- * Build the `canRun` predicate for ONE enrichment dispatch. The step and its
3646
- * enabled descendants ride the SAME `runPipeline` call — one pool, one format
3647
- * (detail-subtree contract: "its inherited children ride the same call → same
3648
- * device") — so a format can run the dispatch only when EVERY catalog-known
3649
- * step in the subtree ships a build for it. Gating only the subtree TOP let a
3650
- * `face-detection` with a tflite build stay on the Coral while its
3651
- * `face-embedding` child (no tflite build anywhere) doomed the call.
3652
- * Unknown/custom addonIds (`getStepDef` → null) have no catalog to gate on
3653
- * and never block. `getStepDef` is injected — this module stays catalog-free.
3654
- */
3655
- function buildSubtreeCanRun(step, getStepDef) {
3656
- const defs = [];
3657
- const walk = (s) => {
3658
- if (s.enabled === false) return;
3659
- const def = getStepDef(s.addonId);
3660
- if (def !== null) defs.push(def);
3661
- for (const child of s.children ?? []) walk(child);
3662
- };
3663
- walk(step);
3664
- return (format) => defs.every((def) => def.models.some((m) => m.formats[format]));
3665
- }
3666
- /**
3667
- * Resolve the device one enrichment step runs on. See the module header for the
3668
- * decision table. Pure — never throws, no I/O.
3669
- */
3670
- function resolveStepDevice(input) {
3671
- const { effectiveDeviceKey, manualJumpDeviceKey, canRun } = input;
3672
- if (manualJumpDeviceKey !== void 0 && manualJumpDeviceKey !== effectiveDeviceKey) {
3673
- const target = input.devices.find((d) => d.deviceKey === manualJumpDeviceKey);
3674
- if (target && canRunOn(target.deviceKey, canRun)) return {
3675
- kind: "jumped",
3676
- deviceKey: target.deviceKey,
3677
- reason: "manual"
3678
- };
3679
- }
3680
- if (canRunOn(effectiveDeviceKey, canRun)) return {
3681
- kind: "no-jump",
3682
- deviceKey: effectiveDeviceKey
3683
- };
3684
- const runnable = input.devices.filter((d) => d.deviceKey !== effectiveDeviceKey && (d.maxSessions === null || d.attachedCameras < d.maxSessions) && canRunOn(d.deviceKey, canRun));
3685
- if (runnable.length === 0) return {
3686
- kind: "no-candidate",
3687
- deviceKey: effectiveDeviceKey
3688
- };
3689
- return {
3690
- kind: "jumped",
3691
- deviceKey: pickWeightedLeastLoaded(runnable).deviceKey,
3692
- reason: "auto"
3693
- };
3694
- }
3695
- //#endregion
3696
4026
  //#region src/pipeline-runner/device-load.ts
3697
4027
  /** The backend prefix of a `<backend>:<device>` key (bare `cpu` → `cpu`). */
3698
4028
  function backendOf(deviceKey) {
@@ -3875,6 +4205,74 @@ function release(cutter) {
3875
4205
  } catch {}
3876
4206
  }
3877
4207
  //#endregion
4208
+ //#region src/pipeline-runner/occupancy-burst-ledger.ts
4209
+ var OccupancyBurstLedger = class {
4210
+ options;
4211
+ frames = 0;
4212
+ inferred = 0;
4213
+ superseded = 0;
4214
+ settled = 0;
4215
+ closed = false;
4216
+ completed = false;
4217
+ boundTimer = null;
4218
+ constructor(options) {
4219
+ this.options = options;
4220
+ }
4221
+ /**
4222
+ * Count one collected frame and hand back its settler. A frame the runner
4223
+ * REFUSED is settled `'lost'` by the caller at once — it is a frame the
4224
+ * verdict did not see, which is what `lost` counts.
4225
+ */
4226
+ admit() {
4227
+ this.frames += 1;
4228
+ let done = false;
4229
+ return (outcome) => {
4230
+ if (done || this.completed) return;
4231
+ done = true;
4232
+ this.settled += 1;
4233
+ if (outcome === "inferred") this.inferred += 1;
4234
+ else if (outcome === "superseded") this.superseded += 1;
4235
+ this.completeIfSettled();
4236
+ };
4237
+ }
4238
+ /**
4239
+ * No more frames will be collected (count reached, safety timeout, or the
4240
+ * decode ended). Completes now when everything admitted has settled, else
4241
+ * arms the bound. Idempotent.
4242
+ */
4243
+ close() {
4244
+ if (this.closed || this.completed) return;
4245
+ this.closed = true;
4246
+ if (this.completeIfSettled()) return;
4247
+ this.boundTimer = setTimeout(() => {
4248
+ this.boundTimer = null;
4249
+ this.complete(true);
4250
+ }, this.options.settleBoundMs);
4251
+ this.boundTimer.unref?.();
4252
+ }
4253
+ completeIfSettled() {
4254
+ if (!this.closed || this.settled < this.frames) return false;
4255
+ this.complete(false);
4256
+ return true;
4257
+ }
4258
+ complete(timedOut) {
4259
+ if (this.completed) return;
4260
+ this.completed = true;
4261
+ if (this.boundTimer !== null) {
4262
+ clearTimeout(this.boundTimer);
4263
+ this.boundTimer = null;
4264
+ }
4265
+ this.options.onComplete({
4266
+ frames: this.frames,
4267
+ inferred: this.inferred,
4268
+ superseded: this.superseded,
4269
+ lost: this.frames - this.inferred - this.superseded,
4270
+ unsettled: this.frames - this.settled,
4271
+ timedOut
4272
+ });
4273
+ }
4274
+ };
4275
+ //#endregion
3878
4276
  //#region src/pipeline-runner/frame-lazy-metrics.ts
3879
4277
  function emptyFrameLazyCounters() {
3880
4278
  return {
@@ -4908,7 +5306,7 @@ var ON_DEMAND_JPEG_VIEW = {
4908
5306
  format: "jpeg"
4909
5307
  };
4910
5308
  function createOnDemandJpegFallback(registry, ref) {
4911
- const lease = registry.acquire(ref);
5309
+ const lease = registry.acquire(ref, "retention");
4912
5310
  let cached;
4913
5311
  let released = false;
4914
5312
  const releaseLease = (reason) => {
@@ -4948,6 +5346,11 @@ function createOnDemandJpegFallback(registry, ref) {
4948
5346
  },
4949
5347
  release() {
4950
5348
  releaseLease("eviction");
5349
+ },
5350
+ holdReader() {
5351
+ if (released || cached !== void 0) return null;
5352
+ const reader = registry.acquire(ref);
5353
+ return reader ? () => reader.release("success") : null;
4951
5354
  }
4952
5355
  };
4953
5356
  }
@@ -5287,41 +5690,6 @@ var FrameGovernor = class {
5287
5690
  };
5288
5691
  //#endregion
5289
5692
  //#region src/pipeline-runner/frame-queue.ts
5290
- /**
5291
- * Latest-frame-only buffer. Keeps only the most recent item, dropping all
5292
- * older items immediately. This ensures inference always runs on the
5293
- * freshest available frame, never accumulating a backlog regardless of
5294
- * inference latency.
5295
- *
5296
- * ## Buffer ownership (Phase 5 / D9 Task 7b)
5297
- *
5298
- * Frames arrive from the shm frame plane via a `FrameRingReader`, whose
5299
- * `FrameRead.pixels` is **borrowed** — a view onto the reader's reusable
5300
- * scratch buffer, valid only until that reader's next read (see
5301
- * `@camstack/shm-ring`'s `FrameRead` contract). Both queue consumers retain
5302
- * the frame PAST the next read:
5303
- *
5304
- * - the **motion** queue is drained by the scheduler's `tick`, not by the
5305
- * poller that read the frame — and the poller drains a burst of handles
5306
- * per poll, so even handle #2's read would clobber handle #1's pixels;
5307
- * - the **detection** queue is drained asynchronously after a semaphore wait
5308
- * and a 10s–100s ms inference.
5309
- *
5310
- * In both cases holding the borrowed buffer is silent corruption. So
5311
- * `enqueue` runs the queue's injected `clone` function which copies the
5312
- * pixels into queue-owned storage at the retention boundary. The queue is
5313
- * latest-only (capacity 1), so this is exactly one allocation per *queued*
5314
- * frame — bounded by the scheduler/inference cadence, NOT per *read* frame.
5315
- * The high-frequency per-read allocation that failed the D9 perf gate is
5316
- * eliminated; this bounded copy-on-retain is the correct cost.
5317
- *
5318
- * The generic parameter `T` lets the detection path queue a
5319
- * `{ frame, handle }` entry (Task 7 — propagating the `FrameHandle`
5320
- * through to the inference-result event), while the motion path keeps
5321
- * queueing bare `DecodedFrame`s. Each instance provides its own
5322
- * `clone` so the inner pixel buffer is detached at the same retention
5323
- * boundary regardless of the wrapping entry shape.
5324
- */
5325
5693
  var FrameQueue = class {
5326
5694
  maxSize;
5327
5695
  latest = null;
@@ -5342,7 +5710,7 @@ var FrameQueue = class {
5342
5710
  enqueue(item) {
5343
5711
  if (this.latest !== null) {
5344
5712
  this._droppedFrames++;
5345
- this.dispose?.(this.latest);
5713
+ this.dispose?.(this.latest, "superseded");
5346
5714
  }
5347
5715
  this.latest = this.clone(item);
5348
5716
  }
@@ -5357,8 +5725,12 @@ var FrameQueue = class {
5357
5725
  get droppedFrames() {
5358
5726
  return this._droppedFrames;
5359
5727
  }
5728
+ /** The queued item, without taking it. */
5729
+ peek() {
5730
+ return this.latest ?? void 0;
5731
+ }
5360
5732
  clear() {
5361
- if (this.latest !== null) this.dispose?.(this.latest);
5733
+ if (this.latest !== null) this.dispose?.(this.latest, "cleared");
5362
5734
  this.latest = null;
5363
5735
  }
5364
5736
  };
@@ -5758,7 +6130,8 @@ function ownDetectionEntry(entry) {
5758
6130
  return {
5759
6131
  frame: ownFrame(entry.frame),
5760
6132
  handle: entry.handle,
5761
- occasion: entry.occasion
6133
+ occasion: entry.occasion,
6134
+ ...entry.onSettled ? { onSettled: entry.onSettled } : {}
5762
6135
  };
5763
6136
  }
5764
6137
  /**
@@ -5900,9 +6273,10 @@ var PipelineRunner = class {
5900
6273
  }
5901
6274
  registerCamera(deviceId, registration) {
5902
6275
  const motionQueue = new FrameQueue(this.config.maxQueueDepth, ownMotionEntry);
5903
- const detectionQueue = new FrameQueue(this.config.maxQueueDepth, ownDetectionEntry, (entry) => {
6276
+ const detectionQueue = new FrameQueue(this.config.maxQueueDepth, ownDetectionEntry, (entry, reason) => {
5904
6277
  if (entry.handle) this.config.releaseRetention?.(entry.handle, "drop");
5905
6278
  if (entry.frame.frameRef) this.config.releaseFrameRef?.(entry.frame.frameRef, "drop");
6279
+ entry.onSettled?.(reason === "superseded" ? "superseded" : "lost");
5906
6280
  });
5907
6281
  const initialPhase = registration.detectionMode === "disabled" ? "idle" : registration.detectionMode === "always-on" ? "active" : "watching";
5908
6282
  const preRollQueue = new PreRollLane({
@@ -5911,6 +6285,7 @@ var PipelineRunner = class {
5911
6285
  dispose: (entry) => {
5912
6286
  if (entry.handle) this.config.releaseRetention?.(entry.handle, "drop");
5913
6287
  if (entry.frame.frameRef) this.config.releaseFrameRef?.(entry.frame.frameRef, "drop");
6288
+ entry.onSettled?.("lost");
5914
6289
  }
5915
6290
  });
5916
6291
  this.logger?.info("camera registered: root detectors", {
@@ -5971,8 +6346,7 @@ var PipelineRunner = class {
5971
6346
  }
5972
6347
  if (state.phase === "active") this.detectionStreamHandler?.(deviceId, "unsubscribe");
5973
6348
  state.motionQueue.clear();
5974
- state.detectionQueue.clear();
5975
- state.preRollQueue.clear();
6349
+ this.discardQueuedDetectionFrames(deviceId, state, "detach");
5976
6350
  this.timingSampler.clearDroppedFramesSource(deviceId);
5977
6351
  this.cameras.delete(deviceId);
5978
6352
  this.lastAdmittedAt.delete(deviceId);
@@ -6096,9 +6470,14 @@ var PipelineRunner = class {
6096
6470
  preRollQueueDepth(deviceId) {
6097
6471
  return this.cameras.get(deviceId)?.preRollQueue.size ?? 0;
6098
6472
  }
6099
- /** Frames this camera lane REFUSED because it was full. */
6473
+ /**
6474
+ * Frames this camera lane REFUSED because it was full — or `null` when the
6475
+ * camera is no longer registered and the count cannot be read (a report
6476
+ * that lands after a detach). A failed read is `null`, never `0` (D393):
6477
+ * zero would say "nothing refused" about a lane nobody looked at.
6478
+ */
6100
6479
  preRollRefusedCount(deviceId) {
6101
- return this.cameras.get(deviceId)?.preRollQueue.refused ?? 0;
6480
+ return this.cameras.get(deviceId)?.preRollQueue.refused ?? null;
6102
6481
  }
6103
6482
  /**
6104
6483
  * Whether the node has a permit to spare for history right now — i.e. one
@@ -6127,7 +6506,7 @@ var PipelineRunner = class {
6127
6506
  * decode worker). `runInference` already falls back to the pixel path
6128
6507
  * when the handle is absent.
6129
6508
  */
6130
- enqueueOccupancyFrame(deviceId, frame, handle) {
6509
+ enqueueOccupancyFrame(deviceId, frame, handle, onSettled) {
6131
6510
  const state = this.cameras.get(deviceId);
6132
6511
  if (!state) return false;
6133
6512
  if (state.phase !== "watching") return false;
@@ -6135,7 +6514,8 @@ var PipelineRunner = class {
6135
6514
  state.detectionQueue.enqueue({
6136
6515
  frame,
6137
6516
  handle,
6138
- occasion: "burst"
6517
+ occasion: "burst",
6518
+ ...onSettled ? { onSettled } : {}
6139
6519
  });
6140
6520
  return true;
6141
6521
  }
@@ -6343,7 +6723,7 @@ var PipelineRunner = class {
6343
6723
  }
6344
6724
  transitionToWatching(deviceId, state, cooldownMs) {
6345
6725
  state.phase = "watching";
6346
- state.detectionQueue.clear();
6726
+ this.discardQueuedDetectionFrames(deviceId, state, "session-close");
6347
6727
  state.occupancyArmed = state.lastResultDetections > 0;
6348
6728
  state.emptyBurstStreak = 0;
6349
6729
  this.logger?.info("motion gate closed — phase=watching", {
@@ -6364,6 +6744,41 @@ var PipelineRunner = class {
6364
6744
  state.lastArmedSource = null;
6365
6745
  state.lastArmedRegions = void 0;
6366
6746
  }
6747
+ /**
6748
+ * Throw away the frames still QUEUED for a camera whose session is ending.
6749
+ *
6750
+ * A branch that drops work says so (D391, D652). These are frames the runner
6751
+ * admitted and will now never infer — the live queue's latest frame and, on a
6752
+ * detach, whatever history is left in the pre-roll lane. Frames already in
6753
+ * flight are not touched here: they finish, and the decode worker they live
6754
+ * in drains before it is killed (`SESSION_DECODE_DRAIN_BOUND_MS`).
6755
+ */
6756
+ discardQueuedDetectionFrames(deviceId, state, reason) {
6757
+ const queued = state.detectionQueue.size;
6758
+ const awaited = state.detectionQueue.peek()?.onSettled !== void 0 ? 1 : 0;
6759
+ const preRoll = reason === "detach" ? state.preRollQueue.size : 0;
6760
+ state.detectionQueue.clear();
6761
+ if (reason === "detach") state.preRollQueue.clear();
6762
+ if (queued + preRoll === 0) return;
6763
+ const meta = {
6764
+ reason,
6765
+ queued,
6766
+ awaited,
6767
+ preRoll,
6768
+ inflight: state.inflight
6769
+ };
6770
+ if (awaited + preRoll > 0) {
6771
+ this.logger?.info("detection frames discarded with the session", {
6772
+ tags: { deviceId },
6773
+ meta
6774
+ });
6775
+ return;
6776
+ }
6777
+ this.logger?.debug("detection frames discarded with the session", {
6778
+ tags: { deviceId },
6779
+ meta
6780
+ });
6781
+ }
6367
6782
  tick() {
6368
6783
  this.drainMotionQueues();
6369
6784
  if (this.semaphore.available <= 0) {
@@ -6451,6 +6866,7 @@ var PipelineRunner = class {
6451
6866
  } catch {}
6452
6867
  if (handle) this.config.releaseRetention?.(handle, frameReleaseReason);
6453
6868
  if (frame.frameRef) this.config.releaseFrameRef?.(frame.frameRef, frameReleaseReason);
6869
+ entry.onSettled?.(frameReleaseReason === "success" ? "inferred" : "lost");
6454
6870
  }
6455
6871
  }
6456
6872
  /**
@@ -6532,6 +6948,7 @@ var PipelineRunner = class {
6532
6948
  } catch {}
6533
6949
  if (handle) this.config.releaseRetention?.(handle, frameReleaseReason);
6534
6950
  if (frame.frameRef) this.config.releaseFrameRef?.(frame.frameRef, frameReleaseReason);
6951
+ entry.onSettled?.(frameReleaseReason === "success" ? "inferred" : "lost");
6535
6952
  }
6536
6953
  }
6537
6954
  /**
@@ -6779,11 +7196,14 @@ async function runStatelessStep(deps, input) {
6779
7196
  return refuse(deps, input, "execution-failed", errorMessage(err));
6780
7197
  }
6781
7198
  if (result === null) return refuse(deps, input, "execution-failed", "the executor returned no frame result for the crop");
7199
+ const ranModelId = result.rootModelId;
7200
+ if (ranModelId === void 0 || ranModelId === "") return refuse(deps, input, "execution-failed", `the executor result names no model — refusing rather than assuming "${input.modelId}" ran`);
7201
+ 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`);
6782
7202
  const details = result.detections.map((detection) => toDetailResult(detection, input.stepId, crop));
6783
7203
  return {
6784
7204
  kind: "ran",
6785
7205
  nodeId: deps.nodeId,
6786
- modelId: input.modelId,
7206
+ modelId: ranModelId,
6787
7207
  details
6788
7208
  };
6789
7209
  }
@@ -7075,6 +7495,19 @@ function writeRetainedDetectionFrame(store, deviceId, frame, retention) {
7075
7495
  */
7076
7496
  var OCCUPANCY_BURST_SAFETY_TIMEOUT_MS = 8e3;
7077
7497
  /**
7498
+ * How long a burst whose collection has ended waits for the frames it admitted
7499
+ * to be INFERRED before it reports anyway (D652).
7500
+ *
7501
+ * A burst's verdict is read off its frames' results, so it completes when they
7502
+ * settle, not when they are enqueued. This bound is only for a frame that never
7503
+ * settles. Measured on the live hub 2026-09-26, the worst enqueue→emit of a
7504
+ * 10 s window was ≤1.5 s in 98.0% of 2 254 windows and ≤5 s in 98.9%; what lies
7505
+ * beyond is the inference-pool GPU hang, which the runner's own 20 s inference
7506
+ * timeout already ends. A timed-out burst counts its unsettled frames as lost
7507
+ * and says so on its `burst complete` line.
7508
+ */
7509
+ var OCCUPANCY_BURST_SETTLE_BOUND_MS = 5e3;
7510
+ /**
7078
7511
  * Freshness window for `lastMotionFrameAt` (Task 10 hardening). A non-null
7079
7512
  * `attachment.motionUnsubscribe` only proves a subscription handle exists —
7080
7513
  * NOT that frames are actually flowing (guarded-session-decode can return
@@ -7179,6 +7612,15 @@ function tryGetStepDefinition(stepId) {
7179
7612
  }
7180
7613
  }
7181
7614
  /**
7615
+ * A detail-plane step: its catalog definition consumes a parent class
7616
+ * (`inputClasses` non-null). Unknown/custom addonIds are root-plane — the
7617
+ * same reading as the executor's frame-plane skip and `deriveDetailSteps`.
7618
+ */
7619
+ function isDetailPlaneStepId(stepId) {
7620
+ const def = tryGetStepDefinition(stepId);
7621
+ return def !== null && def.inputClasses !== null;
7622
+ }
7623
+ /**
7182
7624
  * Precomputes the `detailSteps` announce (two-plane design) for a camera's
7183
7625
  * configured step tree: every ENABLED step whose catalog definition is a
7184
7626
  * detail/child step (`inputClasses !== null` — a root step like
@@ -7884,8 +8326,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
7884
8326
  this.runner = null;
7885
8327
  }
7886
8328
  for (const attachment of this.attached.values()) {
7887
- attachment.motionUnsubscribe?.();
7888
- attachment.detectionUnsubscribe?.();
8329
+ attachment.motionUnsubscribe?.("immediate");
8330
+ attachment.detectionUnsubscribe?.("immediate");
7889
8331
  }
7890
8332
  for (const deviceId of [...this.parkedDetail.keys()]) this.dropParkedDetail(deviceId);
7891
8333
  this.attached.clear();
@@ -8011,14 +8453,16 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
8011
8453
  height: cached.height,
8012
8454
  format: cached.format
8013
8455
  }, pin)).frameId;
8014
- await api.pipelineExecutor.runPipeline.mutate({ ...buildBenchRunPipelineInput({
8456
+ const benchModelId = (await api.pipelineExecutor.runPipeline.mutate({ ...buildBenchRunPipelineInput({
8015
8457
  steps: stepsToRun,
8016
8458
  frame: sharedFrame
8017
- }) }, pin);
8459
+ }) }, pin)).rootModelId;
8460
+ if (benchModelId === void 0 || benchModelId === "") throw new Error(`synthetic bench: the warmup of "${rootStep.addonId}" named no model — refusing to time an unnamed variant`);
8018
8461
  const warmupCount = input.warmup ?? 1;
8019
8462
  for (let w = 0; w < warmupCount; w++) await api.pipelineExecutor.inferCached.mutate({
8020
8463
  stepId: rootStep.addonId,
8021
- frameId: poolFrameId
8464
+ frameId: poolFrameId,
8465
+ modelId: benchModelId
8022
8466
  }, pin);
8023
8467
  const wallTimings = [];
8024
8468
  const inferTimings = [];
@@ -8036,7 +8480,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
8036
8480
  const t0 = performance.now();
8037
8481
  const result = await api.pipelineExecutor.inferCached.mutate({
8038
8482
  stepId: rootStep.addonId,
8039
- frameId: poolFrameId
8483
+ frameId: poolFrameId,
8484
+ modelId: benchModelId
8040
8485
  }, pin);
8041
8486
  const wallMs = performance.now() - t0;
8042
8487
  const r = result;
@@ -8711,6 +9156,10 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
8711
9156
  * resolve legitimately want the lower-RES honest frame).
8712
9157
  */
8713
9158
  async getNativeCrop(input) {
9159
+ return withRetainedReader(this.retainedFrames, input.handle, () => this.resolveNativeCropBothRungs(input));
9160
+ }
9161
+ /** {@link getNativeCrop}'s two rungs, run under its reader hold. */
9162
+ async resolveNativeCropBothRungs(input) {
8714
9163
  const deviceId = this.nativeCropRegistry.deviceIdFor(input.handle) ?? 0;
8715
9164
  const isFullFrame = input.bbox.x <= .001 && input.bbox.y <= .001 && input.bbox.w >= .999 && input.bbox.h >= .999;
8716
9165
  this.frameLazy.noteOnDemandRequest({
@@ -9226,11 +9675,12 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
9226
9675
  */
9227
9676
  async runDetailSubtree(input) {
9228
9677
  const convention = await this.cropConvention.refresh(this.ctxIfReady?.api);
9678
+ await this.customModels.refresh(this.ctxIfReady?.api);
9229
9679
  const outcome = await runDetailSubtree({
9230
9680
  resolveChildSteps: (deviceId, parentClassName, filter) => this.resolveDetailChildSteps(deviceId, parentClassName, filter),
9231
9681
  getNativeCrop: (deviceId, handle, paddedBbox) => this.getNativeCropForDetail(deviceId, handle, paddedBbox),
9232
9682
  runPipeline: (steps, imageJpeg, deviceId, deviceKey, nativeCropRef) => this.runDetailPipeline(steps, imageJpeg, deviceId, deviceKey, nativeCropRef),
9233
- resolveStepDevice: (deviceId, step) => this.resolveDetailStepDevice(deviceId, step),
9683
+ placeStep: (deviceId, step) => this.placeDetailStepForCamera(deviceId, step),
9234
9684
  resolveCropConvention: () => convention,
9235
9685
  logger: this.detailSubtreeLogger()
9236
9686
  }, input);
@@ -9354,6 +9804,13 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
9354
9804
  * global settings, NOT from the attach config. See `crop-convention-source.ts`
9355
9805
  * for why eleven attach builders make the attach config the wrong carrier.
9356
9806
  */
9807
+ /**
9808
+ * The custom-model registry, merged into the catalog the device jump judges
9809
+ * (D657): a custom model with a tflite build, pinned on a Coral, runs there.
9810
+ * Refreshed on the async boundaries (`runInference`, `runDetailSubtree`),
9811
+ * read synchronously by the placement.
9812
+ */
9813
+ customModels = new require_default_detection_model.CustomModelCatalog({ warn: (message, extras) => this.ctxIfReady?.logger.warn(message, extras) });
9357
9814
  cropConvention = new CropConventionSource({ logger: {
9358
9815
  info: (message, extras) => this.ctxIfReady?.logger.info(message, extras),
9359
9816
  warn: (message, extras) => this.ctxIfReady?.logger.warn(message, extras)
@@ -9444,39 +9901,94 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
9444
9901
  return { warn: (message, extras) => this.ctxIfReady?.logger.warn(message, extras) };
9445
9902
  }
9446
9903
  /**
9447
- * Step-tree device jump (phase 1) — pick which same-node inference device an
9448
- * enrichment `step` runs on. AUTO-jumps only when the camera's elected device
9449
- * cannot run the step's model (its format has no build); an operator
9450
- * `step.jumpDeviceKey` overrides. Returns the resolved `<backend>:<device>`
9451
- * key, or `undefined` (⇒ the runner's per-camera default pool) when there is
9452
- * nothing to route: no elected device, or no jump roster on the attach.
9904
+ * Step-tree device jump — place one enrichment subtree on a same-node
9905
+ * inference device ({@link placeDetailStep}). AUTO-jumps only when the
9906
+ * camera's elected device cannot run the subtree (its format has no build);
9907
+ * an operator `step.jumpDeviceKey` overrides. A jumped subtree runs with the
9908
+ * TARGET device's provisioning carried on the roster (D650), never the
9909
+ * elected device's. `deviceKey: undefined` ⇒ the runner's per-camera default
9910
+ * pool (no elected device).
9453
9911
  *
9454
- * When NO enabled device on the node can run the step, logs a LOUD warn once
9455
- * per (camera, step) — killing the historical SILENT per-child degrade (an
9456
- * `edgetpu` root would otherwise drop faces/plates/clip with no signal).
9912
+ * Logs the jump ONCE per (camera, step, target), and — when NO enabled device
9913
+ * on the node can run the subtree — a LOUD warn once per (camera, step).
9914
+ */
9915
+ placeDetailStepForCamera(deviceId, step) {
9916
+ const config = this.attached.get(deviceId)?.config;
9917
+ const placement = this.placementOf(config, step, this.perDeviceAttachedCounts());
9918
+ const decision = placement.decision;
9919
+ if (decision?.kind === "no-candidate") this.warnStepHasNoRunnableDevice(deviceId, step.addonId, decision.deviceKey);
9920
+ else if (decision?.kind === "jumped" && config?.deviceKey) this.logStepJumped(deviceId, config.deviceKey, decision, placement);
9921
+ return placement;
9922
+ }
9923
+ /**
9924
+ * Where one detail subtree of this camera runs — the ONE decision both the
9925
+ * detail plane (which dispatches it) and the frame plane (which decides what
9926
+ * the elected pool pre-warms, D657) read. Pure: nothing is logged here.
9927
+ */
9928
+ placementOf(config, step, attachedByKey) {
9929
+ return require_default_detection_model.placeDetailStep({
9930
+ step,
9931
+ effectiveDeviceKey: config?.deviceKey,
9932
+ roster: config?.inferenceDevices,
9933
+ attachedByKey,
9934
+ getStepDef: this.customModels.stepDefinition
9935
+ });
9936
+ }
9937
+ /**
9938
+ * The tree this camera's frame-plane dispatch carries (D657): a detail
9939
+ * subtree the placement sends to another device (or to none) is left out,
9940
+ * so its model is never loaded on the elected pool — it loads on its
9941
+ * target, with the target's provisioning, when the detail plane dispatches
9942
+ * it there. Said once per (camera, elected device, set left out).
9457
9943
  */
9458
- resolveDetailStepDevice(deviceId, step) {
9944
+ stepsForElectedPoolOf(deviceId, steps) {
9459
9945
  const config = this.attached.get(deviceId)?.config;
9460
- const effectiveDeviceKey = config?.deviceKey;
9461
- if (!effectiveDeviceKey) return void 0;
9462
- const roster = config?.inferenceDevices;
9463
- if (!roster || roster.length < 2) return effectiveDeviceKey;
9946
+ if (config?.deviceKey === void 0 || (config.inferenceDevices?.length ?? 0) < 2) {
9947
+ this.noteElectedPoolDelegation(deviceId, void 0, []);
9948
+ return steps;
9949
+ }
9464
9950
  const attachedByKey = this.perDeviceAttachedCounts();
9465
- const devices = roster.map((d) => ({
9466
- deviceKey: d.deviceKey,
9467
- weight: d.weight,
9468
- maxSessions: d.maxSessions,
9469
- attachedCameras: attachedByKey.get(d.deviceKey) ?? 0
9470
- }));
9471
- const canRun = buildSubtreeCanRun(step, tryGetStepDefinition);
9472
- const result = resolveStepDevice({
9473
- effectiveDeviceKey,
9474
- devices,
9475
- ...step.jumpDeviceKey !== void 0 ? { manualJumpDeviceKey: step.jumpDeviceKey } : {},
9476
- canRun
9951
+ const result = require_default_detection_model.stepsForElectedPool(steps, isDetailPlaneStepId, (step) => this.placementOf(config, step, attachedByKey));
9952
+ this.noteElectedPoolDelegation(deviceId, config.deviceKey, result.delegated);
9953
+ return result.delegated.length === 0 ? steps : result.steps;
9954
+ }
9955
+ /**
9956
+ * Say once per STATE CHANGE which detail steps a camera's elected pool no
9957
+ * longer loads: a camera moving A → B → A says so again on A.
9958
+ */
9959
+ noteElectedPoolDelegation(deviceId, electedDeviceKey, delegated) {
9960
+ const state = `${electedDeviceKey ?? ""}|${delegated.join(",")}`;
9961
+ if (this.electedPoolDelegationState.get(deviceId) === state) return;
9962
+ this.electedPoolDelegationState.set(deviceId, state);
9963
+ if (delegated.length === 0) return;
9964
+ this.ctx?.logger.info("detail steps not loaded on the elected pool — they run elsewhere", {
9965
+ tags: { deviceId },
9966
+ meta: {
9967
+ electedDeviceKey: electedDeviceKey ?? null,
9968
+ steps: [...delegated]
9969
+ }
9970
+ });
9971
+ }
9972
+ /** Per camera, the last (elected device, steps left out) state that was logged. */
9973
+ electedPoolDelegationState = /* @__PURE__ */ new Map();
9974
+ /** `${deviceId}:${addonId}:${target}` triples whose jump has been logged. */
9975
+ loggedStepJumps = /* @__PURE__ */ new Set();
9976
+ logStepJumped(deviceId, electedDeviceKey, decision, placement) {
9977
+ const step = placement.step;
9978
+ const key = `${deviceId}:${step.addonId}:${decision.deviceKey}`;
9979
+ if (this.loggedStepJumps.has(key)) return;
9980
+ this.loggedStepJumps.add(key);
9981
+ this.ctx?.logger.info("enrichment step runs on another device of this node", {
9982
+ tags: { deviceId },
9983
+ meta: {
9984
+ step: step.addonId,
9985
+ electedDeviceKey,
9986
+ targetDeviceKey: decision.deviceKey,
9987
+ reason: decision.reason,
9988
+ provisioned: placement.provisioned,
9989
+ modelId: step.modelId ?? null
9990
+ }
9477
9991
  });
9478
- if (result.kind === "no-candidate") this.warnStepHasNoRunnableDevice(deviceId, step.addonId, effectiveDeviceKey);
9479
- return result.deviceKey;
9480
9992
  }
9481
9993
  /** Live attached-camera count per inference `deviceKey` on this runner. */
9482
9994
  perDeviceAttachedCounts() {
@@ -9695,6 +10207,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
9695
10207
  const warnPrefix = `${deviceId}:`;
9696
10208
  for (const key of this.warnedNoJumpDevice) if (key.startsWith(warnPrefix)) this.warnedNoJumpDevice.delete(key);
9697
10209
  this.attached.delete(deviceId);
10210
+ this.electedPoolDelegationState.delete(deviceId);
9698
10211
  this.lastMotionAt.delete(deviceId);
9699
10212
  this.lastEmittedCameraMetrics.delete(deviceId);
9700
10213
  this.retention?.forgetDevice(deviceId);
@@ -10035,6 +10548,16 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10035
10548
  if (!admission) return void 0;
10036
10549
  return () => admission.admit(deviceId, label);
10037
10550
  }
10551
+ /**
10552
+ * The start's place in the node's queue of refused starts (D652) — taken by
10553
+ * the guarded start on its first refusal, kept until it is over. Spread into
10554
+ * `startGuardedSessionDecode`'s deps; empty when this node runs no ceiling.
10555
+ */
10556
+ liveWorkerSlotWaiter(deviceId) {
10557
+ const admission = this.liveWorkers;
10558
+ if (!admission) return {};
10559
+ return { slotWaiter: () => admission.slotWaiter(deviceId) };
10560
+ }
10038
10561
  buildDecodeSpawnAcquire(log, label) {
10039
10562
  return async () => {
10040
10563
  const gate = this.spawnGate;
@@ -10063,8 +10586,9 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10063
10586
  const teardown = startGuardedSessionDecode({
10064
10587
  logger: log,
10065
10588
  admitWorker: this.buildLiveWorkerAdmit(config.deviceId, "detect"),
10589
+ ...this.liveWorkerSlotWaiter(config.deviceId),
10066
10590
  acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:detect:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config), true),
10067
- startPump: (source) => {
10591
+ startPump: (source, hooks) => {
10068
10592
  const dialMs = Date.now() - requestedAt;
10069
10593
  const useLocalFrameRef = this.executorCoLocated() && process.env["CAMSTACK_DISABLE_LOCAL_FRAME_REF"] !== "1";
10070
10594
  log.info("session-decode: detection routed through per-session decode worker (rgb)", { meta: {
@@ -10076,6 +10600,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10076
10600
  return startSessionDecodePump({
10077
10601
  fork: this.forkDecodeWorkerWithLease,
10078
10602
  lease: this.resolveSessionLeaseKnobs,
10603
+ onWorkerGone: hooks.onWorkerGone,
10079
10604
  logger: log,
10080
10605
  source,
10081
10606
  opts: {
@@ -10157,10 +10682,30 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10157
10682
  strideMs: report.strideMs,
10158
10683
  subsampled: report.subsampled,
10159
10684
  laneRefused: runner.preRollRefusedCount(config.deviceId),
10160
- budgetFrames: this.preRollBudgetFor(config)
10685
+ budgetFrames: this.preRollBudgetFor(config),
10686
+ effectiveBudgetFrames: report.budgetFrames
10161
10687
  }
10162
10688
  });
10163
10689
  },
10690
+ onPreRollSettled: (summary) => {
10691
+ const lost = summary.lostByReason.evicted + summary.lostByReason.cleared + summary.lostByReason["download-failed"] + summary.lostByReason.miss;
10692
+ const meta = {
10693
+ streamId: config.detectionStreamId,
10694
+ ...summary,
10695
+ lost
10696
+ };
10697
+ if (lost > 0 || summary.unsettled > 0) {
10698
+ log.info("session-decode: pre-roll frames settled", {
10699
+ tags: { deviceId: config.deviceId },
10700
+ meta
10701
+ });
10702
+ return;
10703
+ }
10704
+ log.debug("session-decode: pre-roll frames settled", {
10705
+ tags: { deviceId: config.deviceId },
10706
+ meta
10707
+ });
10708
+ },
10164
10709
  onFirstFrame: (timing) => {
10165
10710
  const phaseActiveAt = this.lastMotionAt.get(config.deviceId);
10166
10711
  log.info("session-decode: detection first frame", {
@@ -10204,8 +10749,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10204
10749
  });
10205
10750
  }
10206
10751
  });
10207
- return () => {
10208
- teardown();
10752
+ return (mode) => {
10753
+ teardown(mode);
10209
10754
  };
10210
10755
  }
10211
10756
  /**
@@ -10226,8 +10771,9 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10226
10771
  return startGuardedSessionDecode({
10227
10772
  logger: log,
10228
10773
  admitWorker: this.buildLiveWorkerAdmit(config.deviceId, "motion"),
10774
+ ...this.liveWorkerSlotWaiter(config.deviceId),
10229
10775
  acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.motionStreamId, `session-decode:motion:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config)),
10230
- startPump: (source) => {
10776
+ startPump: (source, hooks) => {
10231
10777
  log.info("session-decode: motion routed through per-session decode worker (gray)", { meta: {
10232
10778
  streamId: config.motionStreamId,
10233
10779
  codec: source.codec
@@ -10235,6 +10781,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10235
10781
  return startSessionDecodePump({
10236
10782
  fork: this.forkDecodeWorkerWithLease,
10237
10783
  lease: this.resolveSessionLeaseKnobs,
10784
+ onWorkerGone: hooks.onWorkerGone,
10238
10785
  logger: log,
10239
10786
  source,
10240
10787
  opts: {
@@ -10371,6 +10918,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10371
10918
  }
10372
10919
  const camConfig = this.attached.get(deviceId)?.config;
10373
10920
  const steps = camConfig?.steps;
10921
+ await this.customModels.refresh(api);
10374
10922
  if (!steps) {
10375
10923
  log.warn("runInference: no steps in attach config — skipping frame (legacy attach?)");
10376
10924
  return null;
@@ -10405,6 +10953,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10405
10953
  });
10406
10954
  }
10407
10955
  }
10956
+ dispatchSteps = this.stepsForElectedPoolOf(deviceId, dispatchSteps);
10408
10957
  let image;
10409
10958
  const executorCoLocated = this.executorCoLocated();
10410
10959
  if (!executorCoLocated) {
@@ -10534,6 +11083,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10534
11083
  const cropResult = await api.pipelineExecutor.runPipeline.mutate({
10535
11084
  steps: [step],
10536
11085
  image: view.jpeg,
11086
+ deviceId,
10537
11087
  plane: "frame",
10538
11088
  ...deviceKey ? { deviceKey } : {}
10539
11089
  });
@@ -10902,15 +11452,36 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10902
11452
  * found/report semantics can't drift between the two admission
10903
11453
  * mechanisms.
10904
11454
  */
10905
- finishOccupancyBurst(deviceId, log, collected) {
11455
+ finishOccupancyBurst(deviceId, log, summary) {
10906
11456
  this.occupancyBurstInFlight.delete(deviceId);
10907
11457
  const found = this.occupancyBurstFound.has(deviceId);
10908
11458
  this.occupancyBurstFound.delete(deviceId);
11459
+ if (summary.inferred === 0) {
11460
+ log.info("occupancy re-check: burst complete — nothing inferred, no verdict", {
11461
+ tags: { deviceId },
11462
+ meta: {
11463
+ ...summary,
11464
+ verdict: null
11465
+ }
11466
+ });
11467
+ return;
11468
+ }
10909
11469
  this.runner?.reportOccupancyBurstOutcome(deviceId, found);
10910
- log.debug("occupancy re-check: burst complete", { meta: {
10911
- collected,
11470
+ const meta = {
11471
+ ...summary,
10912
11472
  found
10913
- } });
11473
+ };
11474
+ if (summary.lost > 0) {
11475
+ log.info("occupancy re-check: burst complete", {
11476
+ tags: { deviceId },
11477
+ meta
11478
+ });
11479
+ return;
11480
+ }
11481
+ log.debug("occupancy re-check: burst complete", {
11482
+ tags: { deviceId },
11483
+ meta
11484
+ });
10914
11485
  }
10915
11486
  /**
10916
11487
  * Task 10: arm a TAP-based occupancy burst for a device whose analyzer
@@ -10946,21 +11517,30 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10946
11517
  log.debug("occupancy re-check: tap safety timeout reached", { meta: { collected } });
10947
11518
  this.finishOccupancyTap(deviceId);
10948
11519
  }, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
11520
+ const ledger = new OccupancyBurstLedger({
11521
+ settleBoundMs: OCCUPANCY_BURST_SETTLE_BOUND_MS,
11522
+ onComplete: resolve
11523
+ });
10949
11524
  this.occupancyTaps.set(deviceId, {
10950
11525
  remaining: frames,
10951
11526
  collected: 0,
10952
11527
  safetyTimer,
11528
+ ledger,
10953
11529
  resolve
10954
11530
  });
10955
11531
  });
10956
11532
  }
10957
- /** Idempotent: a no-op once the tap has already settled (timer fired, or the budget already drained). */
11533
+ /**
11534
+ * Close the tap's COLLECTION. Idempotent: a no-op once the tap has already
11535
+ * closed (timer fired, or the budget already drained). The tap's promise
11536
+ * resolves later, from its ledger, once the frames it admitted have settled.
11537
+ */
10958
11538
  finishOccupancyTap(deviceId) {
10959
11539
  const tap = this.occupancyTaps.get(deviceId);
10960
11540
  if (!tap) return;
10961
11541
  clearTimeout(tap.safetyTimer);
10962
11542
  this.occupancyTaps.delete(deviceId);
10963
- tap.resolve(tap.collected);
11543
+ tap.ledger.close();
10964
11544
  }
10965
11545
  /**
10966
11546
  * Tee an arriving analyzer motion frame into occupancy-burst detection
@@ -10975,7 +11555,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10975
11555
  teeOccupancyTapFrame(deviceId, frame, handle) {
10976
11556
  const tap = this.occupancyTaps.get(deviceId);
10977
11557
  if (!tap) return;
10978
- this.runner?.enqueueOccupancyFrame(deviceId, frame, handle);
11558
+ const settle = tap.ledger.admit();
11559
+ if (!this.runner?.enqueueOccupancyFrame(deviceId, frame, handle, settle)) settle("lost");
10979
11560
  tap.collected++;
10980
11561
  tap.remaining--;
10981
11562
  if (tap.remaining <= 0) this.finishOccupancyTap(deviceId);
@@ -10995,9 +11576,9 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10995
11576
  const lastFrameAt = this.lastMotionFrameAt.get(deviceId);
10996
11577
  const motionFresh = lastFrameAt !== void 0 && Date.now() - lastFrameAt < MOTION_FRESHNESS_WINDOW_MS;
10997
11578
  if (attachment.motionUnsubscribe !== null && motionFresh) {
10998
- const collected = await this.startOccupancyTap(deviceId, frames, log);
10999
- if (collected > 0) {
11000
- this.finishOccupancyBurst(deviceId, log, collected);
11579
+ const summary = await this.startOccupancyTap(deviceId, frames, log);
11580
+ if (summary.frames > 0) {
11581
+ this.finishOccupancyBurst(deviceId, log, summary);
11001
11582
  return;
11002
11583
  }
11003
11584
  log.debug("occupancy re-check: tap collected 0 frames, falling back to dial", { meta: { frames } });
@@ -11028,76 +11609,91 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
11028
11609
  fallback: alreadyInFlight
11029
11610
  } });
11030
11611
  let count = 0;
11612
+ /** Collection is over: no further frame is admitted to this burst. */
11613
+ let admissionClosed = false;
11614
+ /** The burst has reported its verdict (or its lack of one). */
11031
11615
  let settled = false;
11032
11616
  let teardown = null;
11033
- const cleanup = () => {
11617
+ const cleanup = (summary) => {
11034
11618
  if (settled) return;
11035
11619
  settled = true;
11036
- teardown?.();
11037
- this.finishOccupancyBurst(deviceId, log, count);
11620
+ this.finishOccupancyBurst(deviceId, log, summary);
11621
+ };
11622
+ const ledger = new OccupancyBurstLedger({
11623
+ settleBoundMs: OCCUPANCY_BURST_SETTLE_BOUND_MS,
11624
+ onComplete: cleanup
11625
+ });
11626
+ const closeAdmission = () => {
11627
+ if (admissionClosed) return;
11628
+ admissionClosed = true;
11629
+ clearTimeout(safetyTimer);
11630
+ teardown?.("drain");
11631
+ ledger.close();
11632
+ };
11633
+ /** Offer one collected frame to the burst; `true` = the runner admitted it. */
11634
+ const admit = (frame, handle) => {
11635
+ const settle = ledger.admit();
11636
+ const admitted = runner.enqueueOccupancyFrame(deviceId, frame, handle, settle);
11637
+ if (!admitted) settle("lost");
11638
+ count++;
11639
+ if (count >= frames) closeAdmission();
11640
+ return admitted;
11038
11641
  };
11039
11642
  const safetyTimer = setTimeout(() => {
11040
11643
  log.debug("occupancy re-check: safety timeout reached", { meta: { collected: count } });
11041
- cleanup();
11644
+ closeAdmission();
11042
11645
  }, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
11043
11646
  teardown = startGuardedSessionDecode({
11044
11647
  logger: log,
11045
11648
  admitWorker: this.buildLiveWorkerAdmit(deviceId, "occupancy"),
11649
+ ...this.liveWorkerSlotWaiter(deviceId),
11046
11650
  acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:occupancy:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config)),
11047
- startPump: (source) => {
11651
+ startPump: (source, hooks) => {
11048
11652
  const useLocalFrameRef = this.executorCoLocated() && process.env["CAMSTACK_DISABLE_LOCAL_FRAME_REF"] !== "1";
11049
11653
  return startSessionDecodePump({
11050
11654
  fork: this.forkDecodeWorkerWithLease,
11051
11655
  lease: this.resolveSessionLeaseKnobs,
11656
+ onWorkerGone: hooks.onWorkerGone,
11052
11657
  logger: log,
11053
11658
  source,
11054
11659
  opts: {
11055
11660
  fps: config.detectionFps,
11056
11661
  format: "rgb"
11057
11662
  },
11663
+ shouldMaterializeFrame: () => !admissionClosed,
11058
11664
  ...useLocalFrameRef ? {
11059
11665
  frameRegistry: require_default_detection_model.localFrameRegistry,
11060
11666
  inlineWhen: () => runner.detectionBackpressure(config.deviceId),
11061
11667
  inlineViewSpec: SPLIT_DETECTION_VIEW,
11062
11668
  onFrameRef: (frame, nativeCrop) => {
11063
- if (settled) return false;
11669
+ if (admissionClosed) return false;
11064
11670
  const handle = writeRetainedDetectionFrame(retainedFrames, config.deviceId, frame, this.retention);
11065
11671
  if (handle && nativeCrop) {
11066
11672
  this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
11067
11673
  this.bindFrameCutter(handle, nativeCrop);
11068
11674
  }
11069
- const admitted = runner.enqueueOccupancyFrame(deviceId, frame, handle);
11675
+ const admitted = admit(frame, handle);
11070
11676
  if (admitted) nativeCrop?.markInferred();
11071
- count++;
11072
- if (count >= frames) {
11073
- clearTimeout(safetyTimer);
11074
- cleanup();
11075
- }
11076
11677
  return admitted;
11077
11678
  }
11078
11679
  } : { inlineViewSpec: SPLIT_DETECTION_VIEW },
11079
11680
  acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "occupancy"),
11080
- onEnded: () => cleanup(),
11681
+ onEnded: () => closeAdmission(),
11081
11682
  onDecodedFrame: (frame, nativeCrop) => {
11082
- if (settled) return;
11683
+ if (admissionClosed) return;
11083
11684
  const handle = writeRetainedDetectionFrame(retainedFrames, config.deviceId, frame, this.retention);
11084
11685
  if (handle && nativeCrop) {
11085
11686
  this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
11086
11687
  this.bindFrameCutter(handle, nativeCrop);
11087
11688
  }
11088
- if (runner.enqueueOccupancyFrame(deviceId, frame, handle)) nativeCrop?.markInferred();
11089
- count++;
11090
- if (count >= frames) {
11091
- clearTimeout(safetyTimer);
11092
- cleanup();
11093
- }
11689
+ if (admit(frame, handle)) nativeCrop?.markInferred();
11094
11690
  }
11095
11691
  });
11096
11692
  }
11097
11693
  });
11098
- if (settled) {
11694
+ if (admissionClosed) {
11099
11695
  clearTimeout(safetyTimer);
11100
- teardown();
11696
+ teardown("drain");
11101
11697
  }
11102
11698
  }
11103
11699
  emitInferenceResult(deviceId, frame, result, handle, source, occasion) {