@camstack/addon-pipeline 1.2.614 → 1.2.616

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-D_mgggfr.js → custom-model-catalog-D0EZW4Xb.js} +3 -3
  5. package/dist/{custom-model-catalog-CLhfiG1p.mjs → custom-model-catalog-D3NopOC9.mjs} +3 -3
  6. package/dist/detection-pipeline/index.js +114 -6
  7. package/dist/detection-pipeline/index.mjs +114 -6
  8. package/dist/{dist-BGq_y1xM.mjs → dist-BWPVIuD_.mjs} +237 -61
  9. package/dist/{dist-F6F_xjNj.js → dist-D2aJhYm7.js} +237 -61
  10. package/dist/{model-catalogs-DrUv77wD.js → model-catalogs-BDJEmiKm.js} +14 -4
  11. package/dist/{model-catalogs-CF3WVSy_.mjs → model-catalogs-CYoIeMo4.mjs} +14 -4
  12. package/dist/motion-wasm/index.js +1 -1
  13. package/dist/motion-wasm/index.mjs +1 -1
  14. package/dist/{node-D_K-84ig.js → node-BKs5_GlD.js} +1 -1
  15. package/dist/{node-DwTqHnch.mjs → node-D48F38hW.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-k316CseF.mjs → process-memory-58pkBnTk.mjs} +1 -1
  19. package/dist/{process-memory-BN3N6YZh.js → process-memory-BY-1p6rF.js} +1 -1
  20. package/dist/recorder/index.js +114 -11
  21. package/dist/recorder/index.mjs +114 -11
  22. package/dist/{segment-demux-js-C4-eYvhD.js → segment-demux-js-DbM8w1dc.js} +1 -1
  23. package/dist/{segment-demux-js-CWJseQHd.mjs → segment-demux-js-VeHvPIJe.mjs} +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-DrWysXZj.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B6vm1OlX.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-qcb2Czgh.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-D5F0gL07.mjs → hostInit-BnK9Yje5.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-QzO03I5h.mjs +0 -26
@@ -1,9 +1,9 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { $n as parseProfileBrokerId, $t as rankStreams, An as DeviceFeature, At as gifDeriveSpeed, Bn as createEvent, Cn as AUDIO_PRESETS, D as G711_SCALE_CORRECTION_DB, En as buildFfmpegArgs, Fn as CAM_PROFILE_ORDER, I as RATE_CONTROL_RELAXED, In as CamProfileSchema, It as loadContributionCapability, J as addonWidgetsSourceCapability, Jn as isSecretMarker, L as RATE_CONTROL_TIGHT, Lt as logChannelsCapability, Mn as errMsg, Nn as BOOT_RECOVERY_BACKOFF_MS, On as invocationFromEncodeProfile, Ot as ffmpegRate, Pn as BaseAddon, Qn as nodePin, Sn as webrtcSessionCapability, Vt as memoizePinnedFfmpeg, W as RingBuffer, Wn as expandAudioChunkToF32le, Xn as makeSourceBrokerId, Yn as makeProfileBrokerId, Z as armRemovalReconcile, Zn as maskUrlCredentials, ar as boolean, bt as egressTranscodeSharingKey, c as CLIP_STILL_REASON_CODE_HEADER, cr as literal, dt as declareLogChannel, fr as record, hr as EventCategory, ir as array, it as cameraStreamsCapability, jn as DeviceType, jt as gifFromMp4Args, kn as isSoftwareDecode, lr as number, lt as createHwAccelCache, mr as union, o as BatteryStatusSchema, on as resolveEgressDecodeHwAccel, or as discriminatedUnion, ot as clipExportFailureOf, pr as string, pt as deriveBatteryPresence, qn as isEvent, rr as _enum, s as CLIP_BROKER_PACED_RATES, tt as batteryCapability, ur as object, ut as createLogChannelsProvider, vn as streamBrokerCapability, vt as deviceRemovalHookCapability, w as EncodeProfileSchema, xt as egressTransportFromRequest } from "../dist-BGq_y1xM.mjs";
3
- import { a as defaultFfmpegSpawn, i as NO_COST_CLAIM, n as Fmp4FragmentChild, r as Fmp4FragmentPlane, s as probeFfmpegBinary, t as ChildCostRegistry } from "../node-DwTqHnch.mjs";
2
+ import { $n as parseProfileBrokerId, $t as rankStreams, An as DeviceFeature, At as gifDeriveSpeed, Bn as createEvent, Cn as AUDIO_PRESETS, D as G711_SCALE_CORRECTION_DB, En as buildFfmpegArgs, Fn as CAM_PROFILE_ORDER, I as RATE_CONTROL_RELAXED, In as CamProfileSchema, It as loadContributionCapability, J as addonWidgetsSourceCapability, Jn as isSecretMarker, L as RATE_CONTROL_TIGHT, Lt as logChannelsCapability, Mn as errMsg, Nn as BOOT_RECOVERY_BACKOFF_MS, On as invocationFromEncodeProfile, Ot as ffmpegRate, Pn as BaseAddon, Qn as nodePin, Sn as webrtcSessionCapability, Vt as memoizePinnedFfmpeg, W as RingBuffer, Wn as expandAudioChunkToF32le, Xn as makeSourceBrokerId, Yn as makeProfileBrokerId, Z as armRemovalReconcile, Zn as maskUrlCredentials, ar as boolean, bt as egressTranscodeSharingKey, c as CLIP_STILL_REASON_CODE_HEADER, cr as literal, dt as declareLogChannel, fr as record, hr as EventCategory, ir as array, it as cameraStreamsCapability, jn as DeviceType, jt as gifFromMp4Args, kn as isSoftwareDecode, lr as number, lt as createHwAccelCache, mr as union, o as BatteryStatusSchema, on as resolveEgressDecodeHwAccel, or as discriminatedUnion, ot as clipExportFailureOf, pr as string, pt as deriveBatteryPresence, qn as isEvent, rr as _enum, s as CLIP_BROKER_PACED_RATES, tt as batteryCapability, ur as object, ut as createLogChannelsProvider, vn as streamBrokerCapability, vt as deviceRemovalHookCapability, w as EncodeProfileSchema, xt as egressTransportFromRequest } from "../dist-BWPVIuD_.mjs";
3
+ import { a as defaultFfmpegSpawn, i as NO_COST_CLAIM, n as Fmp4FragmentChild, r as Fmp4FragmentPlane, s as probeFfmpegBinary, t as ChildCostRegistry } from "../node-D48F38hW.mjs";
4
4
  import { a as parseRestreamPath, d as PREBUFFER_MAX_MS, f as capPreRollRequestMs, i as isLiveEdgeOnlyDial, m as resolvePrebuffer, n as NO_DECLARATION, p as prebufferRetentionMs, s as sdpVariantFor, u as withSentryIntent } from "../restream-intent-DyGmLAU9.mjs";
