@camstack/addon-pipeline 1.2.295 → 1.2.296

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-CAUBVbgK.mjs → default-detection-model-Co578D8C.mjs} +75 -46
  5. package/dist/{default-detection-model-wNEISVA9.js → default-detection-model-D1daTtqT.js} +75 -46
  6. package/dist/detection-pipeline/index.js +1023 -80
  7. package/dist/detection-pipeline/index.mjs +1023 -80
  8. package/dist/{dist-BsVcf5wO.js → dist-8up-f2TX.js} +877 -365
  9. package/dist/{dist-CuxSNLKW.mjs → dist-CCd0Q3nr.mjs} +872 -366
  10. package/dist/motion-wasm/index.js +1 -1
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-Cqb1QiXk.mjs → node-DgMSXSWP.mjs} +1 -1
  13. package/dist/{node-UFk6I2f6.js → node-lpQgHes9.js} +1 -1
  14. package/dist/pipeline-runner/index.js +789 -203
  15. package/dist/pipeline-runner/index.mjs +789 -203
  16. package/dist/{process-memory-CJV29sPf.js → process-memory-CX_92V_r.js} +1 -1
  17. package/dist/{process-memory-D0Nvs9rr.mjs → process-memory-DFC_O5zE.mjs} +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-DzBx6NN2.js} +1 -1
  21. package/dist/{segment-demux-js-DIDWw1iE.mjs → segment-demux-js-FZbBuk3F.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-C_i7oFBl.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-DUGQKsKL.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-CkbplMHA.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-BPtppL3W.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-8up-f2TX.js");
7
+ const require_node = require("../node-lpQgHes9.js");
8
+ const require_default_detection_model = require("../default-detection-model-D1daTtqT.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
  };
@@ -1450,6 +1565,29 @@ function cropRgb(frame, bbox, maxWidth) {
1450
1565
  /** How long to wait after `kill()` before escalating to `kill('SIGKILL')`. */
1451
1566
  var SIGKILL_FALLBACK_MS = 1e3;
1452
1567
  /**
1568
+ * The longest a planned teardown keeps a session's worker alive so the frames
1569
+ * it already handed out can be read (D652).
1570
+ *
1571
+ * What is being waited for is the REST of a frame's trip through the runner —
1572
+ * semaphore wait + inference + result — for frames that were admitted before
1573
+ * the detach. Measured on the live hub 2026-09-26 over one hour of
1574
+ * `pipeline stats` (2 254 per-camera 10 s windows): the WORST enqueue→emit in a
1575
+ * window was ≤1 000 ms in 96.7% of windows and ≤1 500 ms in 98.0%; going to
1576
+ * 5 000 ms buys only another 0.9%, and that tail is the inference-pool GPU hang,
1577
+ * which no wait here can rescue. The cost of waiting is one idle worker (its
1578
+ * decode already stopped by `drain`) for at most this long, per detach.
1579
+ *
1580
+ * A frame still held when this expires is not dropped silently: the
1581
+ * coordinator logs the count, tagged with the camera, and then kills.
1582
+ */
1583
+ var SESSION_DECODE_DRAIN_BOUND_MS = 1500;
1584
+ /**
1585
+ * The miss reason for a native crop requested after its session's teardown.
1586
+ * Only crops already SENT when the drain began are waited for (D652); a later
1587
+ * one would ask a worker that is about to be killed.
1588
+ */
1589
+ var NATIVE_CROP_REFUSED_DRAINING = "session draining — crop requested after teardown";
1590
+ /**
1453
1591
  * How often the cold-start wait REPORTS that it is still waiting. Task 0
1454
1592
  * live-measured cold-start (restream dial + hwaccel context + keyframe wait) at
1455
1593
  * 5-8s, so this must tolerate a slow-but-healthy start.
@@ -1521,30 +1659,38 @@ var SessionDecodeSession = class {
1521
1659
  firstFrameTimeoutMs;
1522
1660
  frameTimeoutMs;
1523
1661
  firstFrameMaxWaitMs;
1524
- onDialTiming;
1525
- onPreRollReport;
1662
+ hooks;
1526
1663
  pendingPull = null;
1527
1664
  pendingToBuffers = /* @__PURE__ */ new Map();
1528
1665
  /** Outstanding `nativeCrop` requests, keyed by the monotonic `requestId`. */
1529
1666
  pendingNativeCrops = /* @__PURE__ */ new Map();
1530
1667
  nextNativeCropId = 1;
1531
1668
  exited = false;
1532
- /** Set by teardown() so handleExit can tell an intentional stop from a death. */
1669
+ /** Set by teardown() — the session is ending, whether or not it drains first. */
1533
1670
  stopRequested = false;
1671
+ /** Set when `kill()` is sent, so handleExit can tell an intentional stop from a death. */
1672
+ killSent = false;
1673
+ /**
1674
+ * Images yielded to the consumer and not yet closed. Each is a frame the
1675
+ * consumer still holds a reference to — queued, in flight, or being read —
1676
+ * and each dies with the worker. This is what a drain waits on.
1677
+ */
1678
+ openImages = 0;
1679
+ /** Armed while a drain waits; its expiry kills with frames still held. */
1680
+ drainTimer = null;
1534
1681
  sigkillTimer = null;
1535
1682
  pullTimeoutTimer = null;
1536
1683
  firstFrameReceived = false;
1537
1684
  /** `Date.now()` of the FIRST `pull` — the origin of the cold-start ceiling. */
1538
1685
  firstPullAt = 0;
1539
- constructor(child, logger, deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs, onDialTiming, onPreRollReport) {
1686
+ constructor(child, logger, deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs, hooks) {
1540
1687
  this.child = child;
1541
1688
  this.logger = logger;
1542
1689
  this.deviceId = deviceId;
1543
1690
  this.firstFrameTimeoutMs = firstFrameTimeoutMs;
1544
1691
  this.frameTimeoutMs = frameTimeoutMs;
1545
1692
  this.firstFrameMaxWaitMs = firstFrameMaxWaitMs;
1546
- this.onDialTiming = onDialTiming;
1547
- this.onPreRollReport = onPreRollReport;
1693
+ this.hooks = hooks;
1548
1694
  this.child.on("message", (m) => this.handleMessage(m));
1549
1695
  this.child.on("exit", (m) => this.handleExit(toWorkerExitInfo(m)));
1550
1696
  }
@@ -1609,6 +1755,10 @@ var SessionDecodeSession = class {
1609
1755
  */
1610
1756
  requestNativeCrop(frameId, bbox, maxWidth) {
1611
1757
  if (this.exited) return Promise.resolve({ kind: "miss" });
1758
+ if (this.stopRequested) return Promise.resolve({
1759
+ kind: "miss",
1760
+ workerReason: NATIVE_CROP_REFUSED_DRAINING
1761
+ });
1612
1762
  const requestId = this.nextNativeCropId++;
1613
1763
  return new Promise((resolve) => {
1614
1764
  this.pendingNativeCrops.set(requestId, { resolve });
@@ -1623,6 +1773,7 @@ var SessionDecodeSession = class {
1623
1773
  } catch {
1624
1774
  this.pendingNativeCrops.delete(requestId);
1625
1775
  resolve({ kind: "miss" });
1776
+ this.settleDrainIfDone();
1626
1777
  }
1627
1778
  });
1628
1779
  }
@@ -1667,20 +1818,135 @@ var SessionDecodeSession = class {
1667
1818
  frameId
1668
1819
  });
1669
1820
  }
1670
- /** Teardown: `stop`, `kill()`, arm the SIGKILL fallback unless already exited. */
1671
- teardown() {
1821
+ /** A yielded image the consumer now holds — see {@link openImages}. */
1822
+ noteImageOpened() {
1823
+ this.openImages += 1;
1824
+ }
1825
+ /** The consumer let go of a yielded image; a waiting drain may now be done. */
1826
+ noteImageClosed() {
1827
+ this.openImages = Math.max(0, this.openImages - 1);
1828
+ this.settleDrainIfDone();
1829
+ }
1830
+ /**
1831
+ * End the session.
1832
+ *
1833
+ * `'drain'` with frames still held: tell the worker to stop decoding (it
1834
+ * keeps every frame it holds), and kill only once every yielded image has
1835
+ * been closed and every read answered — or when
1836
+ * {@link SESSION_DECODE_DRAIN_BOUND_MS} expires, which is logged. Nothing
1837
+ * held: `stop` + SIGTERM now. `'immediate'`: SIGKILL now.
1838
+ *
1839
+ * A SECOND call that says `'immediate'` while a drain is in progress
1840
+ * ESCALATES (D652): addon shutdown lands on sessions already draining from
1841
+ * a detach, and the runner exits right after — a drain it does not wait for
1842
+ * is an orphaned worker holding its GPU pool. Any other repeat is ignored.
1843
+ */
1844
+ teardown(mode) {
1845
+ if (this.stopRequested) {
1846
+ if (mode === "immediate") this.escalateToImmediate();
1847
+ return;
1848
+ }
1672
1849
  this.stopRequested = true;
1673
1850
  this.clearPullTimeout();
1851
+ if (this.exited) return;
1852
+ if (mode === "drain" && this.unsettledCount() > 0) {
1853
+ this.trySend({ kind: "drain" });
1854
+ this.drainTimer = setTimeout(() => this.handleDrainBoundExpired(), SESSION_DECODE_DRAIN_BOUND_MS);
1855
+ this.drainTimer.unref?.();
1856
+ return;
1857
+ }
1858
+ this.killNow(mode);
1859
+ }
1860
+ /** The drain (or a SIGTERM still waiting on its fallback) gives way to SIGKILL now. */
1861
+ escalateToImmediate() {
1862
+ if (this.exited) return;
1863
+ if (this.drainTimer !== null) {
1864
+ clearTimeout(this.drainTimer);
1865
+ this.drainTimer = null;
1866
+ }
1867
+ if (!this.killSent) {
1868
+ this.killNow("immediate");
1869
+ return;
1870
+ }
1871
+ if (this.sigkillTimer !== null) {
1872
+ clearTimeout(this.sigkillTimer);
1873
+ this.sigkillTimer = null;
1874
+ }
1875
+ this.tryKill("SIGKILL");
1876
+ }
1877
+ /**
1878
+ * Frames the consumer still holds, reads still waiting on the worker, and
1879
+ * native crops already REQUESTED. A crop asked for before the teardown
1880
+ * request is waited for; one asked for after it is refused by name — post-analysis's
1881
+ * ordinary crop arrives 2.2–9.9 s after the frame, past any bound worth
1882
+ * holding a worker for, and is served from subject tiles that die with the
1883
+ * worker either way.
1884
+ */
1885
+ unsettledCount() {
1886
+ return this.openImages + this.pendingReadCount() + this.pendingNativeCrops.size;
1887
+ }
1888
+ pendingReadCount() {
1889
+ let n = 0;
1890
+ for (const waiters of this.pendingToBuffers.values()) n += waiters.length;
1891
+ return n;
1892
+ }
1893
+ settleDrainIfDone() {
1894
+ if (this.drainTimer === null || this.unsettledCount() > 0) return;
1895
+ clearTimeout(this.drainTimer);
1896
+ this.drainTimer = null;
1897
+ this.killNow("drain");
1898
+ }
1899
+ /**
1900
+ * The drain ran out of time with frames still held. Those frames die with
1901
+ * the worker; that is a branch that DROPS work (D391), so it says how many,
1902
+ * on which camera.
1903
+ */
1904
+ handleDrainBoundExpired() {
1905
+ this.drainTimer = null;
1906
+ if (this.exited) return;
1907
+ this.logger.warn("session-decode-coordinator: drain bound expired — killing the worker with frames still held", {
1908
+ tags: { deviceId: this.deviceId },
1909
+ meta: {
1910
+ unsettledFrames: this.openImages,
1911
+ pendingReads: this.pendingReadCount(),
1912
+ pendingCrops: this.pendingNativeCrops.size,
1913
+ boundMs: SESSION_DECODE_DRAIN_BOUND_MS
1914
+ }
1915
+ });
1916
+ this.killNow("drain");
1917
+ }
1918
+ /**
1919
+ * `stop`, then the kill — unless already exited.
1920
+ *
1921
+ * `'drain'`: SIGTERM, so the child flushes its last lines (`bounded-exit.ts`),
1922
+ * and a SIGKILL fallback. `'immediate'`: SIGKILL outright. Since D652 gave the
1923
+ * child a SIGTERM handler, SIGTERM is no longer instant death: a worker
1924
+ * blocked in a native call (a VAAPI hang) never runs the handler, and the
1925
+ * fallback was an `unref`'d timer the exiting parent did not wait for — so
1926
+ * on shutdown that worker was orphaned with its GPU pool. The fallback timer
1927
+ * is now ref'd: it exists only for a drain while the parent is alive (a
1928
+ * shutdown escalates to SIGKILL and clears it), and a timer that keeps the
1929
+ * process up for at most one second is the price of a kill that lands.
1930
+ */
1931
+ killNow(mode) {
1932
+ if (this.killSent) return;
1933
+ this.killSent = true;
1674
1934
  this.trySend({ kind: "stop" });
1935
+ if (mode === "immediate") {
1936
+ this.tryKill("SIGKILL");
1937
+ return;
1938
+ }
1675
1939
  this.tryKill(void 0);
1676
1940
  if (this.exited) return;
1677
1941
  this.sigkillTimer = setTimeout(() => {
1678
1942
  this.sigkillTimer = null;
1679
1943
  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 } });
1944
+ this.logger.warn("session-decode-coordinator: child did not exit within SIGKILL fallback window — forcing", {
1945
+ tags: { deviceId: this.deviceId },
1946
+ meta: { fallbackMs: SIGKILL_FALLBACK_MS }
1947
+ });
1681
1948
  this.tryKill("SIGKILL");
1682
1949
  }, SIGKILL_FALLBACK_MS);
1683
- this.sigkillTimer.unref?.();
1684
1950
  }
