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