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