@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.
Files changed (43) hide show
  1. package/dist/audio-analyzer/index.js +3 -3
  2. package/dist/audio-analyzer/index.mjs +3 -3
  3. package/dist/{custom-model-catalog-YxQheit5.js → custom-model-catalog-CKCK-QcT.js} +7 -7
  4. package/dist/{custom-model-catalog-BW0-xhwJ.mjs → custom-model-catalog-wpMi2U7e.mjs} +7 -7
  5. package/dist/detection-pipeline/index.js +60 -414
  6. package/dist/detection-pipeline/index.mjs +84 -438
  7. package/dist/{dist-mOyFhoa8.mjs → dist-BF60SjmI.mjs} +6855 -1620
  8. package/dist/{dist-CoLjAw2z.js → dist-DUFGDd_2.js} +6866 -1625
  9. package/dist/{model-catalogs-BstZN0ZA.js → model-catalogs-CjTm_0O-.js} +1 -1
  10. package/dist/{model-catalogs-PmNZKpMR.mjs → model-catalogs-CqFDhb0B.mjs} +1 -1
  11. package/dist/motion-wasm/index.js +1 -1
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/{node-Qmp6FkS2.mjs → node-DkphOOM2.mjs} +1 -1
  14. package/dist/{node-CzmiyMnw.js → node-DzGVn7bD.js} +1 -1
  15. package/dist/pipeline-runner/index.js +18 -4
  16. package/dist/pipeline-runner/index.mjs +18 -4
  17. package/dist/{process-memory-C5Cw8Cxz.js → process-memory-8kKexHYR.js} +1 -1
  18. package/dist/{process-memory-DXbuHvOX.mjs → process-memory-CBzfdSvC.mjs} +1 -1
  19. package/dist/recorder/index.js +10 -18
  20. package/dist/recorder/index.mjs +10 -18
  21. package/dist/{segment-demux-js-Din-INnw.mjs → segment-demux-js-DQNoNSJt.mjs} +1 -1
  22. package/dist/{segment-demux-js-DGFHiiIf.js → segment-demux-js-DRLQb1pc.js} +1 -1
  23. package/dist/session-decode/decode-worker-main.js +12 -21
  24. package/dist/session-decode/decode-worker-main.mjs +12 -21
  25. package/dist/stream-broker/_stub.js +2 -2
  26. 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
  27. 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
  28. 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
  29. package/dist/stream-broker/demux-worker-child.js +1 -1
  30. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  31. package/dist/stream-broker/{hostInit-DOiI--7i.mjs → hostInit-B3hXAfr1.mjs} +2 -2
  32. package/dist/stream-broker/index.js +3 -3
  33. package/dist/stream-broker/index.mjs +3 -3
  34. package/dist/stream-broker/remoteEntry.js +1 -1
  35. package/dist/{worker-protocol-D_NzPcnh.mjs → worker-protocol-DngGEAT2.js} +102 -2
  36. package/dist/{worker-protocol-C-G8qmye.js → worker-protocol-_avq7y8O.mjs} +67 -25
  37. package/package.json +1 -1
  38. package/python/coreml_load_probe.py +105 -15
  39. package/python/openvino_compile_probe.py +72 -18
  40. package/python/test_coreml_load_probe.py +83 -0
  41. package/python/test_openvino_compile_probe.py +114 -0
  42. 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
  43. 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
- Object.defineProperty(exports, "formatNativeLeaseKnobs", {
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.620",
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 and on every later load, without re-probing
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
- if stored.get("verdict") not in (VERDICT_OK, VERDICT_CRASHED):
173
- return None
174
- return stored
233
+ return normalise_crash_record(stored)
175
234
 
176
235
 
177
- def _write_verdict(path: str, key: dict, verdict: str, detail: str) -> None:
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({"key": key, "verdict": verdict, "detail": detail}, fh)
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
- sys.stderr.write(f"CoreML probe: {name} device={device} is a KNOWN loader crash — refused\n")
213
- sys.stderr.flush()
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
- return
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
- sys.stderr.write(f"CoreML probe: {name} device={device} -> {verdict} ({detail})\n")
219
- sys.stderr.flush()
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
- if verdict == VERDICT_CRASHED:
223
- raise CoremlLoadCrashed(model_path, device, detail)
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`), now and on every later load of the
23
- same key, without re-probing
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
- if stored.get("verdict") not in (VERDICT_OK, VERDICT_CRASHED):
182
- return None
183
- return stored
205
+ return normalise_crash_record(stored)
184
206
 
185
207
 
186
- def _write_verdict(path: str, key: dict, verdict: str, detail: str, blobs: "list[str]") -> None:
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({"key": key, "verdict": verdict, "detail": detail, "blobs": blobs}, fh)
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
- sys.stderr.write(
230
- f"OpenVINO probe: {name} device={device} is a KNOWN compiler crash — refused\n"
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 cache_dir is not None and _warm(known, cache_dir):
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
- sys.stderr.write(f"OpenVINO probe: {name} device={device} -> {verdict} ({detail})\n")
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