5
5
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
6
- import { a as require_mp4box_all, c as parseNalLengthSize, i as runFfmpegOnce, n as demuxSegmentPreferJs, o as avccSampleToAnnexB, r as FfmpegFallbackRefusedError, s as buildKeyframeAnnexB, t as demuxSegmentFullJs } from "../segment-demux-js-CWJseQHd.mjs";
6
+ import { a as require_mp4box_all, c as parseNalLengthSize, i as runFfmpegOnce, n as demuxSegmentPreferJs, o as avccSampleToAnnexB, r as FfmpegFallbackRefusedError, s as buildKeyframeAnnexB, t as demuxSegmentFullJs } from "../segment-demux-js-VeHvPIJe.mjs";
7
7
  import { createRequire } from "node:module";
8
8
  import * as os$1 from "node:os";
9
9
  import { networkInterfaces, tmpdir } from "node:os";
@@ -5684,6 +5684,13 @@ var RTSP_DESCRIBE_WARMUP_ATTRIBUTION = {
5684
5684
  label: "describe-warmup",
5685
5685
  media: "both"
5686
5686
  };
5687
+ /** How long a DESCRIBE waits for the broker to dial upstream and parse the SDP. */
5688
+ var SDP_WAIT_MS = 3e3;
5689
+ /** The numeric device id a broker id (`<deviceId>/<stream>`) belongs to, or undefined. */
5690
+ function deviceIdOfBroker(brokerId) {
5691
+ const head = Number((brokerId ?? "").split("/")[0]);
5692
+ return Number.isInteger(head) ? head : void 0;
5693
+ }
5687
5694
  /**
5688
5695
  * Single TCP server for all RTSP restream connections.
5689
5696
  * Routes clients to the correct RtspRestreamer by a random token path
@@ -5716,6 +5723,16 @@ var RtspListenServer = class RtspListenServer {
5716
5723
  brokerWarmUps = /* @__PURE__ */ new Map();
5717
5724
  /** brokerId → sentry picture-window opener */
5718
5725
  brokerPrestreamOpeners = /* @__PURE__ */ new Map();
5726
+ logger = null;
5727
+ sdpWaitMs = SDP_WAIT_MS;
5728
+ /** Shorten the DESCRIBE SDP wait (tests only). */
5729
+ setSdpWaitMs(ms) {
5730
+ this.sdpWaitMs = ms;
5731
+ }
5732
+ /** The logger the refusal branches write to; absent in unit specs that do not care. */
5733
+ setLogger(logger) {
5734
+ this.logger = logger;
5735
+ }
5719
5736
  /** Generate a random token for a stream path */
