@camstack/addon-pipeline 1.2.615 → 1.2.617
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 +2 -2
- package/dist/audio-analyzer/index.js +3 -3
- package/dist/audio-analyzer/index.mjs +3 -3
- package/dist/{custom-model-catalog-BmJXsT0k.mjs → custom-model-catalog-BT8HKaes.mjs} +3 -3
- package/dist/{custom-model-catalog-DFuM_m3D.js → custom-model-catalog-Yu1sxc4A.js} +3 -3
- package/dist/detection-pipeline/index.js +114 -6
- package/dist/detection-pipeline/index.mjs +114 -6
- package/dist/{dist-B_1t8oSg.js → dist-CGDR6bSg.js} +114 -67
- package/dist/{dist-CfOv146z.mjs → dist-OImyOnr3.mjs} +114 -67
- package/dist/{model-catalogs-Dnj7Cr3q.mjs → model-catalogs-Cj101H8x.mjs} +14 -4
- package/dist/{model-catalogs-Dr-_ANpG.js → model-catalogs-DV562sqJ.js} +14 -4
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-D82_5p1M.js → node-CSFdbFlw.js} +1 -1
- package/dist/{node-tn9-iwDj.mjs → node-DciF_ZRl.mjs} +1 -1
- package/dist/pipeline-runner/index.js +3 -3
- package/dist/pipeline-runner/index.mjs +3 -3
- package/dist/{process-memory-BlkUi_7x.js → process-memory-CJEkTpRM.js} +1 -1
- package/dist/{process-memory-CD5f3ehN.mjs → process-memory-DjIov9TF.mjs} +1 -1
- package/dist/recorder/index.js +80 -5
- package/dist/recorder/index.mjs +80 -5
- package/dist/{segment-demux-js-Cx61fdp7.mjs → segment-demux-js-B5PU-E-G.mjs} +1 -1
- package/dist/{segment-demux-js-uWcwFqRL.js → segment-demux-js-BsqG99U7.js} +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-dldjR7yB.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BVGTBmAI.mjs} +3 -3
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BubwMH51.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-BvJiBUdN.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-TnEvW5tZ.mjs} +1 -1
- 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-Bo0bh2FZ.mjs → hostInit-CMCfVKgd.mjs} +3 -3
- package/dist/stream-broker/index.js +38 -5
- package/dist/stream-broker/index.mjs +38 -5
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/python/inference_pool.py +33 -0
- package/python/openvino_compile_probe.py +245 -0
- package/python/test_openvino_compile_probe.py +298 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D4kDfIKO.mjs +0 -26
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Compile an OpenVINO GPU/NPU model in a CHILD process before the pool loads it.
|
|
2
|
+
|
|
3
|
+
Why (2026-10-22, hub with a Core Ultra 7 265K iGPU, OpenVINO 2025.4.1): the
|
|
4
|
+
iGPU compiler SIGSEGVed the pool worker three times in 15 s while
|
|
5
|
+
`compile_model()` ran for a model with no compiled blob (`malloc(): unsorted
|
|
6
|
+
double linked list corrupted`, then `longjmp causes uninitialized stack
|
|
7
|
+
frame`). The compile runs in the same process as the predict threads and every
|
|
8
|
+
other model, so one native crash took the whole `openvino:gpu` worker (and the
|
|
9
|
+
camera's detection with it) down, and the respawn went straight back into the
|
|
10
|
+
same load. Python cannot catch a SIGSEGV. A warm load (blob present) never
|
|
11
|
+
crashed.
|
|
12
|
+
|
|
13
|
+
So the first load of each (model x device request x compile config) happens in
|
|
14
|
+
a throwaway interpreter that runs the pool's OWN `_load_model` — same
|
|
15
|
+
PrePostProcessor, same candidate list, same properties, same CACHE_DIR — and
|
|
16
|
+
therefore writes the blob the pool then loads warm. Mirrors CoreML's
|
|
17
|
+
`coreml_load_probe.py` (D742):
|
|
18
|
+
|
|
19
|
+
ok -> load in-process (a cache HIT), never probe this key again
|
|
20
|
+
while the blobs the probe wrote are still on disk
|
|
21
|
+
crashed -> refuse with `OpenvinoLoadCrashed` (reason
|
|
22
|
+
`native-load-crashed`), now and on every later load of the
|
|
23
|
+
same key, without re-probing
|
|
24
|
+
inconclusive -> (a Python exception in the child, a probe past its bound, a
|
|
25
|
+
child that could not start) remembered NOWHERE; the
|
|
26
|
+
in-process load runs exactly as it did before this gate
|
|
27
|
+
|
|
28
|
+
Only a death by SIGNAL is a crash. CPU-only loads are never probed.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import subprocess
|
|
35
|
+
import sys
|
|
36
|
+
from typing import Callable, Optional
|
|
37
|
+
|
|
38
|
+
# One-way: inference_pool imports this module lazily, inside its OpenVINO branch.
|
|
39
|
+
from coreml_load_probe import (
|
|
40
|
+
VERDICT_CRASHED,
|
|
41
|
+
VERDICT_INCONCLUSIVE,
|
|
42
|
+
VERDICT_OK,
|
|
43
|
+
_signal_name,
|
|
44
|
+
classify_probe_exit,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
PROBE_VERDICT_VERSION = 1
|
|
48
|
+
PROBE_MARKER = "@@CAMSTACK_OV_PROBE_OK@@"
|
|
49
|
+
# A cold iGPU compile of a large detector takes tens of seconds; past this the
|
|
50
|
+
# probe says nothing and the pool's own load bound (POOL_MODEL_LOAD_BOUNDS)
|
|
51
|
+
# is the only fence, exactly as before the probe existed.
|
|
52
|
+
PROBE_TIMEOUT_SEC = 300.0
|
|
53
|
+
# The config keys that change what `compile_model` is handed (PrePostProcessor
|
|
54
|
+
# plan, input shape, precision hint). A threshold or a class list does not.
|
|
55
|
+
COMPILE_CONFIG_KEYS = (
|
|
56
|
+
"device",
|
|
57
|
+
"inputSize",
|
|
58
|
+
"inputWidth",
|
|
59
|
+
"inputHeight",
|
|
60
|
+
"inputChannels",
|
|
61
|
+
"preprocessMode",
|
|
62
|
+
"inputNormalization",
|
|
63
|
+
"gpuInferencePrecision",
|
|
64
|
+
)
|
|
65
|
+
PROBED_DEVICE_PREFIXES = ("GPU", "NPU")
|
|
66
|
+
|
|
67
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
68
|
+
|
|
69
|
+
# The child runs the pool's own loader, with the probe switched off in it.
|
|
70
|
+
_CHILD_CODE = f"""
|
|
71
|
+
import json, sys
|
|
72
|
+
args = json.loads(sys.argv[1])
|
|
73
|
+
sys.path.insert(0, args["pool_dir"])
|
|
74
|
+
import inference_pool as ip
|
|
75
|
+
ip._ov_probe_enabled = False
|
|
76
|
+
ip._init_runtime("openvino", str(args["config"].get("device", "")))
|
|
77
|
+
ip._load_model(ip.ModelSlot(), args["config"])
|
|
78
|
+
print({PROBE_MARKER!r}, flush=True)
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
ProbeRunner = Callable[[str, dict], "tuple[str, str]"]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class OpenvinoLoadCrashed(Exception):
|
|
85
|
+
"""The model kills the native compiler on this device; refused instead of loaded."""
|
|
86
|
+
|
|
87
|
+
reason = "native-load-crashed"
|
|
88
|
+
|
|
89
|
+
def __init__(self, model_path: str, device: str, detail: str) -> None:
|
|
90
|
+
self.model_path = model_path
|
|
91
|
+
self.device = device
|
|
92
|
+
self.detail = detail
|
|
93
|
+
super().__init__(
|
|
94
|
+
f"OpenVINO model {os.path.basename(model_path)} crashes the native compiler "
|
|
95
|
+
f"on device={device} ({detail}); refused so it cannot take the pool down. "
|
|
96
|
+
f"Run this model on another device, or replace it"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def needs_probe(candidates: "list[str]") -> bool:
|
|
101
|
+
"""True when any compile target is a GPU or an NPU (CPU-only loads are never probed)."""
|
|
102
|
+
return any(str(c).upper().startswith(PROBED_DEVICE_PREFIXES) for c in candidates)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def run_probe(
|
|
106
|
+
model_path: str,
|
|
107
|
+
config: dict,
|
|
108
|
+
child_code: str = _CHILD_CODE,
|
|
109
|
+
timeout_sec: float = PROBE_TIMEOUT_SEC,
|
|
110
|
+
) -> "tuple[str, str]":
|
|
111
|
+
"""Compile `model_path` in a fresh interpreter; return (verdict, detail)."""
|
|
112
|
+
payload = json.dumps({"config": {**config, "path": model_path}, "pool_dir": _HERE})
|
|
113
|
+
try:
|
|
114
|
+
proc = subprocess.run(
|
|
115
|
+
[sys.executable, "-c", child_code, payload],
|
|
116
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
117
|
+
timeout=timeout_sec,
|
|
118
|
+
)
|
|
119
|
+
except subprocess.TimeoutExpired:
|
|
120
|
+
return VERDICT_INCONCLUSIVE, f"probe timed out after {timeout_sec:g}s"
|
|
121
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
122
|
+
return VERDICT_INCONCLUSIVE, f"probe could not start ({exc})"
|
|
123
|
+
marker_seen = any(line.strip() == PROBE_MARKER for line in proc.stdout.splitlines())
|
|
124
|
+
verdict = classify_probe_exit(proc.returncode, marker_seen)
|
|
125
|
+
tail = " | ".join(proc.stderr.strip().splitlines()[-3:])
|
|
126
|
+
if proc.returncode < 0:
|
|
127
|
+
detail = f"probe child died by signal {-proc.returncode} ({_signal_name(-proc.returncode)})"
|
|
128
|
+
else:
|
|
129
|
+
detail = f"probe child exited {proc.returncode}"
|
|
130
|
+
return verdict, f"{detail}{': ' + tail if tail else ''}"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# Looked up at call time, so a test can replace it.
|
|
134
|
+
DEFAULT_RUNNER: ProbeRunner = run_probe
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _file_fingerprint(path: str) -> dict:
|
|
138
|
+
"""size + mtime of the model file and, for an IR, its `.bin` weights."""
|
|
139
|
+
out: dict = {}
|
|
140
|
+
for candidate in (path, os.path.splitext(path)[0] + ".bin"):
|
|
141
|
+
try:
|
|
142
|
+
st = os.stat(candidate)
|
|
143
|
+
except OSError:
|
|
144
|
+
continue
|
|
145
|
+
out[os.path.basename(candidate)] = [st.st_size, st.st_mtime_ns]
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def probe_key(path: str, config: dict, candidates: "list[str]", env_tag: str) -> dict:
|
|
150
|
+
return {
|
|
151
|
+
"version": PROBE_VERDICT_VERSION,
|
|
152
|
+
"model": _file_fingerprint(path),
|
|
153
|
+
"candidates": [str(c).upper() for c in candidates],
|
|
154
|
+
"compile": {k: config.get(k) for k in COMPILE_CONFIG_KEYS},
|
|
155
|
+
# An OpenVINO / GPU-driver update may fix or break a model.
|
|
156
|
+
"env": env_tag,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def probe_verdict_path(path: str, config: dict, cache_dir: str) -> str:
|
|
161
|
+
stem = os.path.splitext(os.path.basename(path))[0]
|
|
162
|
+
device = str(config.get("device", "AUTO")).upper() or "AUTO"
|
|
163
|
+
return os.path.join(cache_dir, f"{stem}.{device}.ovprobe.json")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _blob_names(cache_dir: str) -> "set[str]":
|
|
167
|
+
try:
|
|
168
|
+
return {n for n in os.listdir(cache_dir) if n.endswith(".blob")}
|
|
169
|
+
except OSError:
|
|
170
|
+
return set()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _read_verdict(path: str, key: dict) -> Optional[dict]:
|
|
174
|
+
try:
|
|
175
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
176
|
+
stored = json.load(fh)
|
|
177
|
+
except (OSError, ValueError):
|
|
178
|
+
return None
|
|
179
|
+
if not isinstance(stored, dict) or stored.get("key") != key:
|
|
180
|
+
return None
|
|
181
|
+
if stored.get("verdict") not in (VERDICT_OK, VERDICT_CRASHED):
|
|
182
|
+
return None
|
|
183
|
+
return stored
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _write_verdict(path: str, key: dict, verdict: str, detail: str, blobs: "list[str]") -> None:
|
|
187
|
+
"""Atomic replace; a verdict that cannot be written only costs a re-probe."""
|
|
188
|
+
tmp = f"{path}.tmp-{os.getpid()}"
|
|
189
|
+
try:
|
|
190
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
191
|
+
json.dump({"key": key, "verdict": verdict, "detail": detail, "blobs": blobs}, fh)
|
|
192
|
+
os.replace(tmp, path)
|
|
193
|
+
except OSError as exc:
|
|
194
|
+
sys.stderr.write(f"OpenVINO probe: could not persist the verdict for {path} ({exc})\n")
|
|
195
|
+
sys.stderr.flush()
|
|
196
|
+
try:
|
|
197
|
+
os.remove(tmp)
|
|
198
|
+
except OSError:
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _warm(known: dict, cache_dir: str) -> bool:
|
|
203
|
+
"""An `ok` verdict holds only while the blobs its probe wrote are still on disk."""
|
|
204
|
+
blobs = known.get("blobs")
|
|
205
|
+
if not isinstance(blobs, list):
|
|
206
|
+
return False
|
|
207
|
+
return all(os.path.isfile(os.path.join(cache_dir, str(b))) for b in blobs)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def ensure_openvino_loadable(
|
|
211
|
+
model_path: str,
|
|
212
|
+
config: dict,
|
|
213
|
+
candidates: "list[str]",
|
|
214
|
+
cache_dir: Optional[str],
|
|
215
|
+
env_tag: str = "",
|
|
216
|
+
run: Optional[ProbeRunner] = None,
|
|
217
|
+
) -> None:
|
|
218
|
+
"""Return when `model_path` may be compiled in-process; raise when it would crash."""
|
|
219
|
+
if not needs_probe(candidates):
|
|
220
|
+
return
|
|
221
|
+
name = os.path.basename(model_path)
|
|
222
|
+
device = str(config.get("device", "AUTO"))
|
|
223
|
+
key = probe_key(model_path, config, candidates, env_tag)
|
|
224
|
+
verdict_path = probe_verdict_path(model_path, config, cache_dir) if cache_dir else None
|
|
225
|
+
if verdict_path is not None:
|
|
226
|
+
known = _read_verdict(verdict_path, key)
|
|
227
|
+
if known is not None:
|
|
228
|
+
if known["verdict"] == VERDICT_CRASHED:
|
|
229
|
+
sys.stderr.write(
|
|
230
|
+
f"OpenVINO probe: {name} device={device} is a KNOWN compiler crash — refused\n"
|
|
231
|
+
)
|
|
232
|
+
sys.stderr.flush()
|
|
233
|
+
raise OpenvinoLoadCrashed(model_path, device, str(known.get("detail") or "known crash"))
|
|
234
|
+
if cache_dir is not None and _warm(known, cache_dir):
|
|
235
|
+
return # blob present: the in-process load is a cache hit
|
|
236
|
+
before = _blob_names(cache_dir) if cache_dir else set()
|
|
237
|
+
runner = run if run is not None else DEFAULT_RUNNER
|
|
238
|
+
verdict, detail = runner(model_path, config)
|
|
239
|
+
sys.stderr.write(f"OpenVINO probe: {name} device={device} -> {verdict} ({detail})\n")
|
|
240
|
+
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)
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Unit tests for the OpenVINO child-process compile probe (openvino_compile_probe).
|
|
2
|
+
|
|
3
|
+
An iGPU compiler SIGSEGV (2026-10-22, hub Core Ultra iGPU) takes the whole pool
|
|
4
|
+
process with it. The probe compiles a (model x device request x compile config)
|
|
5
|
+
key whose blob is absent in a CHILD first, remembers the verdict, and refuses a
|
|
6
|
+
model whose child died by a signal — with an exception, not a crash.
|
|
7
|
+
|
|
8
|
+
Covers:
|
|
9
|
+
- needs_probe — only GPU/NPU targets are probed; CPU-only never.
|
|
10
|
+
- run_probe — a REAL child that SIGSEGVs is `crashed`, one that prints the
|
|
11
|
+
marker is `ok`, a Python failure / a timeout is `inconclusive`.
|
|
12
|
+
- ensure_openvino_loadable — a crash refuses now and on the next call without
|
|
13
|
+
re-probing; a warm key (blob the probe wrote still present) skips the probe;
|
|
14
|
+
a wiped blob re-probes; a changed model / compile config / env re-probes.
|
|
15
|
+
- inference_pool._load_model (openvino branch) — a model the probe calls
|
|
16
|
+
crashed never reaches `core.compile_model`; the reply carries
|
|
17
|
+
reason `native-load-crashed`.
|
|
18
|
+
|
|
19
|
+
Pure-python: stubs numpy / PIL / postprocessors when absent.
|
|
20
|
+
|
|
21
|
+
Run: python3 -m unittest test_openvino_compile_probe -v
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
import tempfile
|
|
29
|
+
import types
|
|
30
|
+
import unittest
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
import numpy # noqa: F401
|
|
34
|
+
except ImportError:
|
|
35
|
+
_np = types.ModuleType("numpy")
|
|
36
|
+
_np.array = lambda seq, dtype=None: list(seq) # noqa: E731
|
|
37
|
+
_np.float32 = "float32"
|
|
38
|
+
sys.modules["numpy"] = _np
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
import PIL.Image # noqa: F401
|
|
42
|
+
except ImportError:
|
|
43
|
+
_pil = types.ModuleType("PIL")
|
|
44
|
+
_pil_image = types.ModuleType("PIL.Image")
|
|
45
|
+
_pil.Image = _pil_image
|
|
46
|
+
sys.modules["PIL"] = _pil
|
|
47
|
+
sys.modules["PIL.Image"] = _pil_image
|
|
48
|
+
|
|
49
|
+
if "postprocessors" not in sys.modules:
|
|
50
|
+
_pp = types.ModuleType("postprocessors")
|
|
51
|
+
_pp.POSTPROCESSORS = {}
|
|
52
|
+
sys.modules["postprocessors"] = _pp
|
|
53
|
+
|
|
54
|
+
import openvino_compile_probe as probe
|
|
55
|
+
import inference_pool as ip
|
|
56
|
+
|
|
57
|
+
GPU_CONFIG = {"device": "gpu", "inputSize": 320}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _write(path: str, content: bytes = b"x") -> None:
|
|
61
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
62
|
+
with open(path, "wb") as fh:
|
|
63
|
+
fh.write(content)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _Runner:
|
|
67
|
+
"""A fake probe runner: answers a fixed verdict, optionally writes a blob."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, verdict: str, detail: str = "", blob: "str | None" = None, cache_dir: str = "") -> None:
|
|
70
|
+
self.verdict = verdict
|
|
71
|
+
self.detail = detail
|
|
72
|
+
self.blob = blob
|
|
73
|
+
self.cache_dir = cache_dir
|
|
74
|
+
self.calls: "list[tuple[str, dict]]" = []
|
|
75
|
+
|
|
76
|
+
def __call__(self, model_path: str, config: dict) -> "tuple[str, str]":
|
|
77
|
+
self.calls.append((model_path, config))
|
|
78
|
+
if self.blob:
|
|
79
|
+
_write(os.path.join(self.cache_dir, self.blob))
|
|
80
|
+
return self.verdict, self.detail
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class NeedsProbeTest(unittest.TestCase):
|
|
84
|
+
def test_gpu_and_npu_are_probed(self) -> None:
|
|
85
|
+
self.assertTrue(probe.needs_probe(["GPU"]))
|
|
86
|
+
self.assertTrue(probe.needs_probe(["NPU", "GPU", "CPU"]))
|
|
87
|
+
self.assertTrue(probe.needs_probe(["GPU.0"]))
|
|
88
|
+
|
|
89
|
+
def test_cpu_only_is_never_probed(self) -> None:
|
|
90
|
+
self.assertFalse(probe.needs_probe(["CPU"]))
|
|
91
|
+
self.assertFalse(probe.needs_probe([]))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class RunProbeRealChildTest(unittest.TestCase):
|
|
95
|
+
"""The child is a real interpreter: a native SIGSEGV is seen as a crash."""
|
|
96
|
+
|
|
97
|
+
def test_child_that_segfaults_is_crashed(self) -> None:
|
|
98
|
+
code = "import os, signal; os.kill(os.getpid(), signal.SIGSEGV)"
|
|
99
|
+
verdict, detail = probe.run_probe("/nonexistent.xml", GPU_CONFIG, child_code=code)
|
|
100
|
+
self.assertEqual(verdict, probe.VERDICT_CRASHED)
|
|
101
|
+
self.assertIn("SIGSEGV", detail)
|
|
102
|
+
|
|
103
|
+
def test_child_that_prints_marker_is_ok(self) -> None:
|
|
104
|
+
code = f"print({probe.PROBE_MARKER!r}, flush=True)"
|
|
105
|
+
verdict, _ = probe.run_probe("/nonexistent.xml", GPU_CONFIG, child_code=code)
|
|
106
|
+
self.assertEqual(verdict, probe.VERDICT_OK)
|
|
107
|
+
|
|
108
|
+
def test_child_that_raises_is_inconclusive(self) -> None:
|
|
109
|
+
verdict, _ = probe.run_probe(
|
|
110
|
+
"/nonexistent.xml", GPU_CONFIG, child_code="raise RuntimeError('bad ir')",
|
|
111
|
+
)
|
|
112
|
+
self.assertEqual(verdict, probe.VERDICT_INCONCLUSIVE)
|
|
113
|
+
|
|
114
|
+
def test_child_past_its_bound_is_inconclusive(self) -> None:
|
|
115
|
+
verdict, detail = probe.run_probe(
|
|
116
|
+
"/nonexistent.xml", GPU_CONFIG, child_code="import time; time.sleep(30)",
|
|
117
|
+
timeout_sec=0.5,
|
|
118
|
+
)
|
|
119
|
+
self.assertEqual(verdict, probe.VERDICT_INCONCLUSIVE)
|
|
120
|
+
self.assertIn("timed out", detail)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class EnsureOpenvinoLoadableTest(unittest.TestCase):
|
|
124
|
+
def setUp(self) -> None:
|
|
125
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
126
|
+
self.model = os.path.join(self._tmp.name, "yolov9m-320-int8.xml")
|
|
127
|
+
_write(self.model, b"<net/>")
|
|
128
|
+
_write(os.path.join(self._tmp.name, "yolov9m-320-int8.bin"), b"w")
|
|
129
|
+
self.cache = os.path.join(self._tmp.name, ".ov-cache")
|
|
130
|
+
os.makedirs(self.cache)
|
|
131
|
+
|
|
132
|
+
def tearDown(self) -> None:
|
|
133
|
+
self._tmp.cleanup()
|
|
134
|
+
|
|
135
|
+
def ensure(self, runner, config=None, candidates=("GPU",), env_tag="2025.4.1") -> None:
|
|
136
|
+
probe.ensure_openvino_loadable(
|
|
137
|
+
self.model, config or GPU_CONFIG, list(candidates), self.cache,
|
|
138
|
+
env_tag=env_tag, run=runner,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def test_crash_refuses_with_a_typed_reason(self) -> None:
|
|
142
|
+
runner = _Runner(probe.VERDICT_CRASHED, "probe child died by signal 11 (SIGSEGV)")
|
|
143
|
+
with self.assertRaises(probe.OpenvinoLoadCrashed) as ctx:
|
|
144
|
+
self.ensure(runner)
|
|
145
|
+
self.assertEqual(ctx.exception.reason, "native-load-crashed")
|
|
146
|
+
self.assertIn("yolov9m-320-int8", str(ctx.exception))
|
|
147
|
+
|
|
148
|
+
def test_crash_is_remembered_and_not_reprobed(self) -> None:
|
|
149
|
+
runner = _Runner(probe.VERDICT_CRASHED)
|
|
150
|
+
for _ in range(2):
|
|
151
|
+
with self.assertRaises(probe.OpenvinoLoadCrashed):
|
|
152
|
+
self.ensure(runner)
|
|
153
|
+
self.assertEqual(len(runner.calls), 1)
|
|
154
|
+
|
|
155
|
+
def test_cpu_only_load_does_not_probe(self) -> None:
|
|
156
|
+
runner = _Runner(probe.VERDICT_CRASHED)
|
|
157
|
+
self.ensure(runner, config={"device": "cpu"}, candidates=("CPU",))
|
|
158
|
+
self.assertEqual(runner.calls, [])
|
|
159
|
+
|
|
160
|
+
def test_warm_key_skips_the_probe(self) -> None:
|
|
161
|
+
runner = _Runner(probe.VERDICT_OK, blob="11aa.blob", cache_dir=self.cache)
|
|
162
|
+
self.ensure(runner)
|
|
163
|
+
self.ensure(runner)
|
|
164
|
+
self.assertEqual(len(runner.calls), 1)
|
|
165
|
+
|
|
166
|
+
def test_a_wiped_blob_is_probed_again(self) -> None:
|
|
167
|
+
runner = _Runner(probe.VERDICT_OK, blob="11aa.blob", cache_dir=self.cache)
|
|
168
|
+
self.ensure(runner)
|
|
169
|
+
os.remove(os.path.join(self.cache, "11aa.blob"))
|
|
170
|
+
self.ensure(runner)
|
|
171
|
+
self.assertEqual(len(runner.calls), 2)
|
|
172
|
+
|
|
173
|
+
def test_a_changed_model_is_reprobed(self) -> None:
|
|
174
|
+
crashed = _Runner(probe.VERDICT_CRASHED)
|
|
175
|
+
with self.assertRaises(probe.OpenvinoLoadCrashed):
|
|
176
|
+
self.ensure(crashed)
|
|
177
|
+
_write(self.model, b"<net>fixed</net>")
|
|
178
|
+
ok = _Runner(probe.VERDICT_OK)
|
|
179
|
+
self.ensure(ok)
|
|
180
|
+
self.assertEqual(len(ok.calls), 1)
|
|
181
|
+
|
|
182
|
+
def test_a_different_compile_config_is_reprobed(self) -> None:
|
|
183
|
+
crashed = _Runner(probe.VERDICT_CRASHED)
|
|
184
|
+
with self.assertRaises(probe.OpenvinoLoadCrashed):
|
|
185
|
+
self.ensure(crashed)
|
|
186
|
+
ok = _Runner(probe.VERDICT_OK)
|
|
187
|
+
self.ensure(ok, config={**GPU_CONFIG, "gpuInferencePrecision": "f32"})
|
|
188
|
+
self.assertEqual(len(ok.calls), 1)
|
|
189
|
+
|
|
190
|
+
def test_a_threshold_is_not_part_of_the_key(self) -> None:
|
|
191
|
+
crashed = _Runner(probe.VERDICT_CRASHED)
|
|
192
|
+
with self.assertRaises(probe.OpenvinoLoadCrashed):
|
|
193
|
+
self.ensure(crashed)
|
|
194
|
+
with self.assertRaises(probe.OpenvinoLoadCrashed):
|
|
195
|
+
self.ensure(crashed, config={**GPU_CONFIG, "confidence": 0.9})
|
|
196
|
+
self.assertEqual(len(crashed.calls), 1)
|
|
197
|
+
|
|
198
|
+
def test_a_different_runtime_build_is_reprobed(self) -> None:
|
|
199
|
+
crashed = _Runner(probe.VERDICT_CRASHED)
|
|
200
|
+
with self.assertRaises(probe.OpenvinoLoadCrashed):
|
|
201
|
+
self.ensure(crashed, env_tag="2025.4.1")
|
|
202
|
+
ok = _Runner(probe.VERDICT_OK)
|
|
203
|
+
self.ensure(ok, env_tag="2026.0.0")
|
|
204
|
+
self.assertEqual(len(ok.calls), 1)
|
|
205
|
+
|
|
206
|
+
def test_inconclusive_neither_refuses_nor_is_remembered(self) -> None:
|
|
207
|
+
runner = _Runner(probe.VERDICT_INCONCLUSIVE, "child exited 1")
|
|
208
|
+
self.ensure(runner)
|
|
209
|
+
self.ensure(runner)
|
|
210
|
+
self.assertEqual(len(runner.calls), 2)
|
|
211
|
+
|
|
212
|
+
def test_verdict_lives_in_the_ov_cache_dir(self) -> None:
|
|
213
|
+
self.ensure(_Runner(probe.VERDICT_OK))
|
|
214
|
+
path = probe.probe_verdict_path(self.model, GPU_CONFIG, self.cache)
|
|
215
|
+
self.assertEqual(os.path.dirname(path), self.cache)
|
|
216
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
217
|
+
self.assertEqual(json.load(fh)["verdict"], probe.VERDICT_OK)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class _FakeCore:
|
|
221
|
+
"""The slice of `openvino.Core` `_load_model` touches before it compiles."""
|
|
222
|
+
|
|
223
|
+
available_devices = ["CPU", "GPU"]
|
|
224
|
+
|
|
225
|
+
def __init__(self) -> None:
|
|
226
|
+
self.compile_calls = 0
|
|
227
|
+
|
|
228
|
+
def get_property(self, *_a, **_k):
|
|
229
|
+
raise RuntimeError("no property")
|
|
230
|
+
|
|
231
|
+
def read_model(self, *_a, **_k):
|
|
232
|
+
raise AssertionError("the model must not be read: the probe refuses first")
|
|
233
|
+
|
|
234
|
+
def compile_model(self, *_a, **_k):
|
|
235
|
+
self.compile_calls += 1
|
|
236
|
+
raise AssertionError("the native compiler must not be reached")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class LoadModelGateTest(unittest.TestCase):
|
|
240
|
+
"""`_load_model` asks the probe BEFORE the in-process compile."""
|
|
241
|
+
|
|
242
|
+
def test_crashed_model_never_reaches_the_native_compiler(self) -> None:
|
|
243
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
244
|
+
model = os.path.join(tmp, "yolov9m-320-int8.xml")
|
|
245
|
+
_write(model, b"<net/>")
|
|
246
|
+
core = _FakeCore()
|
|
247
|
+
saved = (ip._runtime, ip._runtime_lib, probe.DEFAULT_RUNNER)
|
|
248
|
+
try:
|
|
249
|
+
ip._runtime = "openvino"
|
|
250
|
+
ip._runtime_lib = core
|
|
251
|
+
probe.DEFAULT_RUNNER = _Runner(probe.VERDICT_CRASHED, "child died by signal 11")
|
|
252
|
+
with self.assertRaises(probe.OpenvinoLoadCrashed):
|
|
253
|
+
ip._load_model(ip.ModelSlot(), {"path": model, "device": "gpu"})
|
|
254
|
+
finally:
|
|
255
|
+
(ip._runtime, ip._runtime_lib, probe.DEFAULT_RUNNER) = saved
|
|
256
|
+
self.assertEqual(core.compile_calls, 0)
|
|
257
|
+
|
|
258
|
+
def test_the_child_itself_is_not_probed(self) -> None:
|
|
259
|
+
# The child runs `_load_model` with the probe switched off in it; without
|
|
260
|
+
# that switch it would fork a child of its own, forever.
|
|
261
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
262
|
+
model = os.path.join(tmp, "m.xml")
|
|
263
|
+
_write(model, b"<net/>")
|
|
264
|
+
runner = _Runner(probe.VERDICT_CRASHED)
|
|
265
|
+
saved = (ip._runtime, ip._runtime_lib, probe.DEFAULT_RUNNER, ip._ov_probe_enabled)
|
|
266
|
+
try:
|
|
267
|
+
ip._runtime = "openvino"
|
|
268
|
+
ip._runtime_lib = _FakeCore()
|
|
269
|
+
ip._ov_probe_enabled = False
|
|
270
|
+
probe.DEFAULT_RUNNER = runner
|
|
271
|
+
with self.assertRaises(Exception) as ctx:
|
|
272
|
+
ip._load_model(ip.ModelSlot(), {"path": model, "device": "gpu"})
|
|
273
|
+
self.assertNotIsInstance(ctx.exception, probe.OpenvinoLoadCrashed)
|
|
274
|
+
finally:
|
|
275
|
+
(ip._runtime, ip._runtime_lib, probe.DEFAULT_RUNNER, ip._ov_probe_enabled) = saved
|
|
276
|
+
self.assertEqual(runner.calls, [])
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class ModelCommandReasonTest(unittest.TestCase):
|
|
280
|
+
def test_load_reply_carries_native_load_crashed(self) -> None:
|
|
281
|
+
import asyncio
|
|
282
|
+
|
|
283
|
+
def failing_load(_slot, config):
|
|
284
|
+
raise probe.OpenvinoLoadCrashed(config["path"], "gpu", "child died by signal 11")
|
|
285
|
+
|
|
286
|
+
async def run() -> dict:
|
|
287
|
+
loader = ip.ModelLoader(timeout_sec=5.0, load_fn=failing_load)
|
|
288
|
+
cmd = {"cmd": "load", "index": 0, "config": {"path": "/m/yolov9m-320-int8.xml"}}
|
|
289
|
+
return await ip._handle_model_command([], cmd, loader)
|
|
290
|
+
|
|
291
|
+
reply = asyncio.run(run())
|
|
292
|
+
self.assertEqual(reply["status"], "error")
|
|
293
|
+
self.assertEqual(reply["reason"], "native-load-crashed")
|
|
294
|
+
self.assertEqual(reply["modelId"], "yolov9m-320-int8")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
if __name__ == "__main__":
|
|
298
|
+
unittest.main()
|