@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.
Files changed (38) hide show
  1. package/THIRD_PARTY_MODELS.md +2 -2
  2. package/dist/audio-analyzer/index.js +3 -3
  3. package/dist/audio-analyzer/index.mjs +3 -3
  4. package/dist/{custom-model-catalog-BmJXsT0k.mjs → custom-model-catalog-BT8HKaes.mjs} +3 -3
  5. package/dist/{custom-model-catalog-DFuM_m3D.js → custom-model-catalog-Yu1sxc4A.js} +3 -3
  6. package/dist/detection-pipeline/index.js +114 -6
  7. package/dist/detection-pipeline/index.mjs +114 -6
  8. package/dist/{dist-B_1t8oSg.js → dist-CGDR6bSg.js} +114 -67
  9. package/dist/{dist-CfOv146z.mjs → dist-OImyOnr3.mjs} +114 -67
  10. package/dist/{model-catalogs-Dnj7Cr3q.mjs → model-catalogs-Cj101H8x.mjs} +14 -4
  11. package/dist/{model-catalogs-Dr-_ANpG.js → model-catalogs-DV562sqJ.js} +14 -4
  12. package/dist/motion-wasm/index.js +1 -1
  13. package/dist/motion-wasm/index.mjs +1 -1
  14. package/dist/{node-D82_5p1M.js → node-CSFdbFlw.js} +1 -1
  15. package/dist/{node-tn9-iwDj.mjs → node-DciF_ZRl.mjs} +1 -1
  16. package/dist/pipeline-runner/index.js +3 -3
  17. package/dist/pipeline-runner/index.mjs +3 -3
  18. package/dist/{process-memory-BlkUi_7x.js → process-memory-CJEkTpRM.js} +1 -1
  19. package/dist/{process-memory-CD5f3ehN.mjs → process-memory-DjIov9TF.mjs} +1 -1
  20. package/dist/recorder/index.js +80 -5
  21. package/dist/recorder/index.mjs +80 -5
  22. package/dist/{segment-demux-js-Cx61fdp7.mjs → segment-demux-js-B5PU-E-G.mjs} +1 -1
  23. package/dist/{segment-demux-js-uWcwFqRL.js → segment-demux-js-BsqG99U7.js} +1 -1
  24. package/dist/stream-broker/_stub.js +2 -2
  25. 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
  26. 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
  27. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-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
  28. package/dist/stream-broker/demux-worker-child.js +1 -1
  29. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  30. package/dist/stream-broker/{hostInit-Bo0bh2FZ.mjs → hostInit-CMCfVKgd.mjs} +3 -3
  31. package/dist/stream-broker/index.js +38 -5
  32. package/dist/stream-broker/index.mjs +38 -5
  33. package/dist/stream-broker/remoteEntry.js +1 -1
  34. package/package.json +1 -1
  35. package/python/inference_pool.py +33 -0
  36. package/python/openvino_compile_probe.py +245 -0
  37. package/python/test_openvino_compile_probe.py +298 -0
  38. 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()