@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
@@ -1,7 +1,7 @@
1
- import { An as lazy, D as MOTION_CLOSE_AFTER_MS, Dn as array, En as _enum, In as string, Mn as number, Mt as pickNativeLeaseOverride, Nn as object, O as NativeLeaseAdmissionSchema, On as boolean, Pt as pipelineRunnerCapability, Rn as EventCategory, en as errMsg, h as DEFAULT_DETAIL_CROP_CONVENTION, hn as defineCustomActions, i as BackgroundRunRegistry, jn as literal, jt as pickDetailCropConvention, k as NativeLeaseSettingsSchema, mn as customAction, nt as deriveDetailCropRect, pn as createEvent, pt as failureContributionCapability, sn as BaseAddon, v as DEFAULT_NATIVE_LEASE_SETTINGS, vt as loadContributionCapability, w as FailureCounters, xn as nodePin, y as DEVICE_BACKEND_TO_FORMAT } from "../dist-CuxSNLKW.mjs";
2
- import { i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-Cqb1QiXk.mjs";
3
- import { a as localFrameRegistry, d as getStepDefinition, i as rootObjectGate, o as resolveFrameViewGeometry, p as RETIRED_MODEL_IDS, r as macroGateVerdict } from "../default-detection-model-CAUBVbgK.mjs";
4
- import { t as isWorkerReply } from "../worker-protocol-C_W-P_g-.mjs";
1
+ import { D as MOTION_CLOSE_AFTER_MS, Dn as _enum, Ft as pipelineRunnerCapability, Ln as string, Mn as literal, Nn as number, Nt as pickNativeLeaseOverride, O as NativeLeaseAdmissionSchema, On as array, Pn as object, Sn as nodePin, cn as BaseAddon, gn as defineCustomActions, h as DEFAULT_DETAIL_CROP_CONVENTION, hn as customAction, i as BackgroundRunRegistry, jn as lazy, jt as pickDetailCropConvention, k as NativeLeaseSettingsSchema, kn as boolean, mn as createEvent, nt as deriveDetailCropRect, pt as failureContributionCapability, tn as errMsg, v as DEFAULT_NATIVE_LEASE_SETTINGS, vt as loadContributionCapability, w as FailureCounters, y as DEVICE_BACKEND_TO_FORMAT, zn as EventCategory } from "../dist-CCd0Q3nr.mjs";
2
+ import { i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-DgMSXSWP.mjs";
3
+ import { a as localFrameRegistry, d as getStepDefinition, i as rootObjectGate, o as resolveFrameViewGeometry, p as RETIRED_MODEL_IDS, r as macroGateVerdict } from "../default-detection-model-Co578D8C.mjs";
4
+ import { t as isWorkerReply } from "../worker-protocol-D_NzPcnh.mjs";
5
5
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
6
6
  import { t as getSharp } from "../lazy-sharp-DXsqwpph.mjs";
7
7
  import { c as withDetectionIntent, o as preRollRequestOf, t as DETECTION_PRE_ROLL_REQUEST_MS } from "../restream-intent-DyGmLAU9.mjs";
@@ -14,7 +14,7 @@ import { PerformanceObserver } from "node:perf_hooks";
14
14
  import { attributeStall } from "@camstack/system/addon-utils";
15
15
  //#region src/session-decode/fork-decode-worker.ts
16
16
  /** Absolute path to the built decode-worker child entry (a vite build entry). */
17
- var CHILD_ENTRY_PATH = fileURLToPath(new URL("../session-decode/decode-worker-child.js", import.meta.url));
17
+ var CHILD_ENTRY_PATH = fileURLToPath(new URL("../session-decode/decode-worker-main.js", import.meta.url));
18
18
  /**
19
19
  * Known kill signals the coordinator may pass, mapped to their `NodeJS.Signals`
20
20
  * literal so `ChildProcess.kill` receives a correctly-typed signal without a
@@ -35,21 +35,36 @@ var KILL_SIGNALS = {
35
35
  * every listener added out here is one somebody can forget to remove.
36
36
  */
37
37
  function wrapChild(child, onExit) {
38
- child.on("error", () => void 0);
39
- child.on("exit", onExit);
38
+ let spawned = false;
39
+ let exitDelivered = false;
40
+ const exitListeners = [];
41
+ const deliverExit = (info) => {
42
+ if (exitDelivered) return;
43
+ exitDelivered = true;
44
+ onExit();
45
+ for (const listener of exitListeners) listener(info);
46
+ };
47
+ child.on("spawn", () => {
48
+ spawned = true;
49
+ });
50
+ child.on("exit", (code, signal) => deliverExit({
51
+ code: code ?? null,
52
+ signal: signal ?? null
53
+ }));
54
+ child.on("error", () => {
55
+ if (!spawned) deliverExit({
56
+ code: null,
57
+ signal: null
58
+ });
59
+ });
40
60
  return {
41
61
  send: (msg) => {
42
62
  if (!child.connected) return;
43
63
  child.send(msg, () => void 0);
44
64
  },
45
65
  on: (ev, cb) => {
46
- if (ev === "exit") {
47
- const toInfo = (code, signal) => ({
48
- code,
49
- signal
50
- });
51
- child.on("exit", (code, signal) => cb(toInfo(code ?? null, signal ?? null)));
52
- } else child.on("message", (m) => cb(m));
66
+ if (ev === "exit") exitListeners.push(cb);
67
+ else child.on("message", (m) => cb(m));
53
68
  },
54
69
  kill: (signal) => {
55
70
  if (signal === void 0) {
@@ -250,58 +265,77 @@ var DEFAULT_ACQUIRE_RETRY_DELAYS_MS = [
250
265
  15e3,
251
266
  3e4
252
267
  ];
253
- /**
254
- * Guards the in-flight restream-acquire against a detach/unsubscribe race,
255
- * and retries an acquire that fails.
256
- *
257
- * `acquireSessionDecodeRestream` + forking the decode worker both take real
258
- * time (a broker round-trip, then a process fork). If the caller's teardown
259
- * runs while that acquire is still in flight, the closure that would release
260
- * the restream and stop the pump does not exist yet by the time the caller
261
- * stores it — a naive `await acquire(); return () => {...}` construction
262
- * leaks the acquired restream refcount and, once the acquire resolves, forks
263
- * an orphan decode worker nobody will ever stop.
264
- *
265
- * This helper returns the teardown SYNCHRONOUSLY. The acquire + pump-start
266
- * run in the background; an `aborted` flag recorded by the (possibly
267
- * already-called) teardown is checked right after each acquire resolves —
268
- * if set, any restream is released immediately and the pump is NEVER
269
- * started (no orphan worker). Otherwise the pump starts and the teardown,
270
- * once called, stops the pump then releases the restream.
271
- *
272
- * **A failed acquire is retried, not swallowed.** The teardown handed back
273
- * here is stored by the caller (as `attachment.detectionUnsubscribe`) the
274
- * instant this returns, so a `return` on failure left that handle permanently
275
- * non-null with no pump behind it: `onEnded` could never fire because nothing
276
- * had ever started, the camera's phase stayed `active`, and not one frame
277
- * arrived again. The motion path was hardened against this shape by a frame
278
- * freshness window (`MOTION_FRESHNESS_WINDOW_MS`); this is the detection
279
- * path's half.
280
- */
268
+ /** A restream release that runs once however many paths reach it. */
269
+ function onceRelease(release) {
270
+ let done = false;
271
+ return () => {
272
+ if (done) return Promise.resolve();
273
+ done = true;
274
+ return release();
275
+ };
276
+ }
281
277
  function startGuardedSessionDecode(deps) {
282
278
  let aborted = false;
279
+ /** The mode the teardown asked for — replayed if it raced `startPump`. */
280
+ let abortMode = "drain";
283
281
  let stopPump = null;
284
282
  let release = null;
285
283
  let retryTimer = null;
286
284
  /** The live-worker slot held by the RUNNING session, released on teardown. */
287
285
  let workerLease = null;
288
286
  const delays = deps.acquireRetryDelaysMs && deps.acquireRetryDelaysMs.length > 0 ? deps.acquireRetryDelaysMs : DEFAULT_ACQUIRE_RETRY_DELAYS_MS;
289
- /** Resolves after the backoff for `attempt`, or immediately once aborted. */
290
- const waitBackoff = (attempt) => new Promise((resolve) => {
287
+ /** Ends a parked start's wait when the session is torn down. */
288
+ let abortWait = null;
289
+ /**
290
+ * This start's place in the ceiling queue — taken on its first refusal, kept
291
+ * to the end so a re-park keeps the FIFO position (D652).
292
+ */
293
+ let waiter = null;
294
+ /**
295
+ * The start was handed a returned slot and has not yet ASKED for it. Set on
296
+ * a wake, cleared by the next `admitWorker` call; a start torn down while
297
+ * this is set passes the slot on (`SlotWaiter.leave(true)`), or nobody gets
298
+ * it and every other parked start waits out its full timer.
299
+ */
300
+ let wakeInHand = false;
301
+ /**
302
+ * Resolves after the backoff for `attempt` (`'timer'`) — or, when `parked`,
303
+ * as soon as the node hands this start a returned slot (`'woken'`) or the
304
+ * session is torn down (`'torn-down'`), whichever is first. The start leaves
305
+ * the queue on every exit, so a torn-down start leaves no listener behind.
306
+ */
307
+ const waitBackoff = (attempt, parked) => new Promise((resolve) => {
291
308
  const ms = delays[Math.min(attempt, delays.length - 1)] ?? delays[delays.length - 1] ?? 2e3;
292
- retryTimer = setTimeout(() => {
293
- retryTimer = null;
294
- resolve();
295
- }, ms);
309
+ const done = (why) => {
310
+ if (retryTimer !== null) {
311
+ clearTimeout(retryTimer);
312
+ retryTimer = null;
313
+ }
314
+ parked?.cancel();
315
+ abortWait = null;
316
+ resolve(why);
317
+ };
318
+ retryTimer = setTimeout(() => done("timer"), ms);
296
319
  retryTimer.unref?.();
320
+ abortWait = () => done("torn-down");
321
+ if (parked) parked.wait(() => done("woken"));
297
322
  });
298
- (async () => {
323
+ const releaseWorkerSlot = () => {
324
+ workerLease?.release();
325
+ workerLease = null;
326
+ };
327
+ const startLoop = async () => {
299
328
  for (let attempt = 0; !aborted; attempt++) {
300
329
  let lease = null;
301
330
  if (deps.admitWorker) {
331
+ wakeInHand = false;
302
332
  lease = deps.admitWorker();
303
333
  if (!lease) {
304
- await waitBackoff(attempt);
334
+ waiter ??= deps.slotWaiter?.() ?? null;
335
+ if (await waitBackoff(attempt, waiter) === "woken") {
336
+ attempt -= 1;
337
+ wakeInHand = true;
338
+ }
305
339
  continue;
306
340
  }
307
341
  }
@@ -314,7 +348,7 @@ function startGuardedSessionDecode(deps) {
314
348
  attempt: attempt + 1,
315
349
  error: errMsg(err)
316
350
  } });
317
- await waitBackoff(attempt);
351
+ await waitBackoff(attempt, null);
318
352
  continue;
319
353
  }
320
354
  if (aborted) {
@@ -325,27 +359,47 @@ function startGuardedSessionDecode(deps) {
325
359
  if (!acquired) {
326
360
  lease?.release();
327
361
  (attempt === 0 ? deps.logger.warn : deps.logger.debug).call(deps.logger, "session-decode: no broker profile bound yet — retrying", { meta: { attempt: attempt + 1 } });
328
- await waitBackoff(attempt);
362
+ await waitBackoff(attempt, null);
329
363
  continue;
330
364
  }
331
365
  if (attempt > 0) deps.logger.info("session-decode: acquire succeeded after retry", { meta: { attempts: attempt + 1 } });
332
- release = acquired.release;
366
+ release = onceRelease(acquired.release);
333
367
  workerLease = lease;
334
- stopPump = deps.startPump(acquired.source);
368
+ try {
369
+ stopPump = deps.startPump(acquired.source, { onWorkerGone: releaseWorkerSlot });
370
+ } catch (err) {
371
+ releaseWorkerSlot();
372
+ const restream = release;
373
+ release = null;
374
+ restream?.();
375
+ throw err;
376
+ }
377
+ if (aborted) stopPump(abortMode);
335
378
  return;
336
379
  }
337
- })().catch((err) => {
380
+ };
381
+ startLoop().catch((err) => {
338
382
  deps.logger.warn("session-decode: guarded start failed", { meta: { error: errMsg(err) } });
383
+ }).finally(() => {
384
+ waiter?.leave(wakeInHand);
385
+ waiter = null;
339
386
  });
340
- return () => {
387
+ return (mode = "drain") => {
388
+ if (aborted) {
389
+ if (mode === "immediate" && abortMode !== "immediate") {
390
+ abortMode = mode;
391
+ stopPump?.(mode);
392
+ }
393
+ return;
394
+ }
341
395
  aborted = true;
396
+ abortMode = mode;
342
397
  if (retryTimer !== null) {
343
398
  clearTimeout(retryTimer);
344
399
  retryTimer = null;
345
400
  }
346
- stopPump?.();
347
- workerLease?.release();
348
- workerLease = null;
401
+ abortWait?.();
402
+ stopPump?.(mode);
349
403
  if (release) release();
350
404
  };
351
405
  }
@@ -414,6 +468,21 @@ var LiveDecodeWorkerAdmission = class {
414
468
  limit;
415
469
  logger;
416
470
  live = 0;
471
+ /**
472
+ * Starts the ceiling refused, waiting to be told a slot came back — FIFO by
473
+ * the start's FIRST park (D652): the key's `seq` is fixed when the start
474
+ * takes its {@link SlotWaiter}, so a re-park keeps the place.
475
+ */
476
+ parked = /* @__PURE__ */ new Map();
477
+ nextSeq = 0;
478
+ /**
479
+ * Cameras currently refused. The refusal WARN fires on the transition into
480
+ * this set, not on every retry: a start woken or retried while the node is
481
+ * still full says so at debug (D652 — a wake must not become a WARN storm).
482
+ * Cleared on admit AND when a parked start leaves (`SlotWaiter.leave`): a
483
+ * camera torn down while refused must be news again next time (D391).
484
+ */
485
+ refusedDevices = /* @__PURE__ */ new Set();
417
486
  nodeBudgetMb;
418
487
  workerFootprintMb;
419
488
  constructor(options) {
@@ -422,6 +491,48 @@ var LiveDecodeWorkerAdmission = class {
422
491
  this.nodeBudgetMb = positiveOrUndefined(options.nodeBudgetMb);
423
492
  this.workerFootprintMb = positiveOrUndefined(options.workerFootprintMb);
424
493
  }
494
+ /** Starts parked on a {@link SlotWaiter} right now — diagnostics and tests. */
495
+ get slotWaiters() {
496
+ return this.parked.size;
497
+ }
498
+ /**
499
+ * This start's place in the queue. A refused start waits on it instead of a
500
+ * fixed timer, so a slot freed by a draining worker's exit is taken at once
501
+ * (D652). Take it once per start, on the first refusal; keep it to the end.
502
+ */
503
+ slotWaiter(deviceId) {
504
+ const key = {
505
+ seq: this.nextSeq,
506
+ deviceId
507
+ };
508
+ this.nextSeq += 1;
509
+ return {
510
+ wait: (listener) => {
511
+ this.parked.set(key, listener);
512
+ },
513
+ cancel: () => {
514
+ this.parked.delete(key);
515
+ },
516
+ leave: (unusedWake) => {
517
+ this.parked.delete(key);
518
+ this.refusedDevices.delete(deviceId);
519
+ if (unusedWake) this.wakeNext();
520
+ }
521
+ };
522
+ }
523
+ /**
524
+ * ONE slot, ONE wake: the longest-parked start (lowest `seq`). Waking all N
525
+ * produced N−1 refusals per freed slot. The woken start leaves the queue; if
526
+ * something else took the slot first it parks again, at the same place.
527
+ */
528
+ wakeNext() {
529
+ let first;
530
+ for (const key of this.parked.keys()) if (first === void 0 || key.seq < first.seq) first = key;
531
+ if (first === void 0) return;
532
+ const wake = this.parked.get(first);
533
+ this.parked.delete(first);
534
+ wake?.();
535
+ }
425
536
  /** Workers currently holding a slot. */
426
537
  get inFlight() {
427
538
  return this.live;
@@ -478,7 +589,9 @@ var LiveDecodeWorkerAdmission = class {
478
589
  admit(deviceId, label) {
479
590
  const ceiling = this.ceiling;
480
591
  if (this.live >= ceiling) {
481
- this.logger.warn("session-decode: REFUSED — node live decode-worker ceiling reached, will retry", {
592
+ const firstRefusal = !this.refusedDevices.has(deviceId);
593
+ this.refusedDevices.add(deviceId);
594
+ (firstRefusal ? this.logger.warn : this.logger.debug).call(this.logger, "session-decode: REFUSED — node live decode-worker ceiling reached, will retry", {
482
595
  tags: { deviceId },
483
596
  meta: {
484
597
  label,
@@ -494,6 +607,7 @@ var LiveDecodeWorkerAdmission = class {
494
607
  return null;
495
608
  }
496
609
  this.live += 1;
610
+ this.refusedDevices.delete(deviceId);
497
611
  this.logger.debug("session-decode: live decode-worker slot taken", {
498
612
  tags: { deviceId },
499
613
  meta: {
@@ -507,6 +621,7 @@ var LiveDecodeWorkerAdmission = class {
507
621
  if (released) return;
508
622
  released = true;
509
623
  this.live -= 1;
624
+ this.wakeNext();
510
625
  } };
511
626
  }
512
627
  };
@@ -1443,6 +1558,29 @@ function cropRgb(frame, bbox, maxWidth) {
1443
1558
  /** How long to wait after `kill()` before escalating to `kill('SIGKILL')`. */
1444
1559
  var SIGKILL_FALLBACK_MS = 1e3;
1445
1560
  /**
1561
+ * The longest a planned teardown keeps a session's worker alive so the frames
1562
+ * it already handed out can be read (D652).
1563
+ *
1564
+ * What is being waited for is the REST of a frame's trip through the runner —
1565
+ * semaphore wait + inference + result — for frames that were admitted before
1566
+ * the detach. Measured on the live hub 2026-09-26 over one hour of
1567
+ * `pipeline stats` (2 254 per-camera 10 s windows): the WORST enqueue→emit in a
1568
+ * window was ≤1 000 ms in 96.7% of windows and ≤1 500 ms in 98.0%; going to
1569
+ * 5 000 ms buys only another 0.9%, and that tail is the inference-pool GPU hang,
1570
+ * which no wait here can rescue. The cost of waiting is one idle worker (its
1571
+ * decode already stopped by `drain`) for at most this long, per detach.
1572
+ *
1573
+ * A frame still held when this expires is not dropped silently: the
1574
+ * coordinator logs the count, tagged with the camera, and then kills.
1575
+ */
1576
+ var SESSION_DECODE_DRAIN_BOUND_MS = 1500;
1577
+ /**
1578
+ * The miss reason for a native crop requested after its session's teardown.
1579
+ * Only crops already SENT when the drain began are waited for (D652); a later
1580
+ * one would ask a worker that is about to be killed.
1581
+ */
1582
+ var NATIVE_CROP_REFUSED_DRAINING = "session draining — crop requested after teardown";
1583
+ /**
1446
1584
  * How often the cold-start wait REPORTS that it is still waiting. Task 0
1447
1585
  * live-measured cold-start (restream dial + hwaccel context + keyframe wait) at
1448
1586
  * 5-8s, so this must tolerate a slow-but-healthy start.
@@ -1514,30 +1652,38 @@ var SessionDecodeSession = class {
1514
1652
  firstFrameTimeoutMs;
1515
1653
  frameTimeoutMs;
1516
1654
  firstFrameMaxWaitMs;
1517
- onDialTiming;
1518
- onPreRollReport;
1655
+ hooks;
1519
1656
  pendingPull = null;
1520
1657
  pendingToBuffers = /* @__PURE__ */ new Map();
1521
1658
  /** Outstanding `nativeCrop` requests, keyed by the monotonic `requestId`. */
1522
1659
  pendingNativeCrops = /* @__PURE__ */ new Map();
1523
1660
  nextNativeCropId = 1;
1524
1661
  exited = false;
1525
- /** Set by teardown() so handleExit can tell an intentional stop from a death. */
1662
+ /** Set by teardown() — the session is ending, whether or not it drains first. */
1526
1663
  stopRequested = false;
1664
+ /** Set when `kill()` is sent, so handleExit can tell an intentional stop from a death. */
1665
+ killSent = false;
1666
+ /**
1667
+ * Images yielded to the consumer and not yet closed. Each is a frame the
1668
+ * consumer still holds a reference to — queued, in flight, or being read —
1669
+ * and each dies with the worker. This is what a drain waits on.
1670
+ */
1671
+ openImages = 0;
1672
+ /** Armed while a drain waits; its expiry kills with frames still held. */
1673
+ drainTimer = null;
1527
1674
  sigkillTimer = null;
1528
1675
  pullTimeoutTimer = null;
1529
1676
  firstFrameReceived = false;
1530
1677
  /** `Date.now()` of the FIRST `pull` — the origin of the cold-start ceiling. */
1531
1678
  firstPullAt = 0;
1532
- constructor(child, logger, deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs, onDialTiming, onPreRollReport) {
1679
+ constructor(child, logger, deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs, hooks) {
1533
1680
  this.child = child;
1534
1681
  this.logger = logger;
1535
1682
  this.deviceId = deviceId;
1536
1683
  this.firstFrameTimeoutMs = firstFrameTimeoutMs;
1537
1684
  this.frameTimeoutMs = frameTimeoutMs;
1538
1685
  this.firstFrameMaxWaitMs = firstFrameMaxWaitMs;
1539
- this.onDialTiming = onDialTiming;
1540
- this.onPreRollReport = onPreRollReport;
1686
+ this.hooks = hooks;
1541
1687
  this.child.on("message", (m) => this.handleMessage(m));
1542
1688
  this.child.on("exit", (m) => this.handleExit(toWorkerExitInfo(m)));
1543
1689
  }
@@ -1602,6 +1748,10 @@ var SessionDecodeSession = class {
1602
1748
  */
1603
1749
  requestNativeCrop(frameId, bbox, maxWidth) {
1604
1750
  if (this.exited) return Promise.resolve({ kind: "miss" });
1751
+ if (this.stopRequested) return Promise.resolve({
1752
+ kind: "miss",
1753
+ workerReason: NATIVE_CROP_REFUSED_DRAINING
1754
+ });
1605
1755
  const requestId = this.nextNativeCropId++;
1606
1756
  return new Promise((resolve) => {
1607
1757
  this.pendingNativeCrops.set(requestId, { resolve });
@@ -1616,6 +1766,7 @@ var SessionDecodeSession = class {
1616
1766
  } catch {
1617
1767
  this.pendingNativeCrops.delete(requestId);
1618
1768
  resolve({ kind: "miss" });
1769
+ this.settleDrainIfDone();
1619
1770
  }
1620
1771
  });
1621
1772
  }
@@ -1660,20 +1811,135 @@ var SessionDecodeSession = class {
1660
1811
  frameId
1661
1812
  });
1662
1813
  }
1663
- /** Teardown: `stop`, `kill()`, arm the SIGKILL fallback unless already exited. */
1664
- teardown() {
1814
+ /** A yielded image the consumer now holds — see {@link openImages}. */
1815
+ noteImageOpened() {
1816
+ this.openImages += 1;
1817
+ }
1818
+ /** The consumer let go of a yielded image; a waiting drain may now be done. */
1819
+ noteImageClosed() {
1820
+ this.openImages = Math.max(0, this.openImages - 1);
1821
+ this.settleDrainIfDone();
1822
+ }
1823
+ /**
1824
+ * End the session.
1825
+ *
1826
+ * `'drain'` with frames still held: tell the worker to stop decoding (it
1827
+ * keeps every frame it holds), and kill only once every yielded image has
1828
+ * been closed and every read answered — or when
1829
+ * {@link SESSION_DECODE_DRAIN_BOUND_MS} expires, which is logged. Nothing
1830
+ * held: `stop` + SIGTERM now. `'immediate'`: SIGKILL now.
1831
+ *
1832
+ * A SECOND call that says `'immediate'` while a drain is in progress
1833
+ * ESCALATES (D652): addon shutdown lands on sessions already draining from
1834
+ * a detach, and the runner exits right after — a drain it does not wait for
1835
+ * is an orphaned worker holding its GPU pool. Any other repeat is ignored.
1836
+ */
1837
+ teardown(mode) {
1838
+ if (this.stopRequested) {
1839
+ if (mode === "immediate") this.escalateToImmediate();
1840
+ return;
1841
+ }
1665
1842
  this.stopRequested = true;
1666
1843
  this.clearPullTimeout();
1844
+ if (this.exited) return;
1845
+ if (mode === "drain" && this.unsettledCount() > 0) {
1846
+ this.trySend({ kind: "drain" });
1847
+ this.drainTimer = setTimeout(() => this.handleDrainBoundExpired(), SESSION_DECODE_DRAIN_BOUND_MS);
1848
+ this.drainTimer.unref?.();
1849
+ return;
1850
+ }
1851
+ this.killNow(mode);
1852
+ }
1853
+ /** The drain (or a SIGTERM still waiting on its fallback) gives way to SIGKILL now. */
1854
+ escalateToImmediate() {
1855
+ if (this.exited) return;
1856
+ if (this.drainTimer !== null) {
1857
+ clearTimeout(this.drainTimer);
1858
+ this.drainTimer = null;
1859
+ }
1860
+ if (!this.killSent) {
1861
+ this.killNow("immediate");
1862
+ return;
1863
+ }
1864
+ if (this.sigkillTimer !== null) {
1865
+ clearTimeout(this.sigkillTimer);
1866
+ this.sigkillTimer = null;
1867
+ }
1868
+ this.tryKill("SIGKILL");
1869
+ }
1870
+ /**
1871
+ * Frames the consumer still holds, reads still waiting on the worker, and
1872
+ * native crops already REQUESTED. A crop asked for before the teardown
1873
+ * request is waited for; one asked for after it is refused by name — post-analysis's
1874
+ * ordinary crop arrives 2.2–9.9 s after the frame, past any bound worth
1875
+ * holding a worker for, and is served from subject tiles that die with the
1876
+ * worker either way.
1877
+ */
1878
+ unsettledCount() {
1879
+ return this.openImages + this.pendingReadCount() + this.pendingNativeCrops.size;
1880
+ }
1881
+ pendingReadCount() {
1882
+ let n = 0;
1883
+ for (const waiters of this.pendingToBuffers.values()) n += waiters.length;
1884
+ return n;
1885
+ }
1886
+ settleDrainIfDone() {
1887
+ if (this.drainTimer === null || this.unsettledCount() > 0) return;
1888
+ clearTimeout(this.drainTimer);
1889
+ this.drainTimer = null;
1890
+ this.killNow("drain");
1891
+ }
1892
+ /**
1893
+ * The drain ran out of time with frames still held. Those frames die with
1894
+ * the worker; that is a branch that DROPS work (D391), so it says how many,
1895
+ * on which camera.
1896
+ */
1897
+ handleDrainBoundExpired() {
1898
+ this.drainTimer = null;
1899
+ if (this.exited) return;
1900
+ this.logger.warn("session-decode-coordinator: drain bound expired — killing the worker with frames still held", {
1901
+ tags: { deviceId: this.deviceId },
1902
+ meta: {
1903
+ unsettledFrames: this.openImages,
1904
+ pendingReads: this.pendingReadCount(),
1905
+ pendingCrops: this.pendingNativeCrops.size,
1906
+ boundMs: SESSION_DECODE_DRAIN_BOUND_MS
1907
+ }
1908
+ });
1909
+ this.killNow("drain");
1910
+ }
1911
+ /**
1912
+ * `stop`, then the kill — unless already exited.
1913
+ *
1914
+ * `'drain'`: SIGTERM, so the child flushes its last lines (`bounded-exit.ts`),
1915
+ * and a SIGKILL fallback. `'immediate'`: SIGKILL outright. Since D652 gave the
1916
+ * child a SIGTERM handler, SIGTERM is no longer instant death: a worker
1917
+ * blocked in a native call (a VAAPI hang) never runs the handler, and the
1918
+ * fallback was an `unref`'d timer the exiting parent did not wait for — so
1919
+ * on shutdown that worker was orphaned with its GPU pool. The fallback timer
1920
+ * is now ref'd: it exists only for a drain while the parent is alive (a
1921
+ * shutdown escalates to SIGKILL and clears it), and a timer that keeps the
1922
+ * process up for at most one second is the price of a kill that lands.
1923
+ */
1924
+ killNow(mode) {
1925
+ if (this.killSent) return;
1926
+ this.killSent = true;
1667
1927
  this.trySend({ kind: "stop" });
1928
+ if (mode === "immediate") {
1929
+ this.tryKill("SIGKILL");
1930
+ return;
1931
+ }
1668
1932
  this.tryKill(void 0);
1669
1933
  if (this.exited) return;
1670
1934
  this.sigkillTimer = setTimeout(() => {
1671
1935
  this.sigkillTimer = null;
1672
1936
  if (this.exited) return;
1673
- this.logger.warn("session-decode-coordinator: child did not exit within SIGKILL fallback window — forcing", { meta: { fallbackMs: SIGKILL_FALLBACK_MS } });
1937
+ this.logger.warn("session-decode-coordinator: child did not exit within SIGKILL fallback window — forcing", {
1938
+ tags: { deviceId: this.deviceId },
1939
+ meta: { fallbackMs: SIGKILL_FALLBACK_MS }
1940
+ });
1674
1941
  this.tryKill("SIGKILL");
1675
1942
  }, SIGKILL_FALLBACK_MS);
1676
- this.sigkillTimer.unref?.();
1677
1943
  }
1678
1944
  handleMessage(raw) {
1679
1945
  if (!isWorkerReply(raw)) {
@@ -1722,7 +1988,7 @@ var SessionDecodeSession = class {
1722
1988
  this.logger[raw.level](raw.message);
1723
1989
  return;
1724
1990
  case "dialTiming":
1725
- this.onDialTiming?.({
1991
+ this.hooks.onDialTiming?.({
1726
1992
  hwContextMs: raw.hwContextMs,
1727
1993
  demuxerOpenMs: raw.demuxerOpenMs,
1728
1994
  decoderCreateMs: raw.decoderCreateMs,
@@ -1730,25 +1996,46 @@ var SessionDecodeSession = class {
1730
1996
  streamInfoProbed: raw.streamInfoProbed
1731
1997
  });
1732
1998
  return;
1999
+ case "preRollSettled":
2000
+ this.hooks.onPreRollSettled?.({
2001
+ read: raw.read,
2002
+ released: raw.released,
2003
+ lostByReason: raw.lostByReason,
2004
+ unsettled: raw.unsettled
2005
+ });
2006
+ return;
1733
2007
  case "preRollReport":
1734
- this.onPreRollReport?.({
2008
+ this.hooks.onPreRollReport?.({
1735
2009
  offered: raw.offered,
1736
2010
  inferred: raw.inferred,
1737
2011
  dropped: raw.dropped,
1738
2012
  droppedByReason: raw.droppedByReason,
1739
2013
  strideMs: raw.strideMs,
1740
2014
  subsampled: raw.subsampled,
1741
- spanMs: raw.spanMs
2015
+ spanMs: raw.spanMs,
2016
+ budgetFrames: raw.budgetFrames
1742
2017
  });
1743
2018
  return;
1744
2019
  }
1745
2020
  }
1746
2021
  handleExit(info) {
1747
- if (!this.stopRequested && !this.exited) this.logger.warn("session-decode worker exited unexpectedly", { meta: {
1748
- code: info?.code ?? null,
1749
- signal: info?.signal ?? null
1750
- } });
2022
+ if (!this.exited) this.hooks.onWorkerExit?.();
2023
+ if (!this.killSent && !this.exited) this.logger.warn(this.stopRequested ? "session-decode worker exited unexpectedly while draining" : "session-decode worker exited unexpectedly", {
2024
+ tags: { deviceId: this.deviceId },
2025
+ meta: {
2026
+ code: info?.code ?? null,
2027
+ signal: info?.signal ?? null,
2028
+ ...this.stopRequested ? {
2029
+ unsettledFrames: this.openImages,
2030
+ pendingReads: this.pendingReadCount()
2031
+ } : {}
2032
+ }
2033
+ });
1751
2034
  this.exited = true;
2035
+ if (this.drainTimer) {
2036
+ clearTimeout(this.drainTimer);
2037
+ this.drainTimer = null;
2038
+ }
1752
2039
  if (this.sigkillTimer) {
1753
2040
  clearTimeout(this.sigkillTimer);
1754
2041
  this.sigkillTimer = null;
@@ -1770,6 +2057,7 @@ var SessionDecodeSession = class {
1770
2057
  if (!waiter) return;
1771
2058
  this.pendingNativeCrops.delete(requestId);
1772
2059
  waiter.resolve(attempt);
2060
+ this.settleDrainIfDone();
1773
2061
  }
1774
2062
  settlePendingPull(outcome) {
1775
2063
  const waiter = this.pendingPull;
@@ -1865,6 +2153,7 @@ var SessionDecodeSession = class {
1865
2153
  const waiter = queue.shift();
1866
2154
  if (queue.length === 0) this.pendingToBuffers.delete(frameId);
1867
2155
  waiter?.resolve(bytes);
2156
+ this.settleDrainIfDone();
1868
2157
  }
1869
2158
  /**
1870
2159
  * `error` replies MAY carry a `frameId` (a `toBuffer`-scoped miss, e.g. a
@@ -1884,6 +2173,7 @@ var SessionDecodeSession = class {
1884
2173
  if (queue && queue.length > 0) {
1885
2174
  for (const waiter of queue) waiter.reject(err);
1886
2175
  this.pendingToBuffers.delete(frameId);
2176
+ this.settleDrainIfDone();
1887
2177
  return;
1888
2178
  }
1889
2179
  this.logger.debug("session-decode-coordinator: dropping toBuffer error for unknown frameId", { meta: { frameId } });
@@ -1898,6 +2188,7 @@ var SessionDecodeSession = class {
1898
2188
  }
1899
2189
  for (const waiters of this.pendingToBuffers.values()) for (const waiter of waiters) waiter.reject(err);
1900
2190
  this.pendingToBuffers.clear();
2191
+ this.settleDrainIfDone();
1901
2192
  }
1902
2193
  queueToBufferWaiter(frameId, waiter) {
1903
2194
  const queue = this.pendingToBuffers.get(frameId);
@@ -1935,6 +2226,7 @@ var SessionDecodeSession = class {
1935
2226
  /** Build the {@link FrameImage} adapter for one `frame` reply. */
1936
2227
  function buildFrameImage(session, reply) {
1937
2228
  let closed = false;
2229
+ session.noteImageOpened();
1938
2230
  return {
1939
2231
  width: reply.width,
1940
2232
  height: reply.height,
@@ -1949,7 +2241,9 @@ function buildFrameImage(session, reply) {
1949
2241
  releaseNativeLease: () => session.releaseLease(reply.frameId),
1950
2242
  markInferred: () => session.markInferred(reply.frameId),
1951
2243
  close: () => {
2244
+ if (closed) return;
1952
2245
  closed = true;
2246
+ session.noteImageClosed();
1953
2247
  }
1954
2248
  };
1955
2249
  }
@@ -1958,15 +2252,23 @@ function buildFrameImage(session, reply) {
1958
2252
  * `generateVideoFrames()` call, drives it with `pull`/`toBuffer` requests
1959
2253
  * one at a time (no pre-fetch — back-pressure is the async generator's
1960
2254
  * natural suspend-on-yield), and guarantees the child is torn down
1961
- * (`stop` + `kill()` + SIGKILL fallback) in every exit path via `finally`.
2255
+ * (bounded drain, then `stop` + `kill()` + SIGKILL fallback) in every exit
2256
+ * path via `finally`.
1962
2257
  */
1963
2258
  function createSessionDecodeCoordinator(deps) {
1964
2259
  const firstFrameTimeoutMs = deps.firstFrameTimeoutMs ?? DEFAULT_FIRST_FRAME_TIMEOUT_MS;
1965
2260
  const frameTimeoutMs = deps.frameTimeoutMs ?? DEFAULT_FRAME_TIMEOUT_MS;
1966
2261
  const firstFrameMaxWaitMs = Math.max(deps.firstFrameMaxWaitMs ?? DEFAULT_FIRST_FRAME_MAX_WAIT_MS, firstFrameTimeoutMs);
1967
2262
  return { async *generateVideoFrames(source, opts) {
1968
- const session = new SessionDecodeSession(deps.fork(source), deps.logger, source.deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs, deps.onDialTiming, deps.onPreRollReport);
2263
+ const session = new SessionDecodeSession(deps.fork(source), deps.logger, source.deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs, {
2264
+ ...deps.onDialTiming ? { onDialTiming: deps.onDialTiming } : {},
2265
+ ...deps.onPreRollReport ? { onPreRollReport: deps.onPreRollReport } : {},
2266
+ ...deps.onPreRollSettled ? { onPreRollSettled: deps.onPreRollSettled } : {},
2267
+ ...deps.onWorkerExit ? { onWorkerExit: deps.onWorkerExit } : {}
2268
+ });
1969
2269
  session.start(source, opts, deps.lease());
2270
+ deps.onSessionTeardown?.((mode) => session.teardown(mode));
2271
+ let failed = false;
1970
2272
  try {
1971
2273
  while (true) {
1972
2274
  const outcome = await session.pullNext();
@@ -1979,8 +2281,11 @@ function createSessionDecodeCoordinator(deps) {
1979
2281
  ...reply.preRoll === true ? { preRoll: true } : {}
1980
2282
  };
1981
2283
  }
2284
+ } catch (err) {
2285
+ failed = true;
2286
+ throw err;
1982
2287
  } finally {
1983
- session.teardown();
2288
+ session.teardown(failed ? "immediate" : deps.teardownMode?.() ?? "drain");
1984
2289
  }
1985
2290
  } };
1986
2291
  }
@@ -2024,17 +2329,39 @@ async function toDecodedFrame(frame, format, viewSpec) {
2024
2329
  * Start pulling frames from a per-session decode worker and feed each decoded
2025
2330
  * frame to `onDecodedFrame`. Returns an idempotent teardown closure that stops
2026
2331
  * the async generator (`it.return()`) — which triggers the coordinator's
2027
- * `finally` → `stop` + `kill()` + 1s SIGKILL fallback → worker exit → the OS
2028
- * reclaims the VAAPI/GPU decode pool. Fire-and-forget: the returned closure is
2029
- * synchronous; the frame loop and generator teardown run on their own tasks.
2332
+ * `finally` → a bounded DRAIN of the frames already handed out (D652), then
2333
+ * `stop` + SIGTERM + 1s SIGKILL fallback → worker exit → the OS reclaims the
2334
+ * VAAPI/GPU decode pool. `teardown('immediate')` SIGKILLs at once (shutdown),
2335
+ * and said a second time during a drain it escalates that drain to the kill.
2336
+ * Fire-and-forget: the returned closure is synchronous; the frame loop and
2337
+ * generator teardown run on their own tasks.
2030
2338
  */
2031
2339
  function startSessionDecodePump(deps) {
2032
2340
  const format = deps.opts.format ?? "rgb";
2033
2341
  let dialTiming = null;
2342
+ let teardownMode = "drain";
2343
+ let escalateSession = null;
2344
+ let forked = false;
2345
+ let workerGone = false;
2346
+ const noteWorkerGone = () => {
2347
+ if (workerGone) return;
2348
+ workerGone = true;
2349
+ deps.onWorkerGone?.();
2350
+ };
2034
2351
  const iterator = createSessionDecodeCoordinator({
2035
- fork: deps.fork,
2352
+ fork: (source) => {
2353
+ const child = deps.fork(source);
2354
+ forked = true;
2355
+ return child;
2356
+ },
2036
2357
  lease: deps.lease,
2037
2358
  logger: deps.logger,
2359
+ teardownMode: () => teardownMode,
2360
+ onSessionTeardown: (teardown) => {
2361
+ escalateSession = teardown;
2362
+ },
2363
+ onWorkerExit: noteWorkerGone,
2364
+ ...deps.onPreRollSettled ? { onPreRollSettled: deps.onPreRollSettled } : {},
2038
2365
  onDialTiming: (timing) => {
2039
2366
  dialTiming = timing;
2040
2367
  },
@@ -2140,13 +2467,21 @@ function startSessionDecodePump(deps) {
2140
2467
  });
2141
2468
  } finally {
2142
2469
  releaseSpawnSlotOnce();
2470
+ if (!forked) noteWorkerGone();
2143
2471
  if (!stopped) deps.onEnded?.();
2144
2472
  }
2145
2473
  };
2146
2474
  pump();
2147
- return () => {
2148
- if (stopped) return;
2475
+ return (mode = "drain") => {
2476
+ if (stopped) {
2477
+ if (mode === "immediate" && teardownMode !== "immediate") {
2478
+ teardownMode = "immediate";
2479
+ escalateSession?.("immediate");
2480
+ }
2481
+ return;
2482
+ }
2149
2483
  stopped = true;
2484
+ teardownMode = mode;
2150
2485
  releaseSpawnSlotOnce();
2151
2486
  iterator.return().catch(() => {});
2152
2487
  };
@@ -3576,8 +3911,8 @@ async function runDetailSubtree(deps, input) {
3576
3911
  } : void 0;
3577
3912
  const details = [];
3578
3913
  for (const step of steps) {
3579
- const deviceKey = deps.resolveStepDevice?.(input.deviceId, step);
3580
- const result = await deps.runPipeline([step], crop.jpeg, input.deviceId, deviceKey, nativeCropRef);
3914
+ const placement = deps.placeStep?.(input.deviceId, step);
3915
+ const result = await deps.runPipeline([placement?.step ?? step], crop.jpeg, input.deviceId, placement?.deviceKey, nativeCropRef);
3581
3916
  if (!result) continue;
3582
3917
  for (const detection of result.detections) details.push(toDetailResult(detection, step.addonId, crop));
3583
3918
  }
@@ -3684,6 +4019,72 @@ function resolveStepDevice(input) {
3684
4019
  reason: "auto"
3685
4020
  };
3686
4021
  }
4022
+ /**
4023
+ * Re-provision a jumped subtree for its TARGET device (D650): every step's
4024
+ * `modelId` / `settings` are REPLACED by the target's entry — the orchestrator
4025
+ * computed it as what the step would carry had the camera been elected onto
4026
+ * that device (its per-(node,device) base with the camera's override for it).
4027
+ * A step absent from the map runs with neither, i.e. the target format's
4028
+ * default model. The elected device's provisioning never follows a step across:
4029
+ * it was chosen for a format this subtree does not run on.
4030
+ */
4031
+ function provisionSubtreeForDevice(step, provisioning) {
4032
+ const own = provisioning[step.addonId];
4033
+ const { modelId: _electedModelId, settings: _electedSettings, ...rest } = step;
4034
+ return {
4035
+ ...rest,
4036
+ ...own?.modelId !== void 0 ? { modelId: own.modelId } : {},
4037
+ ...own?.settings !== void 0 ? { settings: own.settings } : {},
4038
+ ...step.children !== void 0 ? { children: step.children.map((c) => provisionSubtreeForDevice(c, provisioning)) } : {}
4039
+ };
4040
+ }
4041
+ /**
4042
+ * Place ONE detail subtree (D650): which same-node device runs it
4043
+ * ({@link resolveStepDevice}) and, when it jumps, the provisioning it runs
4044
+ * with there ({@link provisionSubtreeForDevice}). Pure — the caller logs.
4045
+ *
4046
+ * A roster entry without `steps` came from an orchestrator that predates D650:
4047
+ * the step keeps what it was sent with, as it did before.
4048
+ */
4049
+ function placeDetailStep(input) {
4050
+ const { step, effectiveDeviceKey, roster } = input;
4051
+ if (!effectiveDeviceKey) return {
4052
+ deviceKey: void 0,
4053
+ step,
4054
+ decision: null,
4055
+ provisioned: false
4056
+ };
4057
+ if (!roster || roster.length < 2) return {
4058
+ deviceKey: effectiveDeviceKey,
4059
+ step,
4060
+ decision: null,
4061
+ provisioned: false
4062
+ };
4063
+ const decision = resolveStepDevice({
4064
+ effectiveDeviceKey,
4065
+ devices: roster.map((d) => ({
4066
+ deviceKey: d.deviceKey,
4067
+ weight: d.weight,
4068
+ maxSessions: d.maxSessions,
4069
+ attachedCameras: input.attachedByKey.get(d.deviceKey) ?? 0
4070
+ })),
4071
+ ...step.jumpDeviceKey !== void 0 ? { manualJumpDeviceKey: step.jumpDeviceKey } : {},
4072
+ canRun: buildSubtreeCanRun(step, input.getStepDef)
4073
+ });
4074
+ if (decision.kind !== "jumped") return {
4075
+ deviceKey: decision.deviceKey,
4076
+ step,
4077
+ decision,
4078
+ provisioned: false
4079
+ };
4080
+ const provisioning = roster.find((d) => d.deviceKey === decision.deviceKey)?.steps;
4081
+ return {
4082
+ deviceKey: decision.deviceKey,
4083
+ step: provisioning === void 0 ? step : provisionSubtreeForDevice(step, provisioning),
4084
+ decision,
4085
+ provisioned: provisioning !== void 0
4086
+ };
4087
+ }
3687
4088
  //#endregion
3688
4089
  //#region src/pipeline-runner/device-load.ts
3689
4090
  /** The backend prefix of a `<backend>:<device>` key (bare `cpu` → `cpu`). */
@@ -3867,6 +4268,74 @@ function release(cutter) {
3867
4268
  } catch {}
3868
4269
  }
3869
4270
  //#endregion
4271
+ //#region src/pipeline-runner/occupancy-burst-ledger.ts
4272
+ var OccupancyBurstLedger = class {
4273
+ options;
4274
+ frames = 0;
4275
+ inferred = 0;
4276
+ superseded = 0;
4277
+ settled = 0;
4278
+ closed = false;
4279
+ completed = false;
4280
+ boundTimer = null;
4281
+ constructor(options) {
4282
+ this.options = options;
4283
+ }
4284
+ /**
4285
+ * Count one collected frame and hand back its settler. A frame the runner
4286
+ * REFUSED is settled `'lost'` by the caller at once — it is a frame the
4287
+ * verdict did not see, which is what `lost` counts.
4288
+ */
4289
+ admit() {
4290
+ this.frames += 1;
4291
+ let done = false;
4292
+ return (outcome) => {
4293
+ if (done || this.completed) return;
4294
+ done = true;
4295
+ this.settled += 1;
4296
+ if (outcome === "inferred") this.inferred += 1;
4297
+ else if (outcome === "superseded") this.superseded += 1;
4298
+ this.completeIfSettled();
4299
+ };
4300
+ }
4301
+ /**
4302
+ * No more frames will be collected (count reached, safety timeout, or the
4303
+ * decode ended). Completes now when everything admitted has settled, else
4304
+ * arms the bound. Idempotent.
4305
+ */
4306
+ close() {
4307
+ if (this.closed || this.completed) return;
4308
+ this.closed = true;
4309
+ if (this.completeIfSettled()) return;
4310
+ this.boundTimer = setTimeout(() => {
4311
+ this.boundTimer = null;
4312
+ this.complete(true);
4313
+ }, this.options.settleBoundMs);
4314
+ this.boundTimer.unref?.();
4315
+ }
4316
+ completeIfSettled() {
4317
+ if (!this.closed || this.settled < this.frames) return false;
4318
+ this.complete(false);
4319
+ return true;
4320
+ }
4321
+ complete(timedOut) {
4322
+ if (this.completed) return;
4323
+ this.completed = true;
4324
+ if (this.boundTimer !== null) {
4325
+ clearTimeout(this.boundTimer);
4326
+ this.boundTimer = null;
4327
+ }
4328
+ this.options.onComplete({
4329
+ frames: this.frames,
4330
+ inferred: this.inferred,
4331
+ superseded: this.superseded,
4332
+ lost: this.frames - this.inferred - this.superseded,
4333
+ unsettled: this.frames - this.settled,
4334
+ timedOut
4335
+ });
4336
+ }
4337
+ };
4338
+ //#endregion
3870
4339
  //#region src/pipeline-runner/frame-lazy-metrics.ts
3871
4340
  function emptyFrameLazyCounters() {
3872
4341
  return {
@@ -5279,41 +5748,6 @@ var FrameGovernor = class {
5279
5748
  };
5280
5749
  //#endregion
5281
5750
  //#region src/pipeline-runner/frame-queue.ts
5282
- /**
5283
- * Latest-frame-only buffer. Keeps only the most recent item, dropping all
5284
- * older items immediately. This ensures inference always runs on the
5285
- * freshest available frame, never accumulating a backlog regardless of
5286
- * inference latency.
5287
- *
5288
- * ## Buffer ownership (Phase 5 / D9 Task 7b)
5289
- *
5290
- * Frames arrive from the shm frame plane via a `FrameRingReader`, whose
5291
- * `FrameRead.pixels` is **borrowed** — a view onto the reader's reusable
5292
- * scratch buffer, valid only until that reader's next read (see
5293
- * `@camstack/shm-ring`'s `FrameRead` contract). Both queue consumers retain
5294
- * the frame PAST the next read:
5295
- *
5296
- * - the **motion** queue is drained by the scheduler's `tick`, not by the
5297
- * poller that read the frame — and the poller drains a burst of handles
5298
- * per poll, so even handle #2's read would clobber handle #1's pixels;
5299
- * - the **detection** queue is drained asynchronously after a semaphore wait
5300
- * and a 10s–100s ms inference.
5301
- *
5302
- * In both cases holding the borrowed buffer is silent corruption. So
5303
- * `enqueue` runs the queue's injected `clone` function which copies the
5304
- * pixels into queue-owned storage at the retention boundary. The queue is
5305
- * latest-only (capacity 1), so this is exactly one allocation per *queued*
5306
- * frame — bounded by the scheduler/inference cadence, NOT per *read* frame.
5307
- * The high-frequency per-read allocation that failed the D9 perf gate is
5308
- * eliminated; this bounded copy-on-retain is the correct cost.
5309
- *
5310
- * The generic parameter `T` lets the detection path queue a
5311
- * `{ frame, handle }` entry (Task 7 — propagating the `FrameHandle`
5312
- * through to the inference-result event), while the motion path keeps
5313
- * queueing bare `DecodedFrame`s. Each instance provides its own
5314
- * `clone` so the inner pixel buffer is detached at the same retention
5315
- * boundary regardless of the wrapping entry shape.
5316
- */
5317
5751
  var FrameQueue = class {
5318
5752
  maxSize;
5319
5753
  latest = null;
@@ -5334,7 +5768,7 @@ var FrameQueue = class {
5334
5768
  enqueue(item) {
5335
5769
  if (this.latest !== null) {
5336
5770
  this._droppedFrames++;
5337
- this.dispose?.(this.latest);
5771
+ this.dispose?.(this.latest, "superseded");
5338
5772
  }
5339
5773
  this.latest = this.clone(item);
5340
5774
  }
@@ -5349,8 +5783,12 @@ var FrameQueue = class {
5349
5783
  get droppedFrames() {
5350
5784
  return this._droppedFrames;
5351
5785
  }
5786
+ /** The queued item, without taking it. */
5787
+ peek() {
5788
+ return this.latest ?? void 0;
5789
+ }
5352
5790
  clear() {
5353
- if (this.latest !== null) this.dispose?.(this.latest);
5791
+ if (this.latest !== null) this.dispose?.(this.latest, "cleared");
5354
5792
  this.latest = null;
5355
5793
  }
5356
5794
  };
@@ -5750,7 +6188,8 @@ function ownDetectionEntry(entry) {
5750
6188
  return {
5751
6189
  frame: ownFrame(entry.frame),
5752
6190
  handle: entry.handle,
5753
- occasion: entry.occasion
6191
+ occasion: entry.occasion,
6192
+ ...entry.onSettled ? { onSettled: entry.onSettled } : {}
5754
6193
  };
5755
6194
  }
5756
6195
  /**
@@ -5892,9 +6331,10 @@ var PipelineRunner = class {
5892
6331
  }
5893
6332
  registerCamera(deviceId, registration) {
5894
6333
  const motionQueue = new FrameQueue(this.config.maxQueueDepth, ownMotionEntry);
5895
- const detectionQueue = new FrameQueue(this.config.maxQueueDepth, ownDetectionEntry, (entry) => {
6334
+ const detectionQueue = new FrameQueue(this.config.maxQueueDepth, ownDetectionEntry, (entry, reason) => {
5896
6335
  if (entry.handle) this.config.releaseRetention?.(entry.handle, "drop");
5897
6336
  if (entry.frame.frameRef) this.config.releaseFrameRef?.(entry.frame.frameRef, "drop");
6337
+ entry.onSettled?.(reason === "superseded" ? "superseded" : "lost");
5898
6338
  });
5899
6339
  const initialPhase = registration.detectionMode === "disabled" ? "idle" : registration.detectionMode === "always-on" ? "active" : "watching";
5900
6340
  const preRollQueue = new PreRollLane({
@@ -5903,6 +6343,7 @@ var PipelineRunner = class {
5903
6343
  dispose: (entry) => {
5904
6344
  if (entry.handle) this.config.releaseRetention?.(entry.handle, "drop");
5905
6345
  if (entry.frame.frameRef) this.config.releaseFrameRef?.(entry.frame.frameRef, "drop");
6346
+ entry.onSettled?.("lost");
5906
6347
  }
5907
6348
  });
5908
6349
  this.logger?.info("camera registered: root detectors", {
@@ -5963,8 +6404,7 @@ var PipelineRunner = class {
5963
6404
  }
5964
6405
  if (state.phase === "active") this.detectionStreamHandler?.(deviceId, "unsubscribe");
5965
6406
  state.motionQueue.clear();
5966
- state.detectionQueue.clear();
5967
- state.preRollQueue.clear();
6407
+ this.discardQueuedDetectionFrames(deviceId, state, "detach");
5968
6408
  this.timingSampler.clearDroppedFramesSource(deviceId);
5969
6409
  this.cameras.delete(deviceId);
5970
6410
  this.lastAdmittedAt.delete(deviceId);
@@ -6088,9 +6528,14 @@ var PipelineRunner = class {
6088
6528
  preRollQueueDepth(deviceId) {
6089
6529
  return this.cameras.get(deviceId)?.preRollQueue.size ?? 0;
6090
6530
  }
6091
- /** Frames this camera lane REFUSED because it was full. */
6531
+ /**
6532
+ * Frames this camera lane REFUSED because it was full — or `null` when the
6533
+ * camera is no longer registered and the count cannot be read (a report
6534
+ * that lands after a detach). A failed read is `null`, never `0` (D393):
6535
+ * zero would say "nothing refused" about a lane nobody looked at.
6536
+ */
6092
6537
  preRollRefusedCount(deviceId) {
6093
- return this.cameras.get(deviceId)?.preRollQueue.refused ?? 0;
6538
+ return this.cameras.get(deviceId)?.preRollQueue.refused ?? null;
6094
6539
  }
6095
6540
  /**
6096
6541
  * Whether the node has a permit to spare for history right now — i.e. one
@@ -6119,7 +6564,7 @@ var PipelineRunner = class {
6119
6564
  * decode worker). `runInference` already falls back to the pixel path
6120
6565
  * when the handle is absent.
6121
6566
  */
6122
- enqueueOccupancyFrame(deviceId, frame, handle) {
6567
+ enqueueOccupancyFrame(deviceId, frame, handle, onSettled) {
6123
6568
  const state = this.cameras.get(deviceId);
6124
6569
  if (!state) return false;
6125
6570
  if (state.phase !== "watching") return false;
@@ -6127,7 +6572,8 @@ var PipelineRunner = class {
6127
6572
  state.detectionQueue.enqueue({
6128
6573
  frame,
6129
6574
  handle,
6130
- occasion: "burst"
6575
+ occasion: "burst",
6576
+ ...onSettled ? { onSettled } : {}
6131
6577
  });
6132
6578
  return true;
6133
6579
  }
@@ -6335,7 +6781,7 @@ var PipelineRunner = class {
6335
6781
  }
6336
6782
  transitionToWatching(deviceId, state, cooldownMs) {
6337
6783
  state.phase = "watching";
6338
- state.detectionQueue.clear();
6784
+ this.discardQueuedDetectionFrames(deviceId, state, "session-close");
6339
6785
  state.occupancyArmed = state.lastResultDetections > 0;
6340
6786
  state.emptyBurstStreak = 0;
6341
6787
  this.logger?.info("motion gate closed — phase=watching", {
@@ -6356,6 +6802,41 @@ var PipelineRunner = class {
6356
6802
  state.lastArmedSource = null;
6357
6803
  state.lastArmedRegions = void 0;
6358
6804
  }
6805
+ /**
6806
+ * Throw away the frames still QUEUED for a camera whose session is ending.
6807
+ *
6808
+ * A branch that drops work says so (D391, D652). These are frames the runner
6809
+ * admitted and will now never infer — the live queue's latest frame and, on a
6810
+ * detach, whatever history is left in the pre-roll lane. Frames already in
6811
+ * flight are not touched here: they finish, and the decode worker they live
6812
+ * in drains before it is killed (`SESSION_DECODE_DRAIN_BOUND_MS`).
6813
+ */
6814
+ discardQueuedDetectionFrames(deviceId, state, reason) {
6815
+ const queued = state.detectionQueue.size;
6816
+ const awaited = state.detectionQueue.peek()?.onSettled !== void 0 ? 1 : 0;
6817
+ const preRoll = reason === "detach" ? state.preRollQueue.size : 0;
6818
+ state.detectionQueue.clear();
6819
+ if (reason === "detach") state.preRollQueue.clear();
6820
+ if (queued + preRoll === 0) return;
6821
+ const meta = {
6822
+ reason,
6823
+ queued,
6824
+ awaited,
6825
+ preRoll,
6826
+ inflight: state.inflight
6827
+ };
6828
+ if (awaited + preRoll > 0) {
6829
+ this.logger?.info("detection frames discarded with the session", {
6830
+ tags: { deviceId },
6831
+ meta
6832
+ });
6833
+ return;
6834
+ }
6835
+ this.logger?.debug("detection frames discarded with the session", {
6836
+ tags: { deviceId },
6837
+ meta
6838
+ });
6839
+ }
6359
6840
  tick() {
6360
6841
  this.drainMotionQueues();
6361
6842
  if (this.semaphore.available <= 0) {
@@ -6443,6 +6924,7 @@ var PipelineRunner = class {
6443
6924
  } catch {}
6444
6925
  if (handle) this.config.releaseRetention?.(handle, frameReleaseReason);
6445
6926
  if (frame.frameRef) this.config.releaseFrameRef?.(frame.frameRef, frameReleaseReason);
6927
+ entry.onSettled?.(frameReleaseReason === "success" ? "inferred" : "lost");
6446
6928
  }
6447
6929
  }
6448
6930
  /**
@@ -6524,6 +7006,7 @@ var PipelineRunner = class {
6524
7006
  } catch {}
6525
7007
  if (handle) this.config.releaseRetention?.(handle, frameReleaseReason);
6526
7008
  if (frame.frameRef) this.config.releaseFrameRef?.(frame.frameRef, frameReleaseReason);
7009
+ entry.onSettled?.(frameReleaseReason === "success" ? "inferred" : "lost");
6527
7010
  }
6528
7011
  }
6529
7012
  /**
@@ -7067,6 +7550,19 @@ function writeRetainedDetectionFrame(store, deviceId, frame, retention) {
7067
7550
  */
7068
7551
  var OCCUPANCY_BURST_SAFETY_TIMEOUT_MS = 8e3;
7069
7552
  /**
7553
+ * How long a burst whose collection has ended waits for the frames it admitted
7554
+ * to be INFERRED before it reports anyway (D652).
7555
+ *
7556
+ * A burst's verdict is read off its frames' results, so it completes when they
7557
+ * settle, not when they are enqueued. This bound is only for a frame that never
7558
+ * settles. Measured on the live hub 2026-09-26, the worst enqueue→emit of a
7559
+ * 10 s window was ≤1.5 s in 98.0% of 2 254 windows and ≤5 s in 98.9%; what lies
7560
+ * beyond is the inference-pool GPU hang, which the runner's own 20 s inference
7561
+ * timeout already ends. A timed-out burst counts its unsettled frames as lost
7562
+ * and says so on its `burst complete` line.
7563
+ */
7564
+ var OCCUPANCY_BURST_SETTLE_BOUND_MS = 5e3;
7565
+ /**
7070
7566
  * Freshness window for `lastMotionFrameAt` (Task 10 hardening). A non-null
7071
7567
  * `attachment.motionUnsubscribe` only proves a subscription handle exists —
7072
7568
  * NOT that frames are actually flowing (guarded-session-decode can return
@@ -7876,8 +8372,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
7876
8372
  this.runner = null;
7877
8373
  }
7878
8374
  for (const attachment of this.attached.values()) {
7879
- attachment.motionUnsubscribe?.();
7880
- attachment.detectionUnsubscribe?.();
8375
+ attachment.motionUnsubscribe?.("immediate");
8376
+ attachment.detectionUnsubscribe?.("immediate");
7881
8377
  }
7882
8378
  for (const deviceId of [...this.parkedDetail.keys()]) this.dropParkedDetail(deviceId);
7883
8379
  this.attached.clear();
@@ -9222,7 +9718,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
9222
9718
  resolveChildSteps: (deviceId, parentClassName, filter) => this.resolveDetailChildSteps(deviceId, parentClassName, filter),
9223
9719
  getNativeCrop: (deviceId, handle, paddedBbox) => this.getNativeCropForDetail(deviceId, handle, paddedBbox),
9224
9720
  runPipeline: (steps, imageJpeg, deviceId, deviceKey, nativeCropRef) => this.runDetailPipeline(steps, imageJpeg, deviceId, deviceKey, nativeCropRef),
9225
- resolveStepDevice: (deviceId, step) => this.resolveDetailStepDevice(deviceId, step),
9721
+ placeStep: (deviceId, step) => this.placeDetailStepForCamera(deviceId, step),
9226
9722
  resolveCropConvention: () => convention,
9227
9723
  logger: this.detailSubtreeLogger()
9228
9724
  }, input);
@@ -9436,39 +9932,49 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
9436
9932
  return { warn: (message, extras) => this.ctxIfReady?.logger.warn(message, extras) };
9437
9933
  }
9438
9934
  /**
9439
- * Step-tree device jump (phase 1) — pick which same-node inference device an
9440
- * enrichment `step` runs on. AUTO-jumps only when the camera's elected device
9441
- * cannot run the step's model (its format has no build); an operator
9442
- * `step.jumpDeviceKey` overrides. Returns the resolved `<backend>:<device>`
9443
- * key, or `undefined` (⇒ the runner's per-camera default pool) when there is
9444
- * nothing to route: no elected device, or no jump roster on the attach.
9935
+ * Step-tree device jump — place one enrichment subtree on a same-node
9936
+ * inference device ({@link placeDetailStep}). AUTO-jumps only when the
9937
+ * camera's elected device cannot run the subtree (its format has no build);
9938
+ * an operator `step.jumpDeviceKey` overrides. A jumped subtree runs with the
9939
+ * TARGET device's provisioning carried on the roster (D650), never the
9940
+ * elected device's. `deviceKey: undefined` ⇒ the runner's per-camera default
9941
+ * pool (no elected device).
9445
9942
  *
9446
- * When NO enabled device on the node can run the step, logs a LOUD warn once
9447
- * per (camera, step) — killing the historical SILENT per-child degrade (an
9448
- * `edgetpu` root would otherwise drop faces/plates/clip with no signal).
9943
+ * Logs the jump ONCE per (camera, step, target), and — when NO enabled device
9944
+ * on the node can run the subtree — a LOUD warn once per (camera, step).
9449
9945
  */
9450
- resolveDetailStepDevice(deviceId, step) {
9946
+ placeDetailStepForCamera(deviceId, step) {
9451
9947
  const config = this.attached.get(deviceId)?.config;
9452
- const effectiveDeviceKey = config?.deviceKey;
9453
- if (!effectiveDeviceKey) return void 0;
9454
- const roster = config?.inferenceDevices;
9455
- if (!roster || roster.length < 2) return effectiveDeviceKey;
9456
- const attachedByKey = this.perDeviceAttachedCounts();
9457
- const devices = roster.map((d) => ({
9458
- deviceKey: d.deviceKey,
9459
- weight: d.weight,
9460
- maxSessions: d.maxSessions,
9461
- attachedCameras: attachedByKey.get(d.deviceKey) ?? 0
9462
- }));
9463
- const canRun = buildSubtreeCanRun(step, tryGetStepDefinition);
9464
- const result = resolveStepDevice({
9465
- effectiveDeviceKey,
9466
- devices,
9467
- ...step.jumpDeviceKey !== void 0 ? { manualJumpDeviceKey: step.jumpDeviceKey } : {},
9468
- canRun
9948
+ const placement = placeDetailStep({
9949
+ step,
9950
+ effectiveDeviceKey: config?.deviceKey,
9951
+ roster: config?.inferenceDevices,
9952
+ attachedByKey: this.perDeviceAttachedCounts(),
9953
+ getStepDef: tryGetStepDefinition
9954
+ });
9955
+ const decision = placement.decision;
9956
+ if (decision?.kind === "no-candidate") this.warnStepHasNoRunnableDevice(deviceId, step.addonId, decision.deviceKey);
9957
+ else if (decision?.kind === "jumped" && config?.deviceKey) this.logStepJumped(deviceId, config.deviceKey, decision, placement);
9958
+ return placement;
9959
+ }
9960
+ /** `${deviceId}:${addonId}:${target}` triples whose jump has been logged. */
9961
+ loggedStepJumps = /* @__PURE__ */ new Set();
9962
+ logStepJumped(deviceId, electedDeviceKey, decision, placement) {
9963
+ const step = placement.step;
9964
+ const key = `${deviceId}:${step.addonId}:${decision.deviceKey}`;
9965
+ if (this.loggedStepJumps.has(key)) return;
9966
+ this.loggedStepJumps.add(key);
9967
+ this.ctx?.logger.info("enrichment step runs on another device of this node", {
9968
+ tags: { deviceId },
9969
+ meta: {
9970
+ step: step.addonId,
9971
+ electedDeviceKey,
9972
+ targetDeviceKey: decision.deviceKey,
9973
+ reason: decision.reason,
9974
+ provisioned: placement.provisioned,
9975
+ modelId: step.modelId ?? null
9976
+ }
9469
9977
  });
9470
- if (result.kind === "no-candidate") this.warnStepHasNoRunnableDevice(deviceId, step.addonId, effectiveDeviceKey);
9471
- return result.deviceKey;
9472
9978
  }
9473
9979
  /** Live attached-camera count per inference `deviceKey` on this runner. */
9474
9980
  perDeviceAttachedCounts() {
@@ -10027,6 +10533,16 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10027
10533
  if (!admission) return void 0;
10028
10534
  return () => admission.admit(deviceId, label);
10029
10535
  }
10536
+ /**
10537
+ * The start's place in the node's queue of refused starts (D652) — taken by
10538
+ * the guarded start on its first refusal, kept until it is over. Spread into
10539
+ * `startGuardedSessionDecode`'s deps; empty when this node runs no ceiling.
10540
+ */
10541
+ liveWorkerSlotWaiter(deviceId) {
10542
+ const admission = this.liveWorkers;
10543
+ if (!admission) return {};
10544
+ return { slotWaiter: () => admission.slotWaiter(deviceId) };
10545
+ }
10030
10546
  buildDecodeSpawnAcquire(log, label) {
10031
10547
  return async () => {
10032
10548
  const gate = this.spawnGate;
@@ -10055,8 +10571,9 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10055
10571
  const teardown = startGuardedSessionDecode({
10056
10572
  logger: log,
10057
10573
  admitWorker: this.buildLiveWorkerAdmit(config.deviceId, "detect"),
10574
+ ...this.liveWorkerSlotWaiter(config.deviceId),
10058
10575
  acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:detect:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config), true),
10059
- startPump: (source) => {
10576
+ startPump: (source, hooks) => {
10060
10577
  const dialMs = Date.now() - requestedAt;
10061
10578
  const useLocalFrameRef = this.executorCoLocated() && process.env["CAMSTACK_DISABLE_LOCAL_FRAME_REF"] !== "1";
10062
10579
  log.info("session-decode: detection routed through per-session decode worker (rgb)", { meta: {
@@ -10068,6 +10585,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10068
10585
  return startSessionDecodePump({
10069
10586
  fork: this.forkDecodeWorkerWithLease,
10070
10587
  lease: this.resolveSessionLeaseKnobs,
10588
+ onWorkerGone: hooks.onWorkerGone,
10071
10589
  logger: log,
10072
10590
  source,
10073
10591
  opts: {
@@ -10149,10 +10667,30 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10149
10667
  strideMs: report.strideMs,
10150
10668
  subsampled: report.subsampled,
10151
10669
  laneRefused: runner.preRollRefusedCount(config.deviceId),
10152
- budgetFrames: this.preRollBudgetFor(config)
10670
+ budgetFrames: this.preRollBudgetFor(config),
10671
+ effectiveBudgetFrames: report.budgetFrames
10153
10672
  }
10154
10673
  });
10155
10674
  },
10675
+ onPreRollSettled: (summary) => {
10676
+ const lost = summary.lostByReason.evicted + summary.lostByReason.cleared + summary.lostByReason["download-failed"] + summary.lostByReason.miss;
10677
+ const meta = {
10678
+ streamId: config.detectionStreamId,
10679
+ ...summary,
10680
+ lost
10681
+ };
10682
+ if (lost > 0 || summary.unsettled > 0) {
10683
+ log.info("session-decode: pre-roll frames settled", {
10684
+ tags: { deviceId: config.deviceId },
10685
+ meta
10686
+ });
10687
+ return;
10688
+ }
10689
+ log.debug("session-decode: pre-roll frames settled", {
10690
+ tags: { deviceId: config.deviceId },
10691
+ meta
10692
+ });
10693
+ },
10156
10694
  onFirstFrame: (timing) => {
10157
10695
  const phaseActiveAt = this.lastMotionAt.get(config.deviceId);
10158
10696
  log.info("session-decode: detection first frame", {
@@ -10196,8 +10734,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10196
10734
  });
10197
10735
  }
10198
10736
  });
10199
- return () => {
10200
- teardown();
10737
+ return (mode) => {
10738
+ teardown(mode);
10201
10739
  };
10202
10740
  }
10203
10741
  /**
@@ -10218,8 +10756,9 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10218
10756
  return startGuardedSessionDecode({
10219
10757
  logger: log,
10220
10758
  admitWorker: this.buildLiveWorkerAdmit(config.deviceId, "motion"),
10759
+ ...this.liveWorkerSlotWaiter(config.deviceId),
10221
10760
  acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.motionStreamId, `session-decode:motion:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config)),
10222
- startPump: (source) => {
10761
+ startPump: (source, hooks) => {
10223
10762
  log.info("session-decode: motion routed through per-session decode worker (gray)", { meta: {
10224
10763
  streamId: config.motionStreamId,
10225
10764
  codec: source.codec
@@ -10227,6 +10766,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10227
10766
  return startSessionDecodePump({
10228
10767
  fork: this.forkDecodeWorkerWithLease,
10229
10768
  lease: this.resolveSessionLeaseKnobs,
10769
+ onWorkerGone: hooks.onWorkerGone,
10230
10770
  logger: log,
10231
10771
  source,
10232
10772
  opts: {
@@ -10894,15 +11434,36 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10894
11434
  * found/report semantics can't drift between the two admission
10895
11435
  * mechanisms.
10896
11436
  */
10897
- finishOccupancyBurst(deviceId, log, collected) {
11437
+ finishOccupancyBurst(deviceId, log, summary) {
10898
11438
  this.occupancyBurstInFlight.delete(deviceId);
10899
11439
  const found = this.occupancyBurstFound.has(deviceId);
10900
11440
  this.occupancyBurstFound.delete(deviceId);
11441
+ if (summary.inferred === 0) {
11442
+ log.info("occupancy re-check: burst complete — nothing inferred, no verdict", {
11443
+ tags: { deviceId },
11444
+ meta: {
11445
+ ...summary,
11446
+ verdict: null
11447
+ }
11448
+ });
11449
+ return;
11450
+ }
10901
11451
  this.runner?.reportOccupancyBurstOutcome(deviceId, found);
10902
- log.debug("occupancy re-check: burst complete", { meta: {
10903
- collected,
11452
+ const meta = {
11453
+ ...summary,
10904
11454
  found
10905
- } });
11455
+ };
11456
+ if (summary.lost > 0) {
11457
+ log.info("occupancy re-check: burst complete", {
11458
+ tags: { deviceId },
11459
+ meta
11460
+ });
11461
+ return;
11462
+ }
11463
+ log.debug("occupancy re-check: burst complete", {
11464
+ tags: { deviceId },
11465
+ meta
11466
+ });
10906
11467
  }
10907
11468
  /**
10908
11469
  * Task 10: arm a TAP-based occupancy burst for a device whose analyzer
@@ -10938,21 +11499,30 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10938
11499
  log.debug("occupancy re-check: tap safety timeout reached", { meta: { collected } });
10939
11500
  this.finishOccupancyTap(deviceId);
10940
11501
  }, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
11502
+ const ledger = new OccupancyBurstLedger({
11503
+ settleBoundMs: OCCUPANCY_BURST_SETTLE_BOUND_MS,
11504
+ onComplete: resolve
11505
+ });
10941
11506
  this.occupancyTaps.set(deviceId, {
10942
11507
  remaining: frames,
10943
11508
  collected: 0,
10944
11509
  safetyTimer,
11510
+ ledger,
10945
11511
  resolve
10946
11512
  });
10947
11513
  });
10948
11514
  }
10949
- /** Idempotent: a no-op once the tap has already settled (timer fired, or the budget already drained). */
11515
+ /**
11516
+ * Close the tap's COLLECTION. Idempotent: a no-op once the tap has already
11517
+ * closed (timer fired, or the budget already drained). The tap's promise
11518
+ * resolves later, from its ledger, once the frames it admitted have settled.
11519
+ */
10950
11520
  finishOccupancyTap(deviceId) {
10951
11521
  const tap = this.occupancyTaps.get(deviceId);
10952
11522
  if (!tap) return;
10953
11523
  clearTimeout(tap.safetyTimer);
10954
11524
  this.occupancyTaps.delete(deviceId);
10955
- tap.resolve(tap.collected);
11525
+ tap.ledger.close();
10956
11526
  }
10957
11527
  /**
10958
11528
  * Tee an arriving analyzer motion frame into occupancy-burst detection
@@ -10967,7 +11537,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10967
11537
  teeOccupancyTapFrame(deviceId, frame, handle) {
10968
11538
  const tap = this.occupancyTaps.get(deviceId);
10969
11539
  if (!tap) return;
10970
- this.runner?.enqueueOccupancyFrame(deviceId, frame, handle);
11540
+ const settle = tap.ledger.admit();
11541
+ if (!this.runner?.enqueueOccupancyFrame(deviceId, frame, handle, settle)) settle("lost");
10971
11542
  tap.collected++;
10972
11543
  tap.remaining--;
10973
11544
  if (tap.remaining <= 0) this.finishOccupancyTap(deviceId);
@@ -10987,9 +11558,9 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
10987
11558
  const lastFrameAt = this.lastMotionFrameAt.get(deviceId);
10988
11559
  const motionFresh = lastFrameAt !== void 0 && Date.now() - lastFrameAt < MOTION_FRESHNESS_WINDOW_MS;
10989
11560
  if (attachment.motionUnsubscribe !== null && motionFresh) {
10990
- const collected = await this.startOccupancyTap(deviceId, frames, log);
10991
- if (collected > 0) {
10992
- this.finishOccupancyBurst(deviceId, log, collected);
11561
+ const summary = await this.startOccupancyTap(deviceId, frames, log);
11562
+ if (summary.frames > 0) {
11563
+ this.finishOccupancyBurst(deviceId, log, summary);
10993
11564
  return;
10994
11565
  }
10995
11566
  log.debug("occupancy re-check: tap collected 0 frames, falling back to dial", { meta: { frames } });
@@ -11020,76 +11591,91 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
11020
11591
  fallback: alreadyInFlight
11021
11592
  } });
11022
11593
  let count = 0;
11594
+ /** Collection is over: no further frame is admitted to this burst. */
11595
+ let admissionClosed = false;
11596
+ /** The burst has reported its verdict (or its lack of one). */
11023
11597
  let settled = false;
11024
11598
  let teardown = null;
11025
- const cleanup = () => {
11599
+ const cleanup = (summary) => {
11026
11600
  if (settled) return;
11027
11601
  settled = true;
11028
- teardown?.();
11029
- this.finishOccupancyBurst(deviceId, log, count);
11602
+ this.finishOccupancyBurst(deviceId, log, summary);
11603
+ };
11604
+ const ledger = new OccupancyBurstLedger({
11605
+ settleBoundMs: OCCUPANCY_BURST_SETTLE_BOUND_MS,
11606
+ onComplete: cleanup
11607
+ });
11608
+ const closeAdmission = () => {
11609
+ if (admissionClosed) return;
11610
+ admissionClosed = true;
11611
+ clearTimeout(safetyTimer);
11612
+ teardown?.("drain");
11613
+ ledger.close();
11614
+ };
11615
+ /** Offer one collected frame to the burst; `true` = the runner admitted it. */
11616
+ const admit = (frame, handle) => {
11617
+ const settle = ledger.admit();
11618
+ const admitted = runner.enqueueOccupancyFrame(deviceId, frame, handle, settle);
11619
+ if (!admitted) settle("lost");
11620
+ count++;
11621
+ if (count >= frames) closeAdmission();
11622
+ return admitted;
11030
11623
  };
11031
11624
  const safetyTimer = setTimeout(() => {
11032
11625
  log.debug("occupancy re-check: safety timeout reached", { meta: { collected: count } });
11033
- cleanup();
11626
+ closeAdmission();
11034
11627
  }, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
11035
11628
  teardown = startGuardedSessionDecode({
11036
11629
  logger: log,
11037
11630
  admitWorker: this.buildLiveWorkerAdmit(deviceId, "occupancy"),
11631
+ ...this.liveWorkerSlotWaiter(deviceId),
11038
11632
  acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:occupancy:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config)),
11039
- startPump: (source) => {
11633
+ startPump: (source, hooks) => {
11040
11634
  const useLocalFrameRef = this.executorCoLocated() && process.env["CAMSTACK_DISABLE_LOCAL_FRAME_REF"] !== "1";
11041
11635
  return startSessionDecodePump({
11042
11636
  fork: this.forkDecodeWorkerWithLease,
11043
11637
  lease: this.resolveSessionLeaseKnobs,
11638
+ onWorkerGone: hooks.onWorkerGone,
11044
11639
  logger: log,
11045
11640
  source,
11046
11641
  opts: {
11047
11642
  fps: config.detectionFps,
11048
11643
  format: "rgb"
11049
11644
  },
11645
+ shouldMaterializeFrame: () => !admissionClosed,
11050
11646
  ...useLocalFrameRef ? {
11051
11647
  frameRegistry: localFrameRegistry,
11052
11648
  inlineWhen: () => runner.detectionBackpressure(config.deviceId),
11053
11649
  inlineViewSpec: SPLIT_DETECTION_VIEW,
11054
11650
  onFrameRef: (frame, nativeCrop) => {
11055
- if (settled) return false;
11651
+ if (admissionClosed) return false;
11056
11652
  const handle = writeRetainedDetectionFrame(retainedFrames, config.deviceId, frame, this.retention);
11057
11653
  if (handle && nativeCrop) {
11058
11654
  this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
11059
11655
  this.bindFrameCutter(handle, nativeCrop);
11060
11656
  }
11061
- const admitted = runner.enqueueOccupancyFrame(deviceId, frame, handle);
11657
+ const admitted = admit(frame, handle);
11062
11658
  if (admitted) nativeCrop?.markInferred();
11063
- count++;
11064
- if (count >= frames) {
11065
- clearTimeout(safetyTimer);
11066
- cleanup();
11067
- }
11068
11659
  return admitted;
11069
11660
  }
11070
11661
  } : { inlineViewSpec: SPLIT_DETECTION_VIEW },
11071
11662
  acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "occupancy"),
11072
- onEnded: () => cleanup(),
11663
+ onEnded: () => closeAdmission(),
11073
11664
  onDecodedFrame: (frame, nativeCrop) => {
11074
- if (settled) return;
11665
+ if (admissionClosed) return;
11075
11666
  const handle = writeRetainedDetectionFrame(retainedFrames, config.deviceId, frame, this.retention);
11076
11667
  if (handle && nativeCrop) {
11077
11668
  this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
11078
11669
  this.bindFrameCutter(handle, nativeCrop);
11079
11670
  }
11080
- if (runner.enqueueOccupancyFrame(deviceId, frame, handle)) nativeCrop?.markInferred();
11081
- count++;
11082
- if (count >= frames) {
11083
- clearTimeout(safetyTimer);
11084
- cleanup();
11085
- }
11671
+ if (admit(frame, handle)) nativeCrop?.markInferred();
11086
11672
  }
11087
11673
  });
11088
11674
  }
11089
11675
  });
11090
- if (settled) {
11676
+ if (admissionClosed) {
11091
11677
  clearTimeout(safetyTimer);
11092
- teardown();
11678
+ teardown("drain");
11093
11679
  }
11094
11680
  }
11095
11681
  emitInferenceResult(deviceId, frame, result, handle, source, occasion) {