5720
5737
  static generateToken() {
5721
5738
  return randomBytes(16).toString("hex");
@@ -5911,8 +5928,23 @@ var RtspListenServer = class RtspListenServer {
5911
5928
  };
5912
5929
  socket.once("close", releaseOnce);
5913
5930
  socket.once("error", releaseOnce);
5914
- await this.waitForSdp(restreamer, sdpVariant, 3e3);
5915
- const session = new RtspSession(socket, restreamer.getSdpFor(sdpVariant) ?? restreamer.getSdp() ?? "", () => {
5931
+ const sdpReady = await this.waitForSdp(restreamer, sdpVariant, this.sdpWaitMs);
5932
+ const sdp = restreamer.getSdpFor(sdpVariant) ?? restreamer.getSdp() ?? "";
5933
+ if (!sdpReady || sdp.length === 0) {
5934
+ releaseOnce();
5935
+ this.logger?.warn("RTSP DESCRIBE refused: no SDP after the wait", {
5936
+ tags: { deviceId: deviceIdOfBroker(brokerId) },
5937
+ meta: {
5938
+ brokerId,
5939
+ waitedMs: this.sdpWaitMs,
5940
+ variant: sdpVariant
5941
+ }
5942
+ });
5943
+ socket.write(`RTSP/1.0 503 Service Unavailable\r\nCSeq: 1\r\n\r\n`);
5944
+ socket.destroy();
5945
+ return;
5946
+ }
5947
+ const session = new RtspSession(socket, sdp, () => {
5916
5948
  restreamer.removeSession(session.getSessionId());
5917
5949
  releaseOnce();
5918
5950
  }, isMuted, declaration);
@@ -18017,6 +18049,7 @@ var StreamBrokerManager = class StreamBrokerManager {
18017
18049
  };
18018
18050
  constructor(logger) {
18019
18051
  this.logger = logger;
18052
+ this.rtspServer.setLogger(logger.child("rtsp-listen"));
18020
18053
  this.rtspProvider = new RtspRestreamProviderImpl(this.rtspServer, logger.child("rtsp-restream"));
18021
18054
  }
18022
18055
  /**
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DrWysXZj.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B6vm1OlX.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline",
3
- "version": "1.2.614",
3
+ "version": "1.2.616",
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",
@@ -1045,6 +1045,27 @@ def _pin_batch_for_device(core: Any, source: Any, path: str, device: str) -> Any
1045
1045
  return model
1046
1046
 
1047
1047
 
1048
+ # The OpenVINO compile probe (`openvino_compile_probe`) runs the pool's own
1049
+ # `_load_model` in a throwaway child; the child switches the probe off in itself.
1050
+ _ov_probe_enabled: bool = True
1051
+
1052
+
1053
+ def _ov_env_tag(core: Any) -> str:
1054
+ """OpenVINO version + GPU driver: what a persisted probe verdict is valid for."""
1055
+ parts: "list[str]" = []
1056
+ try:
1057
+ from openvino import get_version
1058
+
1059
+ parts.append(str(get_version()))
1060
+ except Exception:
1061
+ pass
1062
+ try:
1063
+ parts.append(str(core.get_property("GPU", "DEVICE_ARCHITECTURE")))
1064
+ except Exception:
1065
+ pass
1066
+ return "|".join(parts)
1067
+
1068
+
1048
1069
  def _load_model(slot: ModelSlot, config: dict) -> None:
1049
1070
  """Load a model into a slot using the active runtime. Thin adapter per backend."""
1050
1071
  slot.config = dict(config)
@@ -1163,6 +1184,18 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
1163
1184
  ov_config["CACHE_DIR"] = _cache_dir
1164
1185
  except OSError:
1165
1186
  pass
1187
+ # A GPU/NPU compile that has no blob yet runs in a CHILD first
1188
+ # (`openvino_compile_probe`): a compiler SIGSEGV is refused here with
1189
+ # `OpenvinoLoadCrashed` instead of killing this worker, and on success
1190
+ # the child has written the blob, so the compile below is a cache hit.
1191
+ if _ov_probe_enabled:
1192
+ import openvino_compile_probe
1193
+
1194
+ openvino_compile_probe.ensure_openvino_loadable(
1195
+ path, config, candidates,
1196
+ cache_dir=ov_config.get("CACHE_DIR"),
1197
+ env_tag=_ov_env_tag(core),
1198
+ )
1166
1199
  # Fix 2 — fold uint8→float/scale/layout preprocessing INTO the compiled
1167
1200
  # graph via PrePostProcessor. When it succeeds we compile the PPP model
1168
1201
  # object (fed uint8 NHWC at inference time); on ANY failure we fall back
@@ -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)