@camstack/addon-pipeline 1.2.620 → 1.2.622
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/dist/audio-analyzer/index.js +3 -3
- package/dist/audio-analyzer/index.mjs +3 -3
- package/dist/{custom-model-catalog-YxQheit5.js → custom-model-catalog-CKCK-QcT.js} +7 -7
- package/dist/{custom-model-catalog-BW0-xhwJ.mjs → custom-model-catalog-wpMi2U7e.mjs} +7 -7
- package/dist/detection-pipeline/index.js +60 -414
- package/dist/detection-pipeline/index.mjs +84 -438
- package/dist/{dist-mOyFhoa8.mjs → dist-BF60SjmI.mjs} +6855 -1620
- package/dist/{dist-CoLjAw2z.js → dist-DUFGDd_2.js} +6866 -1625
- package/dist/{model-catalogs-BstZN0ZA.js → model-catalogs-CjTm_0O-.js} +1 -1
- package/dist/{model-catalogs-PmNZKpMR.mjs → model-catalogs-CqFDhb0B.mjs} +1 -1
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-Qmp6FkS2.mjs → node-DkphOOM2.mjs} +1 -1
- package/dist/{node-CzmiyMnw.js → node-DzGVn7bD.js} +1 -1
- package/dist/pipeline-runner/index.js +18 -4
- package/dist/pipeline-runner/index.mjs +18 -4
- package/dist/{process-memory-C5Cw8Cxz.js → process-memory-8kKexHYR.js} +1 -1
- package/dist/{process-memory-DXbuHvOX.mjs → process-memory-CBzfdSvC.mjs} +1 -1
- package/dist/recorder/index.js +10 -18
- package/dist/recorder/index.mjs +10 -18
- package/dist/{segment-demux-js-Din-INnw.mjs → segment-demux-js-DQNoNSJt.mjs} +1 -1
- package/dist/{segment-demux-js-DGFHiiIf.js → segment-demux-js-DRLQb1pc.js} +1 -1
- package/dist/session-decode/decode-worker-main.js +12 -21
- package/dist/session-decode/decode-worker-main.mjs +12 -21
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets--TcGAfJ-.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BmANAxAe.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-H6RKsaiZ.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-CNoWeZHH.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-DOiI--7i.mjs → hostInit-B3hXAfr1.mjs} +2 -2
- package/dist/stream-broker/index.js +3 -3
- package/dist/stream-broker/index.mjs +3 -3
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-D_NzPcnh.mjs → worker-protocol-DngGEAT2.js} +102 -2
- package/dist/{worker-protocol-C-G8qmye.js → worker-protocol-_avq7y8O.mjs} +67 -25
- package/package.json +1 -1
- package/python/coreml_load_probe.py +105 -15
- package/python/openvino_compile_probe.py +72 -18
- package/python/test_coreml_load_probe.py +83 -0
- package/python/test_openvino_compile_probe.py +114 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BaoBRCJs.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-Dx-WrMx0.mjs +0 -26
|
@@ -60,6 +60,71 @@ function formatNativeLeaseKnobs(resolved) {
|
|
|
60
60
|
return `native lease knobs — ${NATIVE_LEASE_KNOB_NAMES.map(part).join("; ")}`;
|
|
61
61
|
}
|
|
62
62
|
//#endregion
|
|
63
|
+
//#region src/session-decode/session-hwaccel.ts
|
|
64
|
+
var KNOWN_BACKENDS = [
|
|
65
|
+
"videotoolbox",
|
|
66
|
+
"cuda",
|
|
67
|
+
"nvdec",
|
|
68
|
+
"vaapi",
|
|
69
|
+
"qsv",
|
|
70
|
+
"d3d11va",
|
|
71
|
+
"dxva2",
|
|
72
|
+
"amf",
|
|
73
|
+
"vdpau",
|
|
74
|
+
"drm"
|
|
75
|
+
];
|
|
76
|
+
function isHwAccelBackend(value) {
|
|
77
|
+
return typeof value === "string" && KNOWN_BACKENDS.includes(value);
|
|
78
|
+
}
|
|
79
|
+
/** Wire guard for the `start` message's `hwaccel` field. */
|
|
80
|
+
function isSessionHwaccelOffer(value) {
|
|
81
|
+
return value === null || Array.isArray(value) && value.every(isHwAccelBackend);
|
|
82
|
+
}
|
|
83
|
+
/** The worker's decode order per platform — first successful create() wins. */
|
|
84
|
+
function platformDecodeOrder(platform) {
|
|
85
|
+
switch (platform) {
|
|
86
|
+
case "darwin": return ["videotoolbox"];
|
|
87
|
+
case "linux": return [
|
|
88
|
+
"vaapi",
|
|
89
|
+
"qsv",
|
|
90
|
+
"cuda"
|
|
91
|
+
];
|
|
92
|
+
case "win32": return [
|
|
93
|
+
"d3d11va",
|
|
94
|
+
"dxva2",
|
|
95
|
+
"cuda"
|
|
96
|
+
];
|
|
97
|
+
default: return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The platform order, restricted to what the resolver offers. An empty offer
|
|
102
|
+
* (VA init failed and no other GPU) means software, with no per-session try.
|
|
103
|
+
*/
|
|
104
|
+
function sessionDecodeCandidates(platform, offer) {
|
|
105
|
+
const order = platformDecodeOrder(platform);
|
|
106
|
+
if (offer === null) return order;
|
|
107
|
+
return order.filter((backend) => offer.includes(backend));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The parent's copy of the resolver's auto answer, refreshed on the acquire
|
|
111
|
+
* leg of every session spawn and read synchronously when `start` is sent. A
|
|
112
|
+
* resolve that fails changes nothing (D49): the last answer stands.
|
|
113
|
+
*/
|
|
114
|
+
var SessionHwaccelSource = class {
|
|
115
|
+
offer = null;
|
|
116
|
+
current() {
|
|
117
|
+
return this.offer;
|
|
118
|
+
}
|
|
119
|
+
async refresh(resolver) {
|
|
120
|
+
if (!resolver) return;
|
|
121
|
+
try {
|
|
122
|
+
const res = await resolver.resolve(null);
|
|
123
|
+
this.offer = [...res.preferred];
|
|
124
|
+
} catch {}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
//#endregion
|
|
63
128
|
//#region src/session-decode/worker-protocol.ts
|
|
64
129
|
/**
|
|
65
130
|
* Typed IPC protocol between the per-session decode coordinator (Task 3 —
|
|
@@ -129,7 +194,7 @@ function isVideoFrameGenOptions(value) {
|
|
|
129
194
|
function isWorkerRequest(value) {
|
|
130
195
|
if (!isRecord(value)) return false;
|
|
131
196
|
switch (value.kind) {
|
|
132
|
-
case "start": return isVideoFrameSource(value.source) && isVideoFrameGenOptions(value.opts) && isWorkerLeaseKnobs(value.lease);
|
|
197
|
+
case "start": return isVideoFrameSource(value.source) && isVideoFrameGenOptions(value.opts) && isWorkerLeaseKnobs(value.lease) && isSessionHwaccelOffer(value.hwaccel);
|
|
133
198
|
case "pull": return true;
|
|
134
199
|
case "toBuffer": return isFiniteNumber(value.frameId) && (value.opts === void 0 || isFrameImageOptions(value.opts));
|
|
135
200
|
case "nativeCrop": return isFiniteNumber(value.requestId) && isFiniteNumber(value.frameId) && isNormalizedBbox(value.bbox) && (value.maxWidth === void 0 || isFiniteNumber(value.maxWidth));
|
|
@@ -160,27 +225,4 @@ function isWorkerReply(value) {
|
|
|
160
225
|
}
|
|
161
226
|
}
|
|
162
227
|
//#endregion
|
|
163
|
-
|
|
164
|
-
enumerable: true,
|
|
165
|
-
get: function() {
|
|
166
|
-
return formatNativeLeaseKnobs;
|
|
167
|
-
}
|
|
168
|
-
});
|
|
169
|
-
Object.defineProperty(exports, "isWorkerReply", {
|
|
170
|
-
enumerable: true,
|
|
171
|
-
get: function() {
|
|
172
|
-
return isWorkerReply;
|
|
173
|
-
}
|
|
174
|
-
});
|
|
175
|
-
Object.defineProperty(exports, "isWorkerRequest", {
|
|
176
|
-
enumerable: true,
|
|
177
|
-
get: function() {
|
|
178
|
-
return isWorkerRequest;
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
Object.defineProperty(exports, "logLevelForLine", {
|
|
182
|
-
enumerable: true,
|
|
183
|
-
get: function() {
|
|
184
|
-
return logLevelForLine;
|
|
185
|
-
}
|
|
186
|
-
});
|
|
228
|
+
export { sessionDecodeCandidates as a, SessionHwaccelSource as i, isWorkerRequest as n, formatNativeLeaseKnobs as o, logLevelForLine as r, isWorkerReply as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-pipeline",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.622",
|
|
4
4
|
"description": "Pipeline bundle — runner, detection, motion, audio + stream broker. Multi-entry npm package shipping pipeline addons under a single bundle.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|
|
@@ -12,8 +12,11 @@ happens in a throwaway interpreter. The verdict is remembered next to the
|
|
|
12
12
|
compiled-model cache:
|
|
13
13
|
|
|
14
14
|
ok -> load in-process, never probe this package again
|
|
15
|
-
crashed -> refuse with `CoremlLoadCrashed` (reason `native-load-crashed`)
|
|
16
|
-
now
|
|
15
|
+
crashed -> refuse with `CoremlLoadCrashed` (reason `native-load-crashed`)
|
|
16
|
+
now. ONE crash is only `suspect` (D926): refused without
|
|
17
|
+
probing for a cool-off, then probed again. A SECOND crash
|
|
18
|
+
at least a cool-off after the first caches `crashed`, refused
|
|
19
|
+
on every later load without re-probing; an `ok` resets.
|
|
17
20
|
inconclusive -> (a Python exception in the child, a probe past its bound, a
|
|
18
21
|
child that could not start) remembered NOWHERE; the
|
|
19
22
|
in-process load runs exactly as it did before this gate
|
|
@@ -33,6 +36,7 @@ import platform
|
|
|
33
36
|
import signal
|
|
34
37
|
import subprocess
|
|
35
38
|
import sys
|
|
39
|
+
import time
|
|
36
40
|
from typing import Callable, Optional
|
|
37
41
|
|
|
38
42
|
# One-way: inference_pool imports this module lazily, inside its CoreML branch.
|
|
@@ -43,6 +47,63 @@ VERDICT_CRASHED = "crashed"
|
|
|
43
47
|
VERDICT_INCONCLUSIVE = "inconclusive"
|
|
44
48
|
|
|
45
49
|
PROBE_VERDICT_VERSION = 1
|
|
50
|
+
|
|
51
|
+
# The two-crash rule, shared with `openvino_compile_probe` (D926, D49): one
|
|
52
|
+
# native crash is `suspect`; this many crashes, the last at least a cool-off
|
|
53
|
+
# after the first, is `crashed` and cached for good. A v1 `crashed` record
|
|
54
|
+
# (no `crashes` list) reads as ONE crash of unknown age, so it is re-probed.
|
|
55
|
+
VERDICT_SUSPECT = "suspect"
|
|
56
|
+
CRASHES_TO_CONFIRM = 2
|
|
57
|
+
CRASH_COOL_OFF_SEC = 600.0
|
|
58
|
+
|
|
59
|
+
Clock = Callable[[], float]
|
|
60
|
+
LoadAvg = Callable[[], "tuple[float, float, float]"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def host_loadavg() -> "tuple[float, float, float]":
|
|
64
|
+
try:
|
|
65
|
+
return os.getloadavg()
|
|
66
|
+
except (OSError, AttributeError): # Windows has no load average
|
|
67
|
+
return (-1.0, -1.0, -1.0)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def crash_at(crash: object) -> float:
|
|
71
|
+
if isinstance(crash, dict):
|
|
72
|
+
at = crash.get("at")
|
|
73
|
+
if isinstance(at, (int, float)):
|
|
74
|
+
return float(at)
|
|
75
|
+
return 0.0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def crashes_confirmed(crashes: list) -> bool:
|
|
79
|
+
"""Two crashes (or more), the last at least a cool-off after the first."""
|
|
80
|
+
if len(crashes) < CRASHES_TO_CONFIRM:
|
|
81
|
+
return False
|
|
82
|
+
return crash_at(crashes[-1]) - crash_at(crashes[0]) >= CRASH_COOL_OFF_SEC
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def normalise_crash_record(stored: dict) -> Optional[dict]:
|
|
86
|
+
"""A stored verdict with its crash list filled in; an unconfirmed `crashed` reads `suspect`."""
|
|
87
|
+
verdict = stored.get("verdict")
|
|
88
|
+
if verdict not in (VERDICT_OK, VERDICT_CRASHED, VERDICT_SUSPECT):
|
|
89
|
+
return None
|
|
90
|
+
if verdict == VERDICT_OK:
|
|
91
|
+
return stored
|
|
92
|
+
crashes = stored.get("crashes")
|
|
93
|
+
if not isinstance(crashes, list) or not crashes:
|
|
94
|
+
crashes = [{"at": 0.0, "loadavg": None, "detail": str(stored.get("detail") or "")}]
|
|
95
|
+
out = {**stored, "crashes": crashes}
|
|
96
|
+
if verdict == VERDICT_CRASHED and not crashes_confirmed(crashes):
|
|
97
|
+
out = {**out, "verdict": VERDICT_SUSPECT}
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def record_crash(
|
|
102
|
+
prior: list, detail: str, now: float, load: "tuple[float, float, float]",
|
|
103
|
+
) -> "tuple[str, list]":
|
|
104
|
+
"""Append one crash; return the verdict to store (`suspect` or `crashed`) and the list."""
|
|
105
|
+
crashes = [*prior, {"at": now, "loadavg": [round(x, 2) for x in load], "detail": detail}]
|
|
106
|
+
return (VERDICT_CRASHED if crashes_confirmed(crashes) else VERDICT_SUSPECT), crashes
|
|
46
107
|
PROBE_MARKER = "@@CAMSTACK_COREML_PROBE_OK@@"
|
|
47
108
|
# Long enough for the slowest cold compile measured on a Mac (rfdetr-package,
|
|
48
109
|
# 58 s); the pool's own load bound reports a slow load to the host meanwhile.
|
|
@@ -169,18 +230,21 @@ def _read_verdict(path: str, key: dict) -> Optional[dict]:
|
|
|
169
230
|
return None
|
|
170
231
|
if not isinstance(stored, dict) or stored.get("key") != key:
|
|
171
232
|
return None
|
|
172
|
-
|
|
173
|
-
return None
|
|
174
|
-
return stored
|
|
233
|
+
return normalise_crash_record(stored)
|
|
175
234
|
|
|
176
235
|
|
|
177
|
-
def _write_verdict(
|
|
236
|
+
def _write_verdict(
|
|
237
|
+
path: str, key: dict, verdict: str, detail: str, crashes: "Optional[list]" = None,
|
|
238
|
+
) -> None:
|
|
178
239
|
"""Atomic replace; a verdict that cannot be written only costs a re-probe."""
|
|
179
240
|
tmp = f"{path}.tmp-{os.getpid()}"
|
|
241
|
+
record: dict = {"key": key, "verdict": verdict, "detail": detail}
|
|
242
|
+
if crashes:
|
|
243
|
+
record["crashes"] = crashes
|
|
180
244
|
try:
|
|
181
245
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
182
246
|
with open(tmp, "w", encoding="utf-8") as fh:
|
|
183
|
-
json.dump(
|
|
247
|
+
json.dump(record, fh)
|
|
184
248
|
os.replace(tmp, path)
|
|
185
249
|
except OSError as exc:
|
|
186
250
|
sys.stderr.write(f"CoreML probe: could not persist the verdict for {path} ({exc})\n")
|
|
@@ -196,8 +260,11 @@ def ensure_coreml_loadable(
|
|
|
196
260
|
device: str,
|
|
197
261
|
run: Optional[ProbeRunner] = None,
|
|
198
262
|
os_build: Optional[str] = None,
|
|
263
|
+
now: Optional[Clock] = None,
|
|
264
|
+
loadavg: Optional[LoadAvg] = None,
|
|
199
265
|
) -> None:
|
|
200
266
|
"""Return when `model_path` may be loaded in-process; raise when it would crash."""
|
|
267
|
+
clock = now if now is not None else time.time
|
|
201
268
|
key = {
|
|
202
269
|
"version": PROBE_VERDICT_VERSION,
|
|
203
270
|
"fingerprint": _coreml_model_fingerprint(model_path), # a re-download is a new package
|
|
@@ -207,17 +274,40 @@ def ensure_coreml_loadable(
|
|
|
207
274
|
verdict_path = probe_verdict_path(model_path, device)
|
|
208
275
|
known = _read_verdict(verdict_path, key)
|
|
209
276
|
name = os.path.basename(model_path)
|
|
277
|
+
prior_crashes: list = []
|
|
210
278
|
if known is not None:
|
|
211
279
|
if known["verdict"] == VERDICT_CRASHED:
|
|
212
|
-
|
|
213
|
-
|
|
280
|
+
_log(f"{name} device={device} is a KNOWN loader crash "
|
|
281
|
+
f"({len(known['crashes'])} crashes) — refused")
|
|
214
282
|
raise CoremlLoadCrashed(model_path, device, str(known.get("detail") or "known crash"))
|
|
215
|
-
|
|
283
|
+
if known["verdict"] == VERDICT_OK:
|
|
284
|
+
return
|
|
285
|
+
prior_crashes = list(known["crashes"])
|
|
286
|
+
retry_at = crash_at(prior_crashes[-1]) + CRASH_COOL_OFF_SEC
|
|
287
|
+
if clock() < retry_at:
|
|
288
|
+
_log(f"{name} device={device} crashed the loader once — refused until the "
|
|
289
|
+
f"cool-off ends ({retry_at - clock():.0f}s left)")
|
|
290
|
+
raise CoremlLoadCrashed(
|
|
291
|
+
model_path, device,
|
|
292
|
+
f"{known.get('detail') or 'one crash'}; one crash so far, "
|
|
293
|
+
f"re-probed after {CRASH_COOL_OFF_SEC:g}s",
|
|
294
|
+
)
|
|
216
295
|
runner = run if run is not None else DEFAULT_RUNNER
|
|
217
296
|
verdict, detail = runner(model_path, device)
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if verdict in (VERDICT_OK, VERDICT_CRASHED):
|
|
297
|
+
_log(f"{name} device={device} -> {verdict} ({detail})")
|
|
298
|
+
if verdict == VERDICT_OK:
|
|
221
299
|
_write_verdict(verdict_path, key, verdict, detail)
|
|
222
|
-
|
|
223
|
-
|
|
300
|
+
return
|
|
301
|
+
if verdict != VERDICT_CRASHED:
|
|
302
|
+
return # inconclusive: remembered nowhere; an earlier suspect stands
|
|
303
|
+
load = (loadavg if loadavg is not None else host_loadavg)()
|
|
304
|
+
stored, crashes = record_crash(prior_crashes, detail, clock(), load)
|
|
305
|
+
_log(f"{name} device={device} loader crash {len(crashes)}/{CRASHES_TO_CONFIRM} "
|
|
306
|
+
f"(load {load[0]:.1f}) -> {stored}")
|
|
307
|
+
_write_verdict(verdict_path, key, stored, detail, crashes)
|
|
308
|
+
raise CoremlLoadCrashed(model_path, device, detail)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _log(line: str) -> None:
|
|
312
|
+
sys.stderr.write(f"CoreML probe: {line}\n")
|
|
313
|
+
sys.stderr.flush()
|
|
@@ -19,13 +19,24 @@ therefore writes the blob the pool then loads warm. Mirrors CoreML's
|
|
|
19
19
|
ok -> load in-process (a cache HIT), never probe this key again
|
|
20
20
|
while the blobs the probe wrote are still on disk
|
|
21
21
|
crashed -> refuse with `OpenvinoLoadCrashed` (reason
|
|
22
|
-
`native-load-crashed`)
|
|
23
|
-
|
|
22
|
+
`native-load-crashed`) now. ONE crash is only `suspect`:
|
|
23
|
+
later loads are refused without probing until a cool-off
|
|
24
|
+
(CRASH_COOL_OFF_SEC) has passed, then probed again. Only a
|
|
25
|
+
SECOND crash at least a cool-off after the first caches
|
|
26
|
+
`crashed` — refused on every later load of the same key,
|
|
27
|
+
without re-probing. An `ok` in between resets the count.
|
|
24
28
|
inconclusive -> (a Python exception in the child, a probe past its bound, a
|
|
25
29
|
child that could not start) remembered NOWHERE; the
|
|
26
30
|
in-process load runs exactly as it did before this gate
|
|
27
31
|
|
|
28
32
|
Only a death by SIGNAL is a crash. CPU-only loads are never probed.
|
|
33
|
+
|
|
34
|
+
Why two (2026-10-23, D926): a single SIGSEGV during an overloaded moment wrote
|
|
35
|
+
`crashed`, and every later request for that model on the GPU was refused for
|
|
36
|
+
good. A destructive verdict needs a second agreeing read (D49). Every crash is
|
|
37
|
+
recorded with its time, the host load average and the child's detail. To
|
|
38
|
+
clear a verdict by hand, delete
|
|
39
|
+
`<models>/.ov-cache/<model>.<DEVICE>.ovprobe.json`.
|
|
29
40
|
"""
|
|
30
41
|
from __future__ import annotations
|
|
31
42
|
|
|
@@ -33,17 +44,30 @@ import json
|
|
|
33
44
|
import os
|
|
34
45
|
import subprocess
|
|
35
46
|
import sys
|
|
47
|
+
import time
|
|
36
48
|
from typing import Callable, Optional
|
|
37
49
|
|
|
38
50
|
# One-way: inference_pool imports this module lazily, inside its OpenVINO branch.
|
|
51
|
+
# The two-crash rule (D926) lives there too, shared by both probes.
|
|
39
52
|
from coreml_load_probe import (
|
|
53
|
+
CRASH_COOL_OFF_SEC,
|
|
54
|
+
CRASHES_TO_CONFIRM,
|
|
40
55
|
VERDICT_CRASHED,
|
|
41
56
|
VERDICT_INCONCLUSIVE,
|
|
42
57
|
VERDICT_OK,
|
|
58
|
+
VERDICT_SUSPECT,
|
|
59
|
+
Clock,
|
|
60
|
+
LoadAvg,
|
|
43
61
|
_signal_name,
|
|
44
62
|
classify_probe_exit,
|
|
63
|
+
crash_at,
|
|
64
|
+
host_loadavg,
|
|
65
|
+
normalise_crash_record,
|
|
66
|
+
record_crash,
|
|
45
67
|
)
|
|
46
68
|
|
|
69
|
+
# The key's version stays 1: a v1 `crashed` file (no `crashes` list) is read
|
|
70
|
+
# as ONE crash of unknown age, so it is re-probed rather than discarded.
|
|
47
71
|
PROBE_VERDICT_VERSION = 1
|
|
48
72
|
PROBE_MARKER = "@@CAMSTACK_OV_PROBE_OK@@"
|
|
49
73
|
# A cold iGPU compile of a large detector takes tens of seconds; past this the
|
|
@@ -178,17 +202,21 @@ def _read_verdict(path: str, key: dict) -> Optional[dict]:
|
|
|
178
202
|
return None
|
|
179
203
|
if not isinstance(stored, dict) or stored.get("key") != key:
|
|
180
204
|
return None
|
|
181
|
-
|
|
182
|
-
return None
|
|
183
|
-
return stored
|
|
205
|
+
return normalise_crash_record(stored)
|
|
184
206
|
|
|
185
207
|
|
|
186
|
-
def _write_verdict(
|
|
208
|
+
def _write_verdict(
|
|
209
|
+
path: str, key: dict, verdict: str, detail: str, blobs: "list[str]",
|
|
210
|
+
crashes: "Optional[list]" = None,
|
|
211
|
+
) -> None:
|
|
187
212
|
"""Atomic replace; a verdict that cannot be written only costs a re-probe."""
|
|
188
213
|
tmp = f"{path}.tmp-{os.getpid()}"
|
|
214
|
+
record: dict = {"key": key, "verdict": verdict, "detail": detail, "blobs": blobs}
|
|
215
|
+
if crashes:
|
|
216
|
+
record["crashes"] = crashes
|
|
189
217
|
try:
|
|
190
218
|
with open(tmp, "w", encoding="utf-8") as fh:
|
|
191
|
-
json.dump(
|
|
219
|
+
json.dump(record, fh)
|
|
192
220
|
os.replace(tmp, path)
|
|
193
221
|
except OSError as exc:
|
|
194
222
|
sys.stderr.write(f"OpenVINO probe: could not persist the verdict for {path} ({exc})\n")
|
|
@@ -214,32 +242,58 @@ def ensure_openvino_loadable(
|
|
|
214
242
|
cache_dir: Optional[str],
|
|
215
243
|
env_tag: str = "",
|
|
216
244
|
run: Optional[ProbeRunner] = None,
|
|
245
|
+
now: Optional[Clock] = None,
|
|
246
|
+
loadavg: Optional[LoadAvg] = None,
|
|
217
247
|
) -> None:
|
|
218
248
|
"""Return when `model_path` may be compiled in-process; raise when it would crash."""
|
|
219
249
|
if not needs_probe(candidates):
|
|
220
250
|
return
|
|
251
|
+
clock = now if now is not None else time.time
|
|
221
252
|
name = os.path.basename(model_path)
|
|
222
253
|
device = str(config.get("device", "AUTO"))
|
|
223
254
|
key = probe_key(model_path, config, candidates, env_tag)
|
|
224
255
|
verdict_path = probe_verdict_path(model_path, config, cache_dir) if cache_dir else None
|
|
256
|
+
prior_crashes: list = []
|
|
225
257
|
if verdict_path is not None:
|
|
226
258
|
known = _read_verdict(verdict_path, key)
|
|
227
259
|
if known is not None:
|
|
228
260
|
if known["verdict"] == VERDICT_CRASHED:
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
)
|
|
232
|
-
sys.stderr.flush()
|
|
261
|
+
_log(f"{name} device={device} is a KNOWN compiler crash "
|
|
262
|
+
f"({len(known['crashes'])} crashes) — refused")
|
|
233
263
|
raise OpenvinoLoadCrashed(model_path, device, str(known.get("detail") or "known crash"))
|
|
234
|
-
if
|
|
264
|
+
if known["verdict"] == VERDICT_SUSPECT:
|
|
265
|
+
prior_crashes = list(known["crashes"])
|
|
266
|
+
retry_at = crash_at(prior_crashes[-1]) + CRASH_COOL_OFF_SEC
|
|
267
|
+
if clock() < retry_at:
|
|
268
|
+
_log(f"{name} device={device} crashed the compiler once — refused until "
|
|
269
|
+
f"the cool-off ends ({retry_at - clock():.0f}s left)")
|
|
270
|
+
raise OpenvinoLoadCrashed(
|
|
271
|
+
model_path, device,
|
|
272
|
+
f"{known.get('detail') or 'one crash'}; one crash so far, "
|
|
273
|
+
f"re-probed after {CRASH_COOL_OFF_SEC:g}s",
|
|
274
|
+
)
|
|
275
|
+
elif cache_dir is not None and _warm(known, cache_dir):
|
|
235
276
|
return # blob present: the in-process load is a cache hit
|
|
236
277
|
before = _blob_names(cache_dir) if cache_dir else set()
|
|
237
278
|
runner = run if run is not None else DEFAULT_RUNNER
|
|
238
279
|
verdict, detail = runner(model_path, config)
|
|
239
|
-
|
|
280
|
+
_log(f"{name} device={device} -> {verdict} ({detail})")
|
|
281
|
+
if verdict == VERDICT_OK:
|
|
282
|
+
if verdict_path is not None and cache_dir is not None:
|
|
283
|
+
written = sorted(_blob_names(cache_dir) - before)
|
|
284
|
+
_write_verdict(verdict_path, key, verdict, detail, written)
|
|
285
|
+
return
|
|
286
|
+
if verdict != VERDICT_CRASHED:
|
|
287
|
+
return # inconclusive: remembered nowhere; an earlier suspect stands
|
|
288
|
+
load = (loadavg if loadavg is not None else host_loadavg)()
|
|
289
|
+
stored, crashes = record_crash(prior_crashes, detail, clock(), load)
|
|
290
|
+
_log(f"{name} device={device} compiler crash {len(crashes)}/{CRASHES_TO_CONFIRM} "
|
|
291
|
+
f"(load {load[0]:.1f}) -> {stored}")
|
|
292
|
+
if verdict_path is not None and cache_dir is not None:
|
|
293
|
+
_write_verdict(verdict_path, key, stored, detail, [], crashes)
|
|
294
|
+
raise OpenvinoLoadCrashed(model_path, device, detail)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _log(line: str) -> None:
|
|
298
|
+
sys.stderr.write(f"OpenVINO probe: {line}\n")
|
|
240
299
|
sys.stderr.flush()
|
|
241
|
-
if verdict in (VERDICT_OK, VERDICT_CRASHED) and verdict_path is not None and cache_dir is not None:
|
|
242
|
-
written = sorted(_blob_names(cache_dir) - before) if verdict == VERDICT_OK else []
|
|
243
|
-
_write_verdict(verdict_path, key, verdict, detail, written)
|
|
244
|
-
if verdict == VERDICT_CRASHED:
|
|
245
|
-
raise OpenvinoLoadCrashed(model_path, device, detail)
|
|
@@ -224,6 +224,89 @@ class EnsureCoremlLoadableTest(unittest.TestCase):
|
|
|
224
224
|
self.assertEqual(len(runner.calls), 1)
|
|
225
225
|
|
|
226
226
|
|
|
227
|
+
class CoremlCrashNeedsASecondAgreeingReadTest(unittest.TestCase):
|
|
228
|
+
"""One native abort must not ban a package forever (D926, D49)."""
|
|
229
|
+
|
|
230
|
+
def setUp(self) -> None:
|
|
231
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
232
|
+
self.pkg = _make_mlpackage(self._tmp.name)
|
|
233
|
+
self.t = 2_000_000.0
|
|
234
|
+
|
|
235
|
+
def tearDown(self) -> None:
|
|
236
|
+
self._tmp.cleanup()
|
|
237
|
+
|
|
238
|
+
def ensure(self, runner) -> None:
|
|
239
|
+
probe.ensure_coreml_loadable(
|
|
240
|
+
self.pkg, "all", run=runner, os_build="26.2",
|
|
241
|
+
now=lambda: self.t, loadavg=lambda: (4.0, 3.0, 2.0),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def stored(self) -> dict:
|
|
245
|
+
with open(probe.probe_verdict_path(self.pkg, "all"), "r", encoding="utf-8") as fh:
|
|
246
|
+
return json.load(fh)
|
|
247
|
+
|
|
248
|
+
def test_a_single_crash_is_retried_after_the_cool_off(self) -> None:
|
|
249
|
+
with self.assertRaises(probe.CoremlLoadCrashed):
|
|
250
|
+
self.ensure(_Runner(probe.VERDICT_CRASHED, "signal 6"))
|
|
251
|
+
self.assertEqual(self.stored()["verdict"], probe.VERDICT_SUSPECT)
|
|
252
|
+
self.t += probe.CRASH_COOL_OFF_SEC + 1
|
|
253
|
+
ok = _Runner(probe.VERDICT_OK)
|
|
254
|
+
self.ensure(ok)
|
|
255
|
+
self.assertEqual(len(ok.calls), 1)
|
|
256
|
+
self.assertEqual(self.stored()["verdict"], probe.VERDICT_OK)
|
|
257
|
+
|
|
258
|
+
def test_within_the_cool_off_a_single_crash_refuses_without_probing(self) -> None:
|
|
259
|
+
crashed = _Runner(probe.VERDICT_CRASHED)
|
|
260
|
+
with self.assertRaises(probe.CoremlLoadCrashed):
|
|
261
|
+
self.ensure(crashed)
|
|
262
|
+
self.t += probe.CRASH_COOL_OFF_SEC - 1
|
|
263
|
+
with self.assertRaises(probe.CoremlLoadCrashed):
|
|
264
|
+
self.ensure(crashed)
|
|
265
|
+
self.assertEqual(len(crashed.calls), 1)
|
|
266
|
+
|
|
267
|
+
def test_two_crashes_a_cool_off_apart_are_cached(self) -> None:
|
|
268
|
+
crashed = _Runner(probe.VERDICT_CRASHED)
|
|
269
|
+
with self.assertRaises(probe.CoremlLoadCrashed):
|
|
270
|
+
self.ensure(crashed)
|
|
271
|
+
self.t += probe.CRASH_COOL_OFF_SEC + 1
|
|
272
|
+
with self.assertRaises(probe.CoremlLoadCrashed):
|
|
273
|
+
self.ensure(crashed)
|
|
274
|
+
stored = self.stored()
|
|
275
|
+
self.assertEqual(stored["verdict"], probe.VERDICT_CRASHED)
|
|
276
|
+
self.assertEqual(len(stored["crashes"]), 2)
|
|
277
|
+
self.assertEqual(stored["crashes"][0]["loadavg"], [4.0, 3.0, 2.0])
|
|
278
|
+
self.t += 10 * probe.CRASH_COOL_OFF_SEC
|
|
279
|
+
with self.assertRaises(probe.CoremlLoadCrashed):
|
|
280
|
+
self.ensure(crashed)
|
|
281
|
+
self.assertEqual(len(crashed.calls), 2)
|
|
282
|
+
|
|
283
|
+
def test_a_success_in_between_resets_the_count(self) -> None:
|
|
284
|
+
crashed = _Runner(probe.VERDICT_CRASHED)
|
|
285
|
+
with self.assertRaises(probe.CoremlLoadCrashed):
|
|
286
|
+
self.ensure(crashed)
|
|
287
|
+
self.t += probe.CRASH_COOL_OFF_SEC + 1
|
|
288
|
+
self.ensure(_Runner(probe.VERDICT_OK))
|
|
289
|
+
# An ok is remembered for good; a changed OS build is what re-probes it.
|
|
290
|
+
with open(probe.probe_verdict_path(self.pkg, "all"), "r", encoding="utf-8") as fh:
|
|
291
|
+
record = json.load(fh)
|
|
292
|
+
self.assertNotIn("crashes", record)
|
|
293
|
+
|
|
294
|
+
def test_a_legacy_single_crash_verdict_is_reprobed(self) -> None:
|
|
295
|
+
key = {
|
|
296
|
+
"version": probe.PROBE_VERDICT_VERSION,
|
|
297
|
+
"fingerprint": ip._coreml_model_fingerprint(self.pkg),
|
|
298
|
+
"device": "all",
|
|
299
|
+
"os": "26.2",
|
|
300
|
+
}
|
|
301
|
+
path = probe.probe_verdict_path(self.pkg, "all")
|
|
302
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
303
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
304
|
+
json.dump({"key": key, "verdict": probe.VERDICT_CRASHED, "detail": "sig 6"}, fh)
|
|
305
|
+
ok = _Runner(probe.VERDICT_OK)
|
|
306
|
+
self.ensure(ok)
|
|
307
|
+
self.assertEqual(len(ok.calls), 1)
|
|
308
|
+
|
|
309
|
+
|
|
227
310
|
class _FakeCt:
|
|
228
311
|
"""The slice of coremltools `_load_model` touches before the model loads."""
|
|
229
312
|
|
|
@@ -217,6 +217,120 @@ class EnsureOpenvinoLoadableTest(unittest.TestCase):
|
|
|
217
217
|
self.assertEqual(json.load(fh)["verdict"], probe.VERDICT_OK)
|
|
218
218
|
|
|
219
219
|
|
|
220
|
+
class _Clock:
|
|
221
|
+
"""A settable wall clock (seconds) handed to the probe as `now`."""
|
|
222
|
+
|
|
223
|
+
def __init__(self, start: float = 1_000_000.0) -> None:
|
|
224
|
+
self.t = start
|
|
225
|
+
|
|
226
|
+
def __call__(self) -> float:
|
|
227
|
+
return self.t
|
|
228
|
+
|
|
229
|
+
def advance(self, seconds: float) -> None:
|
|
230
|
+
self.t += seconds
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class CrashNeedsASecondAgreeingReadTest(unittest.TestCase):
|
|
234
|
+
"""One SIGSEGV during an overloaded moment must not ban a model forever (D49).
|
|
235
|
+
|
|
236
|
+
Measured 2026-10-23: a single probe crash on the hub GPU wrote `crashed`
|
|
237
|
+
and every later load of the model was refused `native-load-crashed`.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
def setUp(self) -> None:
|
|
241
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
242
|
+
self.model = os.path.join(self._tmp.name, "yolov9m-320-fp16.xml")
|
|
243
|
+
_write(self.model, b"<net/>")
|
|
244
|
+
self.cache = os.path.join(self._tmp.name, ".ov-cache")
|
|
245
|
+
os.makedirs(self.cache)
|
|
246
|
+
self.clock = _Clock()
|
|
247
|
+
|
|
248
|
+
def tearDown(self) -> None:
|
|
249
|
+
self._tmp.cleanup()
|
|
250
|
+
|
|
251
|
+
def ensure(self, runner) -> None:
|
|
252
|
+
probe.ensure_openvino_loadable(
|
|
253
|
+
self.model, GPU_CONFIG, ["GPU"], self.cache, env_tag="2025.4.1",
|
|
254
|
+
run=runner, now=self.clock, loadavg=lambda: (13.9, 9.1, 6.2),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def stored(self) -> dict:
|
|
258
|
+
path = probe.probe_verdict_path(self.model, GPU_CONFIG, self.cache)
|
|
259
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
260
|
+
return json.load(fh)
|
|
261
|
+
|
|
262
|
+
def test_a_single_crash_is_retried_after_the_cool_off(self) -> None:
|
|
263
|
+
crashed = _Runner(probe.VERDICT_CRASHED, "probe child died by signal 11 (SIGSEGV)")
|
|
264
|
+
with self.assertRaises(probe.OpenvinoLoadCrashed):
|
|
265
|
+
self.ensure(crashed)
|
|
266
|
+
self.clock.advance(probe.CRASH_COOL_OFF_SEC + 1)
|
|
267
|
+
ok = _Runner(probe.VERDICT_OK)
|
|
268
|
+
self.ensure(ok) # no raise: the second read disagreed
|
|
269
|
+
self.assertEqual(len(ok.calls), 1)
|
|
270
|
+
self.assertEqual(self.stored()["verdict"], probe.VERDICT_OK)
|
|
271
|
+
|
|
272
|
+
def test_within_the_cool_off_a_single_crash_refuses_without_probing(self) -> None:
|
|
273
|
+
crashed = _Runner(probe.VERDICT_CRASHED)
|
|
274
|
+
with self.assertRaises(probe.OpenvinoLoadCrashed):
|
|
275
|
+
self.ensure(crashed)
|
|
276
|
+
self.clock.advance(probe.CRASH_COOL_OFF_SEC - 1)
|
|
277
|
+
with self.assertRaises(probe.OpenvinoLoadCrashed) as ctx:
|
|
278
|
+
self.ensure(crashed)
|
|
279
|
+
self.assertEqual(len(crashed.calls), 1)
|
|
280
|
+
self.assertIn("re-probed after", str(ctx.exception))
|
|
281
|
+
|
|
282
|
+
def test_a_single_crash_is_stored_as_suspect_not_crashed(self) -> None:
|
|
283
|
+
with self.assertRaises(probe.OpenvinoLoadCrashed):
|
|
284
|
+
self.ensure(_Runner(probe.VERDICT_CRASHED, "signal 11"))
|
|
285
|
+
stored = self.stored()
|
|
286
|
+
self.assertEqual(stored["verdict"], probe.VERDICT_SUSPECT)
|
|
287
|
+
self.assertEqual(len(stored["crashes"]), 1)
|
|
288
|
+
crash = stored["crashes"][0]
|
|
289
|
+
self.assertEqual(crash["at"], self.clock.t)
|
|
290
|
+
self.assertEqual(crash["loadavg"], [13.9, 9.1, 6.2])
|
|
291
|
+
self.assertEqual(crash["detail"], "signal 11")
|
|
292
|
+
|
|
293
|
+
def test_two_crashes_a_cool_off_apart_are_cached(self) -> None:
|
|
294
|
+
crashed = _Runner(probe.VERDICT_CRASHED)
|
|
295
|
+
with self.assertRaises(probe.OpenvinoLoadCrashed):
|
|
296
|
+
self.ensure(crashed)
|
|
297
|
+
self.clock.advance(probe.CRASH_COOL_OFF_SEC + 1)
|
|
298
|
+
with self.assertRaises(probe.OpenvinoLoadCrashed):
|
|
299
|
+
self.ensure(crashed)
|
|
300
|
+
stored = self.stored()
|
|
301
|
+
self.assertEqual(stored["verdict"], probe.VERDICT_CRASHED)
|
|
302
|
+
self.assertEqual(len(stored["crashes"]), 2)
|
|
303
|
+
# Confirmed: refused for good, never probed again.
|
|
304
|
+
self.clock.advance(10 * probe.CRASH_COOL_OFF_SEC)
|
|
305
|
+
with self.assertRaises(probe.OpenvinoLoadCrashed):
|
|
306
|
+
self.ensure(crashed)
|
|
307
|
+
self.assertEqual(len(crashed.calls), 2)
|
|
308
|
+
|
|
309
|
+
def test_a_success_in_between_resets_the_count(self) -> None:
|
|
310
|
+
crashed = _Runner(probe.VERDICT_CRASHED)
|
|
311
|
+
with self.assertRaises(probe.OpenvinoLoadCrashed):
|
|
312
|
+
self.ensure(crashed)
|
|
313
|
+
self.clock.advance(probe.CRASH_COOL_OFF_SEC + 1)
|
|
314
|
+
self.ensure(_Runner(probe.VERDICT_OK, blob="22bb.blob", cache_dir=self.cache))
|
|
315
|
+
os.remove(os.path.join(self.cache, "22bb.blob")) # wiped: the next load re-probes
|
|
316
|
+
self.clock.advance(probe.CRASH_COOL_OFF_SEC + 1)
|
|
317
|
+
with self.assertRaises(probe.OpenvinoLoadCrashed):
|
|
318
|
+
self.ensure(crashed)
|
|
319
|
+
stored = self.stored()
|
|
320
|
+
self.assertEqual(stored["verdict"], probe.VERDICT_SUSPECT)
|
|
321
|
+
self.assertEqual(len(stored["crashes"]), 1)
|
|
322
|
+
|
|
323
|
+
def test_a_legacy_single_crash_verdict_is_reprobed(self) -> None:
|
|
324
|
+
# A v1 file (no crash list) is what the field wrote after ONE crash.
|
|
325
|
+
key = probe.probe_key(self.model, GPU_CONFIG, ["GPU"], "2025.4.1")
|
|
326
|
+
path = probe.probe_verdict_path(self.model, GPU_CONFIG, self.cache)
|
|
327
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
328
|
+
json.dump({"key": key, "verdict": probe.VERDICT_CRASHED, "detail": "sig 11", "blobs": []}, fh)
|
|
329
|
+
ok = _Runner(probe.VERDICT_OK)
|
|
330
|
+
self.ensure(ok)
|
|
331
|
+
self.assertEqual(len(ok.calls), 1)
|
|
332
|
+
|
|
333
|
+
|
|
220
334
|
class _FakeCore:
|
|
221
335
|
"""The slice of `openvino.Core` `_load_model` touches before it compiles."""
|
|
222
336
|
|