1685
1951
  handleMessage(raw) {
1686
1952
  if (!require_worker_protocol.isWorkerReply(raw)) {
@@ -1729,7 +1995,7 @@ var SessionDecodeSession = class {
1729
1995
  this.logger[raw.level](raw.message);
1730
1996
  return;
1731
1997
  case "dialTiming":
1732
- this.onDialTiming?.({
1998
+ this.hooks.onDialTiming?.({
1733
1999
  hwContextMs: raw.hwContextMs,
1734
2000
  demuxerOpenMs: raw.demuxerOpenMs,
1735
2001
  decoderCreateMs: raw.decoderCreateMs,
@@ -1737,25 +2003,46 @@ var SessionDecodeSession = class {
1737
2003
  streamInfoProbed: raw.streamInfoProbed
1738
2004
  });
1739
2005
  return;
2006
+ case "preRollSettled":
2007
+ this.hooks.onPreRollSettled?.({
2008
+ read: raw.read,
2009
+ released: raw.released,
2010
+ lostByReason: raw.lostByReason,
2011
+ unsettled: raw.unsettled
2012
+ });
2013
+ return;
1740
2014
  case "preRollReport":
1741
- this.onPreRollReport?.({
2015
+ this.hooks.onPreRollReport?.({
1742
2016
  offered: raw.offered,
1743
2017
  inferred: raw.inferred,
1744
2018
  dropped: raw.dropped,
1745
2019
  droppedByReason: raw.droppedByReason,
1746
2020
  strideMs: raw.strideMs,
1747
2021
  subsampled: raw.subsampled,
1748
- spanMs: raw.spanMs
2022
+ spanMs: raw.spanMs,
2023
+ budgetFrames: raw.budgetFrames
1749
2024
  });
1750
2025
  return;
1751
2026
  }
1752
2027
  }
1753
2028
  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
- } });
2029
+ if (!this.exited) this.hooks.onWorkerExit?.();
2030
+ if (!this.killSent && !this.exited) this.logger.warn(this.stopRequested ? "session-decode worker exited unexpectedly while draining" : "session-decode worker exited unexpectedly", {
2031
+ tags: { deviceId: this.deviceId },
2032
+ meta: {
2033
+ code: info?.code ?? null,
2034
+ signal: info?.signal ?? null,
2035
+ ...this.stopRequested ? {
2036
+ unsettledFrames: this.openImages,
2037
+ pendingReads: this.pendingReadCount()
2038
+ } : {}
2039
+ }
2040
+ });
1758
2041
  this.exited = true;
2042
+ if (this.drainTimer) {
2043
+ clearTimeout(this.drainTimer);
2044
+ this.drainTimer = null;
2045
+ }
1759
2046
  if (this.sigkillTimer) {
1760
2047
  clearTimeout(this.sigkillTimer);
1761
2048
  this.sigkillTimer = null;
@@ -1777,6 +2064,7 @@ var SessionDecodeSession = class {
1777
2064
  if (!waiter) return;
1778
2065
  this.pendingNativeCrops.delete(requestId);
1779
2066
  waiter.resolve(attempt);
2067
+ this.settleDrainIfDone();
1780
2068
  }
1781
2069
  settlePendingPull(outcome) {
1782
2070
  const waiter = this.pendingPull;
@@ -1872,6 +2160,7 @@ var SessionDecodeSession = class {
1872
2160
  const waiter = queue.shift();
1873
2161
  if (queue.length === 0) this.pendingToBuffers.delete(frameId);
1874
2162
  waiter?.resolve(bytes);
2163
+ this.settleDrainIfDone();
1875
2164
  }
1876
2165
  /**
1877
2166
  * `error` replies MAY carry a `frameId` (a `toBuffer`-scoped miss, e.g. a
@@ -1891,6 +2180,7 @@ var SessionDecodeSession = class {
1891
2180
  if (queue && queue.length > 0) {
1892
2181
  for (const waiter of queue) waiter.reject(err);
1893
2182
  this.pendingToBuffers.delete(frameId);
2183
+ this.settleDrainIfDone();
1894
2184
  return;
1895
2185
  }
1896
2186
  this.logger.debug("session-decode-coordinator: dropping toBuffer error for unknown frameId", { meta: { frameId } });
@@ -1905,6 +2195,7 @@ var SessionDecodeSession = class {
1905
2195
  }
1906
2196
  for (const waiters of this.pendingToBuffers.values()) for (const waiter of waiters) waiter.reject(err);
1907
2197
  this.pendingToBuffers.clear();
2198
+ this.settleDrainIfDone();
1908
2199
  }
1909
2200
  queueToBufferWaiter(frameId, waiter) {
1910
2201
  const queue = this.pendingToBuffers.get(frameId);
@@ -1942,6 +2233,7 @@ var SessionDecodeSession = class {
1942
2233
  /** Build the {@link FrameImage} adapter for one `frame` reply. */
1943
2234
  function buildFrameImage(session, reply) {
1944
2235
  let closed = false;
2236
+ session.noteImageOpened();
1945
2237
  return {
1946
2238
  width: reply.width,
1947
2239
  height: reply.height,
@@ -1956,7 +2248,9 @@ function buildFrameImage(session, reply) {
1956
2248
  releaseNativeLease: () => session.releaseLease(reply.frameId),
1957
2249
  markInferred: () => session.markInferred(reply.frameId),
1958
2250
  close: () => {
2251
+ if (closed) return;
1959
2252
  closed = true;
2253
+ session.noteImageClosed();
1960
2254
  }
1961
2255
  };
1962
2256
  }
@@ -1965,15 +2259,23 @@ function buildFrameImage(session, reply) {
1965
2259
  * `generateVideoFrames()` call, drives it with `pull`/`toBuffer` requests
1966
2260
  * one at a time (no pre-fetch — back-pressure is the async generator's
1967
2261
  * natural suspend-on-yield), and guarantees the child is torn down
1968
- * (`stop` + `kill()` + SIGKILL fallback) in every exit path via `finally`.
2262
+ * (bounded drain, then `stop` + `kill()` + SIGKILL fallback) in every exit
2263
+ * path via `finally`.
1969
2264
  */
1970
2265
  function createSessionDecodeCoordinator(deps) {
1971
2266
  const firstFrameTimeoutMs = deps.firstFrameTimeoutMs ?? DEFAULT_FIRST_FRAME_TIMEOUT_MS;
1972
2267
  const frameTimeoutMs = deps.frameTimeoutMs ?? DEFAULT_FRAME_TIMEOUT_MS;
1973
2268
  const firstFrameMaxWaitMs = Math.max(deps.firstFrameMaxWaitMs ?? DEFAULT_FIRST_FRAME_MAX_WAIT_MS, firstFrameTimeoutMs);
1974
2269
  return { async *generateVideoFrames(source, opts) {
1975
- const session = new SessionDecodeSession(deps.fork(source), deps.logger, source.deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs, deps.onDialTiming, deps.onPreRollReport);
2270
+ const session = new SessionDecodeSession(deps.fork(source), deps.logger, source.deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs, {
2271
+ ...deps.onDialTiming ? { onDialTiming: deps.onDialTiming } : {},
2272
+ ...deps.onPreRollReport ? { onPreRollReport: deps.onPreRollReport } : {},
2273
+ ...deps.onPreRollSettled ? { onPreRollSettled: deps.onPreRollSettled } : {},
2274
+ ...deps.onWorkerExit ? { onWorkerExit: deps.onWorkerExit } : {}
2275
+ });
1976
2276
  session.start(source, opts, deps.lease());
2277
+ deps.onSessionTeardown?.((mode) => session.teardown(mode));
2278
+ let failed = false;
1977
2279
  try {
1978
2280
  while (true) {
1979
2281
  const outcome = await session.pullNext();
@@ -1986,8 +2288,11 @@ function createSessionDecodeCoordinator(deps) {
1986
2288
  ...reply.preRoll === true ? { preRoll: true } : {}
1987
2289
  };
1988
2290
  }
2291
+ } catch (err) {
2292
+ failed = true;
2293
+ throw err;
1989
2294
  } finally {
1990
- session.teardown();
2295
+ session.teardown(failed ? "immediate" : deps.teardownMode?.() ?? "drain");
1991
2296
  }
1992
2297
  } };
1993
2298
  }
@@ -2031,17 +2336,39 @@ async function toDecodedFrame(frame, format, viewSpec) {
2031
2336
  * Start pulling frames from a per-session decode worker and feed each decoded
2032
2337
  * frame to `onDecodedFrame`. Returns an idempotent teardown closure that stops
2033
2338
  * 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.
2339
+ * `finally` → a bounded DRAIN of the frames already handed out (D652), then
2340
+ * `stop` + SIGTERM + 1s SIGKILL fallback → worker exit → the OS reclaims the
2341
+ * VAAPI/GPU decode pool. `teardown('immediate')` SIGKILLs at once (shutdown),
2342
+ * and said a second time during a drain it escalates that drain to the kill.
2343
+ * Fire-and-forget: the returned closure is synchronous; the frame loop and
2344
+ * generator teardown run on their own tasks.
2037
2345
  */
2038
2346
  function startSessionDecodePump(deps) {
2039
2347
  const format = deps.opts.format ?? "rgb";
2040
2348
  let dialTiming = null;
2349
+ let teardownMode = "drain";
2350
+ let escalateSession = null;
2351
+ let forked = false;
2352
+ let workerGone = false;
2353
+ const noteWorkerGone = () => {
2354
+ if (workerGone) return;
2355
+ workerGone = true;
2356
+ deps.onWorkerGone?.();
2357
+ };
2041
2358
  const iterator = createSessionDecodeCoordinator({
2042
- fork: deps.fork,
2359
+ fork: (source) => {
2360
+ const child = deps.fork(source);
2361
+ forked = true;
2362
+ return child;
2363
+ },
2043
2364
  lease: deps.lease,
2044
2365
  logger: deps.logger,
2366
+ teardownMode: () => teardownMode,
2367
+ onSessionTeardown: (teardown) => {
2368
+ escalateSession = teardown;
2369
+ },
2370
+ onWorkerExit: noteWorkerGone,
2371
+ ...deps.onPreRollSettled ? { onPreRollSettled: deps.onPreRollSettled } : {},
2045
2372
  onDialTiming: (timing) => {
2046
2373
  dialTiming = timing;
2047
2374
  },
@@ -2147,13 +2474,21 @@ function startSessionDecodePump(deps) {
2147
2474
  });
2148
2475
  } finally {
2149
2476
  releaseSpawnSlotOnce();
2477
+ if (!forked) noteWorkerGone();
2150
2478
  if (!stopped) deps.onEnded?.();
2151
2479
  }
2152
2480
  };
2153
2481
  pump();
2154
- return () => {
2155
- if (stopped) return;
2482
+ return (mode = "drain") => {
2483
+ if (stopped) {
2484
+ if (mode === "immediate" && teardownMode !== "immediate") {
2485
+ teardownMode = "immediate";
2486
+ escalateSession?.("immediate");
2487
+ }
2488
+ return;
2489
+ }
2156
2490
  stopped = true;
2491
+ teardownMode = mode;
2157
2492
  releaseSpawnSlotOnce();
2158
2493
  iterator.return().catch(() => {});
2159
2494
  };
@@ -3584,8 +3919,8 @@ async function runDetailSubtree(deps, input) {
3584
3919
  } : void 0;
3585
3920
  const details = [];
3586
3921
  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);
3922
+ const placement = deps.placeStep?.(input.deviceId, step);
3923
+ const result = await deps.runPipeline([placement?.step ?? step], crop.jpeg, input.deviceId, placement?.deviceKey, nativeCropRef);
3589
3924
  if (!result) continue;
3590
3925
  for (const detection of result.detections) details.push(toDetailResult(detection, step.addonId, crop));
3591
3926
  }
@@ -3692,6 +4027,72 @@ function resolveStepDevice(input) {
3692
4027
  reason: "auto"
3693
4028
  };
3694
4029
  }
4030
+ /**
4031
+ * Re-provision a jumped subtree for its TARGET device (D650): every step's
4032
+ * `modelId` / `settings` are REPLACED by the target's entry — the orchestrator
4033
+ * computed it as what the step would carry had the camera been elected onto
4034
+ * that device (its per-(node,device) base with the camera's override for it).
4035
+ * A step absent from the map runs with neither, i.e. the target format's
4036
+ * default model. The elected device's provisioning never follows a step across:
4037
+ * it was chosen for a format this subtree does not run on.
4038
+ */
4039
+ function provisionSubtreeForDevice(step, provisioning) {
4040
+ const own = provisioning[step.addonId];
4041
+ const { modelId: _electedModelId, settings: _electedSettings, ...rest } = step;
4042
+ return {
4043
+ ...rest,
4044
+ ...own?.modelId !== void 0 ? { modelId: own.modelId } : {},
4045
+ ...own?.settings !== void 0 ? { settings: own.settings } : {},
4046
+ ...step.children !== void 0 ? { children: step.children.map((c) => provisionSubtreeForDevice(c, provisioning)) } : {}
4047
+ };
4048
+ }
4049
+ /**
4050
+ * Place ONE detail subtree (D650): which same-node device runs it
4051
+ * ({@link resolveStepDevice}) and, when it jumps, the provisioning it runs
4052
+ * with there ({@link provisionSubtreeForDevice}). Pure — the caller logs.
4053
+ *
4054
+ * A roster entry without `steps` came from an orchestrator that predates D650:
4055
+ * the step keeps what it was sent with, as it did before.
4056
+ */
4057
+ function placeDetailStep(input) {
4058
+ const { step, effectiveDeviceKey, roster } = input;
4059
+ if (!effectiveDeviceKey) return {
4060
+ deviceKey: void 0,
4061
+ step,
4062
+ decision: null,
4063
+ provisioned: false
4064
+ };
4065
+ if (!roster || roster.length < 2) return {
4066
+ deviceKey: effectiveDeviceKey,
4067
+ step,
4068
+ decision: null,
4069
+ provisioned: false
4070
+ };
4071
+ const decision = resolveStepDevice({
4072
+ effectiveDeviceKey,
4073
+ devices: roster.map((d) => ({
4074
+ deviceKey: d.deviceKey,
4075
+ weight: d.weight,
4076
+ maxSessions: d.maxSessions,
4077
+ attachedCameras: input.attachedByKey.get(d.deviceKey) ?? 0
4078
+ })),
4079
+ ...step.jumpDeviceKey !== void 0 ? { manualJumpDeviceKey: step.jumpDeviceKey } : {},
4080
+ canRun: buildSubtreeCanRun(step, input.getStepDef)
4081
+ });
4082
+ if (decision.kind !== "jumped") return {
4083
+ deviceKey: decision.deviceKey,
4084
+ step,
4085
+ decision,
4086
+ provisioned: false
4087
+ };
4088
+ const provisioning = roster.find((d) => d.deviceKey === decision.deviceKey)?.steps;
4089
+ return {
4090
+ deviceKey: decision.deviceKey,
4091
+ step: provisioning === void 0 ? step : provisionSubtreeForDevice(step, provisioning),
4092
+ decision,
4093
+ provisioned: provisioning !== void 0
4094
+ };
4095
+ }
3695
4096
  //#endregion
3696
4097
  //#region src/pipeline-runner/device-load.ts
3697
4098
  /** The backend prefix of a `<backend>:<device>` key (bare `cpu` → `cpu`). */
@@ -3875,6 +4276,74 @@ function release(cutter) {
3875
4276
  } catch {}
3876
4277
  }
3877
4278
  //#endregion
4279
+ //#region src/pipeline-runner/occupancy-burst-ledger.ts
4280
+ var OccupancyBurstLedger = class {
4281
+ options;
4282
+ frames = 0;
4283
+ inferred = 0;
4284
+ superseded = 0;
4285
+ settled = 0;
4286
+ closed = false;
4287
+ completed = false;
4288
+ boundTimer = null;
4289
+ constructor(options) {
4290
+ this.options = options;
4291
+ }
4292
+ /**
4293
+ * Count one collected frame and hand back its settler. A frame the runner
4294
+ * REFUSED is settled `'lost'` by the caller at once — it is a frame the
4295
+ * verdict did not see, which is what `lost` counts.
4296
+ */
4297
+ admit() {
4298
+ this.frames += 1;
4299
+ let done = false;
4300
+ return (outcome) => {
4301
+ if (done || this.completed) return;
4302
+ done = true;
4303
+ this.settled += 1;
4304
+ if (outcome === "inferred") this.inferred += 1;
4305
+ else if (outcome === "superseded") this.superseded += 1;
4306
+ this.completeIfSettled();
4307
+ };
4308
+ }
4309
+ /**
4310
+ * No more frames will be collected (count reached, safety timeout, or the
4311
+ * decode ended). Completes now when everything admitted has settled, else
4312
+ * arms the bound. Idempotent.
4313
+ */
4314
+ close() {
4315
+ if (this.closed || this.completed) return;
4316
+ this.closed = true;
4317
+ if (this.completeIfSettled()) return;
4318
+ this.boundTimer = setTimeout(() => {
4319
+ this.boundTimer = null;
4320
+ this.complete(true);
4321
+ }, this.options.settleBoundMs);
4322
+ this.boundTimer.unref?.();
4323
+ }
4324
+ completeIfSettled() {
4325
+ if (!this.closed || this.settled < this.frames) return false;
4326
+ this.complete(false);
4327
+ return true;
4328
+ }
4329
+ complete(timedOut) {
4330
+ if (this.completed) return;
4331
+ this.completed = true;
4332
+ if (this.boundTimer !== null) {
4333
+ clearTimeout(this.boundTimer);
4334
+ this.boundTimer = null;
4335
+ }
4336
+ this.options.onComplete({
4337
+ frames: this.frames,
4338
+ inferred: this.inferred,
4339
+ superseded: this.superseded,
4340
+ lost: this.frames - this.inferred - this.superseded,
4341
+ unsettled: this.frames - this.settled,
4342
+ timedOut
4343
+ });
4344
+ }
4345
+ };
4346
+ //#endregion
3878
4347
  //#region src/pipeline-runner/frame-lazy-metrics.ts
3879
4348
  function emptyFrameLazyCounters() {
3880
4349
  return {
@@ -5287,41 +5756,6 @@ var FrameGovernor = class {
5287
5756
  };
5288
5757
  //#endregion
5289
5758
  //#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
5759
  var FrameQueue = class {
5326
5760
  maxSize;
5327
5761
  latest = null;
@@ -5342,7 +5776,7 @@ var FrameQueue = class {
5342
5776
  enqueue(item) {
5343
5777
  if (this.latest !== null) {
5344
5778
  this._droppedFrames++;
5345
- this.dispose?.(this.latest);
5779
+ this.dispose?.(this.latest, "superseded");
5346
5780
  }
5347
5781
  this.latest = this.clone(item);
5348
5782
  }
@@ -5357,8 +5791,12 @@ var FrameQueue = class {
5357
5791
  get droppedFrames() {
5358
5792
  return this._droppedFrames;
5359
5793
  }
5794
+ /** The queued item, without taking it. */
5795
+ peek() {
5796
+ return this.latest ?? void 0;
5797
+ }
5360
5798
  clear() {
5361
- if (this.latest !== null) this.dispose?.(this.latest);
5799
+ if (this.latest !== null) this.dispose?.(this.latest, "cleared");
5362
5800
  this.latest = null;
5363
5801
  }
5364
5802
  };
@@ -5758,7 +6196,8 @@ function ownDetectionEntry(entry) {
5758
6196
  return {
5759
6197
  frame: ownFrame(entry.frame),
5760
6198
  handle: entry.handle,
5761
- occasion: entry.occasion
6199
+ occasion: entry.occasion,
6200
+ ...entry.onSettled ? { onSettled: entry.onSettled } : {}
5762
6201
  };
5763
6202
  }
5764
6203
  /**
@@ -5900,9 +6339,10 @@ var PipelineRunner = class {
5900
6339
  }
5901
6340
  registerCamera(deviceId, registration) {
5902
6341
  const motionQueue = new FrameQueue(this.config.maxQueueDepth, ownMotionEntry);
5903
- const detectionQueue = new FrameQueue(this.config.maxQueueDepth, ownDetectionEntry, (entry) => {
6342
+ const detectionQueue = new FrameQueue(this.config.maxQueueDepth, ownDetectionEntry, (entry, reason) => {
5904
6343
  if (entry.handle) this.config.releaseRetention?.(entry.handle, "drop");
5905
6344
  if (entry.frame.frameRef) this.config.releaseFrameRef?.(entry.frame.frameRef, "drop");
6345
+ entry.onSettled?.(reason === "superseded" ? "superseded" : "lost");
5906
6346
  });
5907
6347
  const initialPhase = registration.detectionMode === "disabled" ? "idle" : registration.detectionMode === "always-on" ? "active" : "watching";
5908
6348
  const preRollQueue = new PreRollLane({
@@ -5911,6 +6351,7 @@ var PipelineRunner = class {
5911
6351
  dispose: (entry) => {
5912
6352
  if (entry.handle) this.config.releaseRetention?.(entry.handle, "drop");
5913
6353
  if (entry.frame.frameRef) this.config.releaseFrameRef?.(entry.frame.frameRef, "drop");
6354
+ entry.onSettled?.("lost");
5914
6355
  }
5915
6356
  });
5916
6357
  this.logger?.info("camera registered: root detectors", {
@@ -5971,8 +6412,7 @@ var PipelineRunner = class {
5971
6412
  }
5972
6413
  if (state.phase === "active") this.detectionStreamHandler?.(deviceId, "unsubscribe");
5973
6414
  state.motionQueue.clear();
5974
- state.detectionQueue.clear();
5975
- state.preRollQueue.clear();
6415
+ this.discardQueuedDetectionFrames(deviceId, state, "detach");
5976
6416
  this.timingSampler.clearDroppedFramesSource(deviceId);
5977
6417
  this.cameras.delete(deviceId);
5978
6418
  this.lastAdmittedAt.delete(deviceId);
@@ -6096,9 +6536,14 @@ var PipelineRunner = class {
6096
6536
  preRollQueueDepth(deviceId) {
6097
6537
  return this.cameras.get(deviceId)?.preRollQueue.size ?? 0;
6098
6538
  }
6099
- /** Frames this camera lane REFUSED because it was full. */
6539
+ /**
6540
+ * Frames this camera lane REFUSED because it was full — or `null` when the
6541
+ * camera is no longer registered and the count cannot be read (a report
6542
+ * that lands after a detach). A failed read is `null`, never `0` (D393):
6543
+ * zero would say "nothing refused" about a lane nobody looked at.
6544
+ */
6100
6545
  preRollRefusedCount(deviceId) {
6101
- return this.cameras.get(deviceId)?.preRollQueue.refused ?? 0;
6546
+ return this.cameras.get(deviceId)?.preRollQueue.refused ?? null;
6102
6547
  }
6103
6548
  /**
6104
6549
  * Whether the node has a permit to spare for history right now — i.e. one
@@ -6127,7 +6572,7 @@ var PipelineRunner = class {
6127
6572
  * decode worker). `runInference` already falls back to the pixel path
6128
6573
  * when the handle is absent.
6129
6574
  */
6130
- enqueueOccupancyFrame(deviceId, frame, handle) {
6575
+ enqueueOccupancyFrame(deviceId, frame, handle, onSettled) {
6131
6576
  const state = this.cameras.get(deviceId);
6132
6577
  if (!state) return false;
6133
6578
  if (state.phase !== "watching") return false;
@@ -6135,7 +6580,8 @@ var PipelineRunner = class {
6135
6580
  state.detectionQueue.enqueue({
6136
6581
  frame,
6137
6582
  handle,
6138
- occasion: "burst"
6583
+ occasion: "burst",
6584
+ ...onSettled ? { onSettled } : {}
6139
6585
  });
6140
6586
  return true;
6141
6587
  }
@@ -6343,7 +6789,7 @@ var PipelineRunner = class {
6343
6789
  }
6344
6790
  transitionToWatching(deviceId, state, cooldownMs) {
6345
6791
  state.phase = "watching";
6346
- state.detectionQueue.clear();
6792
+ this.discardQueuedDetectionFrames(deviceId, state, "session-close");
6347
6793
  state.occupancyArmed = state.lastResultDetections > 0;
6348
6794
  state.emptyBurstStreak = 0;
6349
6795
  this.logger?.info("motion gate closed — phase=watching", {
@@ -6364,6 +6810,41 @@ var PipelineRunner = class {
6364
6810
  state.lastArmedSource = null;
6365
6811
  state.lastArmedRegions = void 0;
6366
6812
  }
6813
+ /**
6814
+ * Throw away the frames still QUEUED for a camera whose session is ending.
6815
+ *
6816
+ * A branch that drops work says so (D391, D652). These are frames the runner
6817
+ * admitted and will now never infer — the live queue's latest frame and, on a
6818
+ * detach, whatever history is left in the pre-roll lane. Frames already in
6819
+ * flight are not touched here: they finish, and the decode worker they live
6820
+ * in drains before it is killed (`SESSION_DECODE_DRAIN_BOUND_MS`).
6821
+ */
6822
+ discardQueuedDetectionFrames(deviceId, state, reason) {
6823
+ const queued = state.detectionQueue.size;
6824
+ const awaited = state.detectionQueue.peek()?.onSettled !== void 0 ? 1 : 0;
6825
+ const preRoll = reason === "detach" ? state.preRollQueue.size : 0;
6826
+ state.detectionQueue.clear();
6827
+ if (reason === "detach") state.preRollQueue.clear();
6828
+ if (queued + preRoll === 0) return;
6829
+ const meta = {
6830
+ reason,
6831
+ queued,
6832
+ awaited,
6833
+ preRoll,
6834
+ inflight: state.inflight
6835
+ };
6836
+ if (awaited + preRoll > 0) {
6837
+ this.logger?.info("detection frames discarded with the session", {
6838
+ tags: { deviceId },
6839
+ meta
6840
+ });
6841
+ return;
6842
+ }
6843
+ this.logger?.debug("detection frames discarded with the session", {
6844
+ tags: { deviceId },
6845
+ meta
6846
+ });
6847
+ }
6367
6848
  tick() {
6368
6849
  this.drainMotionQueues();
6369
6850
  if (this.semaphore.available <= 0) {
@@ -6451,6 +6932,7 @@ var PipelineRunner = class {
6451
6932
  } catch {}
6452
6933
  if (handle) this.config.releaseRetention?.(handle, frameReleaseReason);
6453
6934
  if (frame.frameRef) this.config.releaseFrameRef?.(frame.frameRef, frameReleaseReason);
6935
+ entry.onSettled?.(frameReleaseReason === "success" ? "inferred" : "lost");
6454
6936
  }
6455
6937
  }
6456
6938
  /**
@@ -6532,6 +7014,7 @@ var PipelineRunner = class {
6532
7014
  } catch {}
6533
7015
  if (handle) this.config.releaseRetention?.(handle, frameReleaseReason);
6534
7016
  if (frame.frameRef) this.config.releaseFrameRef?.(frame.frameRef, frameReleaseReason);
7017
+ entry.onSettled?.(frameReleaseReason === "success" ? "inferred" : "lost");
6535
7018
  }
6536
7019
  }
6537
7020
  /**
@@ -7075,6 +7558,19 @@ function writeRetainedDetectionFrame(store, deviceId, frame, retention) {
7075
7558
  */
7076
7559
  var OCCUPANCY_BURST_SAFETY_TIMEOUT_MS = 8e3;
7077
7560
  /**
7561
+ * How long a burst whose collection has ended waits for the frames it admitted
7562
+ * to be INFERRED before it reports anyway (D652).
7563
+ *
7564
+ * A burst's verdict is read off its frames' results, so it completes when they
7565
+ * settle, not when they are enqueued. This bound is only for a frame that never
7566
+ * settles. Measured on the live hub 2026-09-26, the worst enqueue→emit of a
7567
+ * 10 s window was ≤1.5 s in 98.0% of 2 254 windows and ≤5 s in 98.9%; what lies
7568
+ * beyond is the inference-pool GPU hang, which the runner's own 20 s inference
7569
+ * timeout already ends. A timed-out burst counts its unsettled frames as lost
7570
+ * and says so on its `burst complete` line.
7571
+ */
7572
+ var OCCUPANCY_BURST_SETTLE_BOUND_MS = 5e3;
7573
+ /**
7078
7574
  * Freshness window for `lastMotionFrameAt` (Task 10 hardening). A non-null
7079
7575
  * `attachment.motionUnsubscribe` only proves a subscription handle exists —
7080
7576
  * NOT that frames are actually flowing (guarded-session-decode can return
@@ -7884,8 +8380,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
7884
8380
  this.runner = null;
7885
8381
  }
7886
8382
  for (const attachment of this.attached.values()) {
7887
- attachment.motionUnsubscribe?.();
7888
- attachment.detectionUnsubscribe?.();
8383
+ attachment.motionUnsubscribe?.("immediate");
8384
+ attachment.detectionUnsubscribe?.("immediate");
7889
8385
  }
7890
8386
  for (const deviceId of [...this.parkedDetail.keys()]) this.dropParkedDetail(deviceId);
7891
8387
  this.attached.clear();
@@ -9230,7 +9726,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
9230
9726
  resolveChildSteps: (deviceId, parentClassName, filter) => this.resolveDetailChildSteps(deviceId, parentClassName, filter),
9231
9727
  getNativeCrop: (deviceId, handle, paddedBbox) => this.getNativeCropForDetail(deviceId, handle, paddedBbox),
9232
9728
  runPipeline: (steps, imageJpeg, deviceId, deviceKey, nativeCropRef) => this.runDetailPipeline(steps, imageJpeg, deviceId, deviceKey, nativeCropRef),
9233
- resolveStepDevice: (deviceId, step) => this.resolveDetailStepDevice(deviceId, step),
9729
+ placeStep: (deviceId, step) => this.placeDetailStepForCamera(deviceId, step),
9234
9730
  resolveCropConvention: () => convention,
9235
9731
  logger: this.detailSubtreeLogger()
9236
9732
  }, input);
@@ -9444,39 +9940,49 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
9444
9940
  return { warn: (message, extras) => this.ctxIfReady?.logger.warn(message, extras) };
9445
9941
  }
9446
9942
  /**
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.
9943
+ * Step-tree device jump — place one enrichment subtree on a same-node
9944
+ * inference device ({@link placeDetailStep}). AUTO-jumps only when the
9945
+ * camera's elected device cannot run the subtree (its format has no build);
9946
+ * an operator `step.jumpDeviceKey` overrides. A jumped subtree runs with the
9947
+ * TARGET device's provisioning carried on the roster (D650), never the
9948
+ * elected device's. `deviceKey: undefined` ⇒ the runner's per-camera default
9949
+ * pool (no elected device).
9453
9950
  *
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).
9951
+ * Logs the jump ONCE per (camera, step, target), and — when NO enabled device
9952
+ * on the node can run the subtree — a LOUD warn once per (camera, step).
9457
9953
  */
9458
- resolveDetailStepDevice(deviceId, step) {
9954
+ placeDetailStepForCamera(deviceId, step) {
9459
9955
  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;
9464
- 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
9956
+ const placement = placeDetailStep({
9957
+ step,
9958
+ effectiveDeviceKey: config?.deviceKey,
9959
+ roster: config?.inferenceDevices,
9960
+ attachedByKey: this.perDeviceAttachedCounts(),
9961
+ getStepDef: tryGetStepDefinition
9962
+ });
9963
+ const decision = placement.decision;
9964
+ if (decision?.kind === "no-candidate") this.warnStepHasNoRunnableDevice(deviceId, step.addonId, decision.deviceKey);
9965
+ else if (decision?.kind === "jumped" && config?.deviceKey) this.logStepJumped(deviceId, config.deviceKey, decision, placement);
9966
+ return placement;
9967
+ }
9968
+ /** `${deviceId}:${addonId}:${target}` triples whose jump has been logged. */
9969
+ loggedStepJumps = /* @__PURE__ */ new Set();
9970
+ logStepJumped(deviceId, electedDeviceKey, decision, placement) {
9971
+ const step = placement.step;
9972
+ const key = `${deviceId}:${step.addonId}:${decision.deviceKey}`;
9973
+ if (this.loggedStepJumps.has(key)) return;
9974
+ this.loggedStepJumps.add(key);
9975
+ this.ctx?.logger.info("enrichment step runs on another device of this node", {
9976
+ tags: { deviceId },
9977
+ meta: {
9978
+ step: step.addonId,
9979
+ electedDeviceKey,
9980
+ targetDeviceKey: decision.deviceKey,
9981
+ reason: decision.reason,
9982
+ provisioned: placement.provisioned,
9983
+ modelId: step.modelId ?? null
9984
+ }
9477
9985
  });
9478
- if (result.kind === "no-candidate") this.warnStepHasNoRunnableDevice(deviceId, step.addonId, effectiveDeviceKey);
9479
- return result.deviceKey;
9480
9986
  }
9481
9987
  /** Live attached-camera count per inference `deviceKey` on this runner. */
9482
9988
  perDeviceAttachedCounts() {
@@ -10035,6 +10541,16 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10035
10541
  if (!admission) return void 0;
10036
10542
  return () => admission.admit(deviceId, label);
10037
10543
  }
10544
+ /**
10545
+ * The start's place in the node's queue of refused starts (D652) — taken by
10546
+ * the guarded start on its first refusal, kept until it is over. Spread into
10547
+ * `startGuardedSessionDecode`'s deps; empty when this node runs no ceiling.
10548
+ */
10549
+ liveWorkerSlotWaiter(deviceId) {
10550
+ const admission = this.liveWorkers;
10551
+ if (!admission) return {};
10552
+ return { slotWaiter: () => admission.slotWaiter(deviceId) };
10553
+ }
10038
10554
  buildDecodeSpawnAcquire(log, label) {
10039
10555
  return async () => {
10040
10556
  const gate = this.spawnGate;
@@ -10063,8 +10579,9 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10063
10579
  const teardown = startGuardedSessionDecode({
10064
10580
  logger: log,
10065
10581
  admitWorker: this.buildLiveWorkerAdmit(config.deviceId, "detect"),
10582
+ ...this.liveWorkerSlotWaiter(config.deviceId),
10066
10583
  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) => {
10584
+ startPump: (source, hooks) => {
10068
10585
  const dialMs = Date.now() - requestedAt;
10069
10586
  const useLocalFrameRef = this.executorCoLocated() && process.env["CAMSTACK_DISABLE_LOCAL_FRAME_REF"] !== "1";
10070
10587
  log.info("session-decode: detection routed through per-session decode worker (rgb)", { meta: {
@@ -10076,6 +10593,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10076
10593
  return startSessionDecodePump({
10077
10594
  fork: this.forkDecodeWorkerWithLease,
10078
10595
  lease: this.resolveSessionLeaseKnobs,
10596
+ onWorkerGone: hooks.onWorkerGone,
10079
10597
  logger: log,
10080
10598
  source,
10081
10599
  opts: {
@@ -10157,10 +10675,30 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10157
10675
  strideMs: report.strideMs,
10158
10676
  subsampled: report.subsampled,
10159
10677
  laneRefused: runner.preRollRefusedCount(config.deviceId),
10160
- budgetFrames: this.preRollBudgetFor(config)
10678
+ budgetFrames: this.preRollBudgetFor(config),
10679
+ effectiveBudgetFrames: report.budgetFrames
10161
10680
  }
10162
10681
  });
10163
10682
  },
10683
+ onPreRollSettled: (summary) => {
10684
+ const lost = summary.lostByReason.evicted + summary.lostByReason.cleared + summary.lostByReason["download-failed"] + summary.lostByReason.miss;
10685
+ const meta = {
10686
+ streamId: config.detectionStreamId,
10687
+ ...summary,
10688
+ lost
10689
+ };
10690
+ if (lost > 0 || summary.unsettled > 0) {
10691
+ log.info("session-decode: pre-roll frames settled", {
10692
+ tags: { deviceId: config.deviceId },
10693
+ meta
10694
+ });
10695
+ return;
10696
+ }
10697
+ log.debug("session-decode: pre-roll frames settled", {
10698
+ tags: { deviceId: config.deviceId },
10699
+ meta
10700
+ });
10701
+ },
10164
10702
  onFirstFrame: (timing) => {
10165
10703
  const phaseActiveAt = this.lastMotionAt.get(config.deviceId);
10166
10704
  log.info("session-decode: detection first frame", {
@@ -10204,8 +10742,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10204
10742
  });
10205
10743
  }
10206
10744
  });
10207
- return () => {
10208
- teardown();
10745
+ return (mode) => {
10746
+ teardown(mode);
10209
10747
  };
10210
10748
  }
10211
10749
  /**
@@ -10226,8 +10764,9 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10226
10764
  return startGuardedSessionDecode({
10227
10765
  logger: log,
10228
10766
  admitWorker: this.buildLiveWorkerAdmit(config.deviceId, "motion"),
10767
+ ...this.liveWorkerSlotWaiter(config.deviceId),
10229
10768
  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) => {
10769
+ startPump: (source, hooks) => {
10231
10770
  log.info("session-decode: motion routed through per-session decode worker (gray)", { meta: {
10232
10771
  streamId: config.motionStreamId,
10233
10772
  codec: source.codec
@@ -10235,6 +10774,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10235
10774
  return startSessionDecodePump({
10236
10775
  fork: this.forkDecodeWorkerWithLease,
10237
10776
  lease: this.resolveSessionLeaseKnobs,
10777
+ onWorkerGone: hooks.onWorkerGone,
10238
10778
  logger: log,
10239
10779
  source,
10240
10780
  opts: {
@@ -10902,15 +11442,36 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10902
11442
  * found/report semantics can't drift between the two admission
10903
11443
  * mechanisms.
10904
11444
  */
10905
- finishOccupancyBurst(deviceId, log, collected) {
11445
+ finishOccupancyBurst(deviceId, log, summary) {
10906
11446
  this.occupancyBurstInFlight.delete(deviceId);
10907
11447
  const found = this.occupancyBurstFound.has(deviceId);
10908
11448
  this.occupancyBurstFound.delete(deviceId);
11449
+ if (summary.inferred === 0) {
11450
+ log.info("occupancy re-check: burst complete — nothing inferred, no verdict", {
11451
+ tags: { deviceId },
11452
+ meta: {
11453
+ ...summary,
11454
+ verdict: null
11455
+ }
11456
+ });
11457
+ return;
11458
+ }
10909
11459
  this.runner?.reportOccupancyBurstOutcome(deviceId, found);
10910
- log.debug("occupancy re-check: burst complete", { meta: {
10911
- collected,
11460
+ const meta = {
11461
+ ...summary,
10912
11462
  found
10913
- } });
11463
+ };
11464
+ if (summary.lost > 0) {
11465
+ log.info("occupancy re-check: burst complete", {
11466
+ tags: { deviceId },
11467
+ meta
11468
+ });
11469
+ return;
11470
+ }
11471
+ log.debug("occupancy re-check: burst complete", {
11472
+ tags: { deviceId },
11473
+ meta
11474
+ });
10914
11475
  }
10915
11476
  /**
10916
11477
  * Task 10: arm a TAP-based occupancy burst for a device whose analyzer
@@ -10946,21 +11507,30 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10946
11507
  log.debug("occupancy re-check: tap safety timeout reached", { meta: { collected } });
10947
11508
  this.finishOccupancyTap(deviceId);
10948
11509
  }, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
11510
+ const ledger = new OccupancyBurstLedger({
11511
+ settleBoundMs: OCCUPANCY_BURST_SETTLE_BOUND_MS,
11512
+ onComplete: resolve
11513
+ });
10949
11514
  this.occupancyTaps.set(deviceId, {
10950
11515
  remaining: frames,
10951
11516
  collected: 0,
10952
11517
  safetyTimer,
11518
+ ledger,
10953
11519
  resolve
10954
11520
  });
10955
11521
  });
10956
11522
  }
10957
- /** Idempotent: a no-op once the tap has already settled (timer fired, or the budget already drained). */
11523
+ /**
11524
+ * Close the tap's COLLECTION. Idempotent: a no-op once the tap has already
11525
+ * closed (timer fired, or the budget already drained). The tap's promise
11526
+ * resolves later, from its ledger, once the frames it admitted have settled.
11527
+ */
10958
11528
  finishOccupancyTap(deviceId) {
10959
11529
  const tap = this.occupancyTaps.get(deviceId);
10960
11530
  if (!tap) return;
10961
11531
  clearTimeout(tap.safetyTimer);
10962
11532
  this.occupancyTaps.delete(deviceId);
10963
- tap.resolve(tap.collected);
11533
+ tap.ledger.close();
10964
11534
  }
10965
11535
  /**
10966
11536
  * Tee an arriving analyzer motion frame into occupancy-burst detection
@@ -10975,7 +11545,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10975
11545
  teeOccupancyTapFrame(deviceId, frame, handle) {
10976
11546
  const tap = this.occupancyTaps.get(deviceId);
10977
11547
  if (!tap) return;
10978
- this.runner?.enqueueOccupancyFrame(deviceId, frame, handle);
11548
+ const settle = tap.ledger.admit();
11549
+ if (!this.runner?.enqueueOccupancyFrame(deviceId, frame, handle, settle)) settle("lost");
10979
11550
  tap.collected++;
10980
11551
  tap.remaining--;
10981
11552
  if (tap.remaining <= 0) this.finishOccupancyTap(deviceId);
@@ -10995,9 +11566,9 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
10995
11566
  const lastFrameAt = this.lastMotionFrameAt.get(deviceId);
10996
11567
  const motionFresh = lastFrameAt !== void 0 && Date.now() - lastFrameAt < MOTION_FRESHNESS_WINDOW_MS;
10997
11568
  if (attachment.motionUnsubscribe !== null && motionFresh) {
10998
- const collected = await this.startOccupancyTap(deviceId, frames, log);
10999
- if (collected > 0) {
11000
- this.finishOccupancyBurst(deviceId, log, collected);
11569
+ const summary = await this.startOccupancyTap(deviceId, frames, log);
11570
+ if (summary.frames > 0) {
11571
+ this.finishOccupancyBurst(deviceId, log, summary);
11001
11572
  return;
11002
11573
  }
11003
11574
  log.debug("occupancy re-check: tap collected 0 frames, falling back to dial", { meta: { frames } });
@@ -11028,76 +11599,91 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
11028
11599
  fallback: alreadyInFlight
11029
11600
  } });
11030
11601
  let count = 0;
11602
+ /** Collection is over: no further frame is admitted to this burst. */
11603
+ let admissionClosed = false;
11604
+ /** The burst has reported its verdict (or its lack of one). */
11031
11605
  let settled = false;
11032
11606
  let teardown = null;
11033
- const cleanup = () => {
11607
+ const cleanup = (summary) => {
11034
11608
  if (settled) return;
11035
11609
  settled = true;
11036
- teardown?.();
11037
- this.finishOccupancyBurst(deviceId, log, count);
11610
+ this.finishOccupancyBurst(deviceId, log, summary);
11611
+ };
11612
+ const ledger = new OccupancyBurstLedger({
11613
+ settleBoundMs: OCCUPANCY_BURST_SETTLE_BOUND_MS,
11614
+ onComplete: cleanup
11615
+ });
11616
+ const closeAdmission = () => {
11617
+ if (admissionClosed) return;
11618
+ admissionClosed = true;
11619
+ clearTimeout(safetyTimer);
11620
+ teardown?.("drain");
11621
+ ledger.close();
11622
+ };
11623
+ /** Offer one collected frame to the burst; `true` = the runner admitted it. */
11624
+ const admit = (frame, handle) => {
11625
+ const settle = ledger.admit();
11626
+ const admitted = runner.enqueueOccupancyFrame(deviceId, frame, handle, settle);
11627
+ if (!admitted) settle("lost");
11628
+ count++;
11629
+ if (count >= frames) closeAdmission();
11630
+ return admitted;
11038
11631
  };
11039
11632
  const safetyTimer = setTimeout(() => {
11040
11633
  log.debug("occupancy re-check: safety timeout reached", { meta: { collected: count } });
11041
- cleanup();
11634
+ closeAdmission();
11042
11635
  }, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
11043
11636
  teardown = startGuardedSessionDecode({
11044
11637
  logger: log,
11045
11638
  admitWorker: this.buildLiveWorkerAdmit(deviceId, "occupancy"),
11639
+ ...this.liveWorkerSlotWaiter(deviceId),
11046
11640
  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) => {
11641
+ startPump: (source, hooks) => {
11048
11642
  const useLocalFrameRef = this.executorCoLocated() && process.env["CAMSTACK_DISABLE_LOCAL_FRAME_REF"] !== "1";
11049
11643
  return startSessionDecodePump({
11050
11644
  fork: this.forkDecodeWorkerWithLease,
11051
11645
  lease: this.resolveSessionLeaseKnobs,
11646
+ onWorkerGone: hooks.onWorkerGone,
11052
11647
  logger: log,
11053
11648
  source,
11054
11649
  opts: {
11055
11650
  fps: config.detectionFps,
11056
11651
  format: "rgb"
11057
11652
  },
11653
+ shouldMaterializeFrame: () => !admissionClosed,
11058
11654
  ...useLocalFrameRef ? {
11059
11655
  frameRegistry: require_default_detection_model.localFrameRegistry,
11060
11656
  inlineWhen: () => runner.detectionBackpressure(config.deviceId),
11061
11657
  inlineViewSpec: SPLIT_DETECTION_VIEW,
11062
11658
  onFrameRef: (frame, nativeCrop) => {
11063
- if (settled) return false;
11659
+ if (admissionClosed) return false;
11064
11660
  const handle = writeRetainedDetectionFrame(retainedFrames, config.deviceId, frame, this.retention);
11065
11661
  if (handle && nativeCrop) {
11066
11662
  this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
11067
11663
  this.bindFrameCutter(handle, nativeCrop);
11068
11664
  }
11069
- const admitted = runner.enqueueOccupancyFrame(deviceId, frame, handle);
11665
+ const admitted = admit(frame, handle);
11070
11666
  if (admitted) nativeCrop?.markInferred();
11071
- count++;
11072
- if (count >= frames) {
11073
- clearTimeout(safetyTimer);
11074
- cleanup();
11075
- }
11076
11667
  return admitted;
11077
11668
  }
11078
11669
  } : { inlineViewSpec: SPLIT_DETECTION_VIEW },
11079
11670
  acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "occupancy"),
11080
- onEnded: () => cleanup(),
11671
+ onEnded: () => closeAdmission(),
11081
11672
  onDecodedFrame: (frame, nativeCrop) => {
11082
- if (settled) return;
11673
+ if (admissionClosed) return;
11083
11674
  const handle = writeRetainedDetectionFrame(retainedFrames, config.deviceId, frame, this.retention);
11084
11675
  if (handle && nativeCrop) {
11085
11676
  this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
11086
11677
  this.bindFrameCutter(handle, nativeCrop);
11087
11678
  }
11088
- if (runner.enqueueOccupancyFrame(deviceId, frame, handle)) nativeCrop?.markInferred();
11089
- count++;
11090
- if (count >= frames) {
11091
- clearTimeout(safetyTimer);
11092
- cleanup();
11093
- }
11679
+ if (admit(frame, handle)) nativeCrop?.markInferred();
11094
11680
  }
11095
11681
  });
11096
11682
  }
11097
11683
  });
11098
- if (settled) {
11684
+ if (admissionClosed) {
11099
11685
  clearTimeout(safetyTimer);
11100
- teardown();
11686
+ teardown("drain");
11101
11687
  }
11102
11688
  }
11103
11689
  emitInferenceResult(deviceId, frame, result, handle, source, occasion) {