@camstack/addon-pipeline 1.2.350 → 1.2.351

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.
@@ -1857,7 +1857,7 @@ var MODEL_BY_CLASS = {
1857
1857
  "intel-npu": "yolov9m-320-int8",
1858
1858
  "intel-gpu": "yolov9m-320-int8",
1859
1859
  "openvino-cpu": "yolov9m-320-int8",
1860
- "amd-igpu": "yolov9m-320",
1860
+ "amd-igpu": "yolov9t-320",
1861
1861
  cpu: null
1862
1862
  };
1863
1863
  /**
@@ -1857,7 +1857,7 @@ var MODEL_BY_CLASS = {
1857
1857
  "intel-npu": "yolov9m-320-int8",
1858
1858
  "intel-gpu": "yolov9m-320-int8",
1859
1859
  "openvino-cpu": "yolov9m-320-int8",
1860
- "amd-igpu": "yolov9m-320",
1860
+ "amd-igpu": "yolov9t-320",
1861
1861
  cpu: null
1862
1862
  };
1863
1863
  /**
@@ -5,7 +5,7 @@ Object.defineProperties(exports, {
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
6
  const require_dist = require("../dist-C2jSMJo_.js");
7
7
  const require_node = require("../node-B7ZinEw5.js");
8
- const require_custom_model_catalog = require("../custom-model-catalog-B1oKNuAa.js");
8
+ const require_custom_model_catalog = require("../custom-model-catalog-CPqbnIUh.js");
9
9
  const require_lazy_sharp = require("../lazy-sharp-BuwKlQLR.js");
10
10
  const require_process_memory = require("../process-memory-C1s8YIw4.js");
11
11
  let node_os = require("node:os");
@@ -1,7 +1,7 @@
1
1
  import { n as __require } from "../chunk-DnnnRqeS.mjs";
2
2
  import { Gt as modelBindingKey, Hn as hydrateSchema, It as loadContributionCapability, Kn as nodePin, L as PoolMemoryWatchdog, Mn as errMsg$1, Pn as BaseAddon, Pt as linkedLabelStepId, Q as applyModelBoundSettings, Qt as pipelineExecutorCapability, Rn as createEvent, St as evaluateZoneRules, Y as YAMNET_TO_MACRO, Yn as sleep, Yt as pickClusterStepSettings, Zn as array, an as resolveClusterStepModelId, bn as supportedRuntimes$1, cr as EventCategory, ct as collectModelBoundKeys, d as CLUSTER_MODEL_SCOPED_STEPS, g as DEFAULT_CLUSTER_STEP_SETTINGS, hn as stepModelRunsOnFormat, jt as hfModelUrl, k as LABEL_SUPPLY_MACRO, lt as containingParentIndex, mn as runtimeDevices$1, n as APPLE_SA_TO_MACRO, or as string, pn as rootSuppliesOf, pt as defaultDeviceFor$1, qt as overlayClusterStepSettings, rr as object, sr as union, un as resolvePoolMemoryPolicy, vt as detectionPipelineCapability, xt as enumerateInferenceDevices } from "../dist-DFCl7PZV.mjs";
3
3
  import { s as readProcessCost } from "../node-C12l0K5V.mjs";
4
- import { C as resolveModelDefaultConfidence, S as landmarkPrecisionVerdict, _ as ALL_STEPS, b as getStepDefinition, f as localFrameRegistry, g as ALL_PIPELINE_STEPS, l as resolveDefaultDetectionModel, n as mergeCustomModels, o as deviceKeyOf, r as buildSubtreeCanRun, s as resolveDeviceEngine, t as CustomModelCatalog, u as macroGateVerdict, v as getDefaultModelForFormat, x as resolveModelForFormat, y as getStep } from "../custom-model-catalog-XCE-YLb2.mjs";
4
+ import { C as resolveModelDefaultConfidence, S as landmarkPrecisionVerdict, _ as ALL_STEPS, b as getStepDefinition, f as localFrameRegistry, g as ALL_PIPELINE_STEPS, l as resolveDefaultDetectionModel, n as mergeCustomModels, o as deviceKeyOf, r as buildSubtreeCanRun, s as resolveDeviceEngine, t as CustomModelCatalog, u as macroGateVerdict, v as getDefaultModelForFormat, x as resolveModelForFormat, y as getStep } from "../custom-model-catalog-DdHOXMKu.mjs";
5
5
  import { t as getSharp } from "../lazy-sharp-DXsqwpph.mjs";
6
6
  import { n as pickNodePlatformArch, t as readProcessMemory } from "../process-memory-3F1cP1ou.mjs";
7
7
  import * as os$1 from "node:os";
@@ -5,7 +5,7 @@ Object.defineProperties(exports, {
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
6
  const require_dist = require("../dist-C2jSMJo_.js");
7
7
  const require_node = require("../node-B7ZinEw5.js");
8
- const require_custom_model_catalog = require("../custom-model-catalog-B1oKNuAa.js");
8
+ const require_custom_model_catalog = require("../custom-model-catalog-CPqbnIUh.js");
9
9
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
10
10
  const require_lazy_sharp = require("../lazy-sharp-BuwKlQLR.js");
11
11
  const require_restream_intent = require("../restream-intent-7uDNqMKs.js");
@@ -1,6 +1,6 @@
1
1
  import { $t as pipelineRunnerCapability, Bn as defineCustomActions, E as FailureCounters, Et as failureContributionCapability, Ft as linkedStepOf, I as PIPELINE_RAMP, It as loadContributionCapability, Kn as nodePin, M as NativeLeaseAdmissionSchema, Mn as errMsg, N as NativeLeaseSettingsSchema, P as NodeBenchRunnerInputSchema, Pn as BaseAddon, Qn as boolean, Rn as createEvent, U as ROOT_SUPPLIABLE_STEP_IDS, Xn as _enum, Xt as pickDetailCropConvention, Z as allowedSuppliedEffect, Zn as array, Zt as pickNativeLeaseOverride, _ as DEFAULT_DETAIL_CROP_CONVENTION, _t as deriveRootFaceRefineRect, a as BackgroundRunRegistry, cr as EventCategory, er as lazy, fn as resolveRootSupply, ht as deriveDetailCropRect, it as cameraOwnsMotionViaSensor, j as MODEL_FORMATS, ln as resolveModelBoundSetting, m as CROP_SUPPLY_SOURCES, nr as number, or as string, pn as rootSuppliesOf, rr as object, rt as buildBenchStepTree, tr as literal, x as DEVICE_RAMP, y as DEFAULT_NATIVE_LEASE_SETTINGS, zn as customAction } from "../dist-DFCl7PZV.mjs";
2
2
  import { i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-C12l0K5V.mjs";
3
- import { a as stepsForElectedPool, b as getStepDefinition, d as rootObjectGate, f as localFrameRegistry, h as resolveFrameViewGeometry, i as placeDetailStep, l as resolveDefaultDetectionModel, m as createSessionDecodeCoordinator, p as isSessionDecodeReadGone, s as resolveDeviceEngine, t as CustomModelCatalog, u as macroGateVerdict, y as getStep } from "../custom-model-catalog-XCE-YLb2.mjs";
3
+ import { a as stepsForElectedPool, b as getStepDefinition, d as rootObjectGate, f as localFrameRegistry, h as resolveFrameViewGeometry, i as placeDetailStep, l as resolveDefaultDetectionModel, m as createSessionDecodeCoordinator, p as isSessionDecodeReadGone, s as resolveDeviceEngine, t as CustomModelCatalog, u as macroGateVerdict, y as getStep } from "../custom-model-catalog-DdHOXMKu.mjs";
4
4
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
5
5
  import { t as getSharp } from "../lazy-sharp-DXsqwpph.mjs";
6
6
  import { c as withDetectionIntent, o as preRollRequestOf, t as DETECTION_PRE_ROLL_REQUEST_MS } from "../restream-intent-DyGmLAU9.mjs";
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.338",
21
+ version: "1.2.339",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_stream_broker_widgets",
@@ -33,7 +33,7 @@ var e = {
33
33
  },
34
34
  "@camstack/ui-library": {
35
35
  name: "@camstack/ui-library",
36
- version: "1.2.258",
36
+ version: "1.2.259",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_stream_broker_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.2.338",
39
+ version: "1.2.339",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -81,7 +81,7 @@ async function r() {
81
81
  }
82
82
  },
83
83
  "@camstack/ui-library": {
84
- version: "1.2.258",
84
+ version: "1.2.259",
85
85
  scope: "default",
86
86
  shareConfig: {
87
87
  singleton: !0,
@@ -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-VwFAKiPW.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BgC3mSLA.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.350",
3
+ "version": "1.2.351",
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",
@@ -448,9 +448,6 @@ def _resolve_ov_devices(available: list[str], full_names: dict[str, str]) -> lis
448
448
  ONNX_CUDA_EP = "CUDAExecutionProvider"
449
449
  ONNX_COREML_EP = "CoreMLExecutionProvider"
450
450
  ONNX_CPU_EP = "CPUExecutionProvider"
451
- # Plugin EP (onnxruntime-ep-webgpu) — registered at session load, not in
452
- # get_available_providers() until register_execution_provider_library runs.
453
- ONNX_WEBGPU_EP_REGISTRATION = "webgpu_ep_registration"
454
451
 
455
452
 
456
453
  def _resolve_onnx_providers(
@@ -471,8 +468,8 @@ def _resolve_onnx_providers(
471
468
  Deliberate adaptations for camstack:
472
469
  - an operator device pin is honored: "cuda"/"coreml" put that EP first
473
470
  (CPU still last as fallback); "cpu" compiles CPU only.
474
- - "webgpu" is NOT handled here — it uses the plugin-EP SessionOptions
475
- path in `_load_onnx_webgpu_session` (ORT ≥1.24 + onnxruntime-ep-webgpu).
471
+ - "webgpu" is NOT handled here — it uses an isolated child process
472
+ (`WebGpuIsolatedSession` / webgpu_isolated.py; D823).
476
473
  - hardware entries not present in `available`
477
474
  (ort.get_available_providers()) are dropped — camstack ships the
478
475
  plain `onnxruntime` wheel on most nodes, and requesting an EP the
@@ -503,55 +500,10 @@ def _resolve_onnx_providers(
503
500
  return providers
504
501
 
505
502
 
506
- def _load_onnx_webgpu_session(ort, path: str):
507
- """Build an InferenceSession on the WebGPU plugin EP, CPU fallback on miss.
508
-
509
- Registers `onnxruntime-ep-webgpu`, picks the first OrtEpDevice whose ep_name
510
- matches the plugin, and attaches it via SessionOptions.add_provider_for_devices.
511
- Any failure (missing wheel, no adapter, old ORT) falls back to CPU so the
512
- pool still loads — the stderr line names which path won.
513
- """
514
- try:
515
- import onnxruntime_ep_webgpu as webgpu_ep # type: ignore
516
- except Exception as err:
517
- sys.stderr.write(f"ONNX WebGPU: plugin import failed ({err}); CPU fallback\n")
518
- sys.stderr.flush()
519
- return ort.InferenceSession(path, providers=[ONNX_CPU_EP]), [ONNX_CPU_EP]
520
-
521
- try:
522
- ort.register_execution_provider_library(
523
- ONNX_WEBGPU_EP_REGISTRATION, webgpu_ep.get_library_path()
524
- )
525
- except Exception as err:
526
- # Already registered across pool workers is fine; other errors → CPU.
527
- msg = str(err).lower()
528
- if "already" not in msg and "exist" not in msg:
529
- sys.stderr.write(f"ONNX WebGPU: register failed ({err}); CPU fallback\n")
530
- sys.stderr.flush()
531
- return ort.InferenceSession(path, providers=[ONNX_CPU_EP]), [ONNX_CPU_EP]
532
-
533
- ep_name = webgpu_ep.get_ep_name()
534
- webgpu_device = next(
535
- (d for d in ort.get_ep_devices() if getattr(d, "ep_name", None) == ep_name),
536
- None,
537
- )
538
- if webgpu_device is None:
539
- sys.stderr.write(
540
- f"ONNX WebGPU: no OrtEpDevice for {ep_name!r} "
541
- f"(Vulkan/libvulkan missing?); CPU fallback\n"
542
- )
543
- sys.stderr.flush()
544
- return ort.InferenceSession(path, providers=[ONNX_CPU_EP]), [ONNX_CPU_EP]
545
-
546
- sess_options = ort.SessionOptions()
547
- sess_options.add_provider_for_devices([webgpu_device], {})
548
- session = ort.InferenceSession(path, sess_options=sess_options)
549
- active = list(session.get_providers())
550
- sys.stderr.write(
551
- f"ONNX WebGPU: providers={active} ep={ep_name} device=webgpu\n"
552
- )
553
- sys.stderr.flush()
554
- return session, active
503
+ # WebGPU: one ORT session per OS process (Dawn/Vulkan SIGSEGVs on a second
504
+ # session in-process — amd-proxmox 2026-10-10). Implementation lives in
505
+ # webgpu_isolated.py; the pool parent never holds a WebGPU session itself.
506
+ from webgpu_isolated import WebGpuIsolatedSession # noqa: E402
555
507
 
556
508
 
557
509
  # ---------------------------------------------------------------------------
@@ -1388,13 +1340,32 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
1388
1340
  # WebGPU uses the plugin-EP SessionOptions path (ORT ≥1.24); everything
1389
1341
  # else uses the classic providers= list (see _resolve_onnx_providers).
1390
1342
  if ort_device.strip().lower() == "webgpu":
1391
- session, active_list = _load_onnx_webgpu_session(ort, path)
1343
+ # Isolated child — never create a WebGPU InferenceSession in this
1344
+ # process (second in-process session SIGSEGVs the pool worker).
1345
+ isolated = WebGpuIsolatedSession.open(path)
1346
+ active_list = isolated.providers
1392
1347
  _active_eps = [p for p in active_list if p != ONNX_CPU_EP]
1393
1348
  sys.stderr.write(
1394
1349
  f"ONNX: execution provider {', '.join(_active_eps) or ONNX_CPU_EP} "
1395
- f"(device=webgpu)\n"
1350
+ f"(device=webgpu, isolated)\n"
1396
1351
  )
1397
1352
  sys.stderr.flush()
1353
+ slot.config["_input_name"] = isolated.input_name
1354
+ try:
1355
+ slot.config["_input_shape"] = isolated.input_shape
1356
+ except Exception:
1357
+ pass
1358
+ try:
1359
+ if isolated.input_type == "tensor(uint8)":
1360
+ slot.config["_input_dtype"] = "uint8"
1361
+ except Exception:
1362
+ pass
1363
+
1364
+ def predict(inp_dict: dict, _iso=isolated) -> dict:
1365
+ return _iso.run(inp_dict)
1366
+
1367
+ slot.model = isolated
1368
+ slot.predict_fn = predict
1398
1369
  else:
1399
1370
  try:
1400
1371
  available_eps = list(ort.get_available_providers())
@@ -1412,29 +1383,29 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
1412
1383
  f"available={available_eps})\n"
1413
1384
  )
1414
1385
  sys.stderr.flush()
1415
- output_names = [o.name for o in session.get_outputs()]
1416
- slot.config["_input_name"] = session.get_inputs()[0].name
1417
- # Declared input layout (ArcFace/CLIP are NHWC) → correct preprocess.
1418
- try:
1419
- slot.config["_input_shape"] = session.get_inputs()[0].shape
1420
- except Exception:
1421
- pass
1422
- # Declared input DTYPE. super-gradients / Frigate YOLO-NAS exports bake
1423
- # their preprocessing into the graph and take `tensor(uint8)` — the
1424
- # preprocess must feed raw uint8 pixels, not the float32 [0,1] tensor
1425
- # (a hard onnxruntime type error otherwise).
1426
- try:
1427
- if session.get_inputs()[0].type == "tensor(uint8)":
1428
- slot.config["_input_dtype"] = "uint8"
1429
- except Exception:
1430
- pass
1386
+ output_names = [o.name for o in session.get_outputs()]
1387
+ slot.config["_input_name"] = session.get_inputs()[0].name
1388
+ # Declared input layout (ArcFace/CLIP are NHWC) → correct preprocess.
1389
+ try:
1390
+ slot.config["_input_shape"] = session.get_inputs()[0].shape
1391
+ except Exception:
1392
+ pass
1393
+ # Declared input DTYPE. super-gradients / Frigate YOLO-NAS exports bake
1394
+ # their preprocessing into the graph and take `tensor(uint8)` — the
1395
+ # preprocess must feed raw uint8 pixels, not the float32 [0,1] tensor
1396
+ # (a hard onnxruntime type error otherwise).
1397
+ try:
1398
+ if session.get_inputs()[0].type == "tensor(uint8)":
1399
+ slot.config["_input_dtype"] = "uint8"
1400
+ except Exception:
1401
+ pass
1431
1402
 
1432
- def predict(inp_dict: dict) -> dict:
1433
- outputs = session.run(output_names, inp_dict)
1434
- return {name: out for name, out in zip(output_names, outputs)}
1403
+ def predict(inp_dict: dict) -> dict:
1404
+ outputs = session.run(output_names, inp_dict)
1405
+ return {name: out for name, out in zip(output_names, outputs)}
1435
1406
 
1436
- slot.model = session
1437
- slot.predict_fn = predict
1407
+ slot.model = session
1408
+ slot.predict_fn = predict
1438
1409
 
1439
1410
  elif _runtime == "edgetpu":
1440
1411
  Interpreter = _runtime_lib["Interpreter"]
@@ -1507,6 +1478,15 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
1507
1478
 
1508
1479
 
1509
1480
  def _unload_model(slot: ModelSlot) -> None:
1481
+ model = slot.model
1482
+ # WebGpuIsolatedSession (and any future handle) exposes close().
1483
+ close = getattr(model, "close", None)
1484
+ if callable(close):
1485
+ try:
1486
+ close()
1487
+ except Exception as err:
1488
+ sys.stderr.write(f"model close failed: {err}\n")
1489
+ sys.stderr.flush()
1510
1490
  slot.model = None
1511
1491
  slot.predict_fn = None
1512
1492
  slot.input_names = frozenset()
@@ -0,0 +1,172 @@
1
+ """Unit tests for WebGPU process isolation (no real ORT/WebGPU required)."""
2
+ from __future__ import annotations
3
+
4
+ import io
5
+ import pickle
6
+ import struct
7
+ import sys
8
+ import unittest
9
+ from pathlib import Path
10
+ from unittest.mock import MagicMock, patch
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
13
+
14
+ import webgpu_isolated as wi # noqa: E402
15
+
16
+
17
+ class FramingTests(unittest.TestCase):
18
+ def test_roundtrip(self) -> None:
19
+ buf = io.BytesIO()
20
+ payload = {"cmd": "predict", "inputs": {"x": [1, 2, 3]}}
21
+ wi._write_msg(buf, payload)
22
+ buf.seek(0)
23
+ self.assertEqual(wi._read_msg(buf), payload)
24
+
25
+ def test_eof(self) -> None:
26
+ with self.assertRaises(EOFError):
27
+ wi._read_msg(io.BytesIO(b""))
28
+
29
+ def test_short_reads_are_assembled(self) -> None:
30
+ """Pipe read() may return short; framing must loop until n bytes."""
31
+ payload = pickle.dumps({"ok": True, "n": 42}, protocol=pickle.HIGHEST_PROTOCOL)
32
+ frame = struct.pack(">I", len(payload)) + payload
33
+
34
+ class ShortReader:
35
+ def __init__(self) -> None:
36
+ self._data = frame
37
+ self._i = 0
38
+
39
+ def read(self, n: int) -> bytes:
40
+ # At most 3 bytes per call.
41
+ take = min(3, n, len(self._data) - self._i)
42
+ chunk = self._data[self._i : self._i + take]
43
+ self._i += take
44
+ return chunk
45
+
46
+ self.assertEqual(wi._read_msg(ShortReader()), {"ok": True, "n": 42})
47
+
48
+
49
+ class IsolatedSessionTests(unittest.TestCase):
50
+ def test_open_load_predict_close(self) -> None:
51
+ replies = [
52
+ {
53
+ "ok": True,
54
+ "providers": ["WebGpuExecutionProvider", "CPUExecutionProvider"],
55
+ "input_name": "images",
56
+ "input_shape": [1, 3, 640, 640],
57
+ "input_type": "tensor(float)",
58
+ "output_names": ["output0"],
59
+ },
60
+ {"ok": True, "outputs": {"output0": [[0.1, 0.2]]}},
61
+ {"ok": True},
62
+ ]
63
+ written: list = []
64
+ resp_buf = bytearray()
65
+
66
+ class FakeIpcOut:
67
+ def write(self, b: bytes) -> int:
68
+ # Parse complete frames as they arrive and enqueue replies.
69
+ if not hasattr(self, "_req"):
70
+ self._req = bytearray()
71
+ self._req.extend(b)
72
+ while len(self._req) >= 4:
73
+ (size,) = struct.unpack(">I", self._req[:4])
74
+ if len(self._req) < 4 + size:
75
+ break
76
+ msg = pickle.loads(bytes(self._req[4 : 4 + size]))
77
+ del self._req[: 4 + size]
78
+ written.append(msg)
79
+ reply = pickle.dumps(replies.pop(0))
80
+ resp_buf.extend(struct.pack(">I", len(reply)))
81
+ resp_buf.extend(reply)
82
+ return len(b)
83
+
84
+ def flush(self) -> None:
85
+ pass
86
+
87
+ def close(self) -> None:
88
+ pass
89
+
90
+ class FakeIpcIn:
91
+ def read(self, n: int) -> bytes:
92
+ chunk = bytes(resp_buf[:n])
93
+ del resp_buf[:n]
94
+ return chunk
95
+
96
+ def close(self) -> None:
97
+ pass
98
+
99
+ proc = MagicMock()
100
+ proc.pid = 4242
101
+ proc.poll.return_value = None
102
+ proc.wait.return_value = 0
103
+
104
+ fake_out = FakeIpcOut()
105
+ fake_in = FakeIpcIn()
106
+ pipes = iter([(10, 11), (12, 13)])
107
+
108
+ def fake_pipe() -> tuple[int, int]:
109
+ return next(pipes)
110
+
111
+ with (
112
+ patch("webgpu_isolated.os.pipe", side_effect=fake_pipe),
113
+ patch("webgpu_isolated.os.close"),
114
+ patch("webgpu_isolated.os.fdopen", side_effect=[fake_out, fake_in]),
115
+ patch("webgpu_isolated.subprocess.Popen", return_value=proc),
116
+ ):
117
+ sess = wi.WebGpuIsolatedSession.open("/tmp/model.onnx", python_exe="python3")
118
+ self.assertEqual(sess.input_name, "images")
119
+ self.assertEqual(written[0]["cmd"], "load")
120
+ out = sess.run({"images": [[1.0]]})
121
+ self.assertEqual(out["output0"], [[0.1, 0.2]])
122
+ self.assertEqual(written[1]["cmd"], "predict")
123
+ sess.close()
124
+ self.assertEqual(written[2]["cmd"], "shutdown")
125
+
126
+ def test_load_failure_kills_child(self) -> None:
127
+ reply = pickle.dumps({"ok": False, "error": "boom"})
128
+ frame = struct.pack(">I", len(reply)) + reply
129
+
130
+ class FakeIpcOut:
131
+ def write(self, b: bytes) -> int:
132
+ return len(b)
133
+
134
+ def flush(self) -> None:
135
+ pass
136
+
137
+ def close(self) -> None:
138
+ pass
139
+
140
+ class FakeIpcIn:
141
+ def __init__(self) -> None:
142
+ self._data = frame
143
+ self._i = 0
144
+
145
+ def read(self, n: int) -> bytes:
146
+ chunk = self._data[self._i : self._i + n]
147
+ self._i += n
148
+ return chunk
149
+
150
+ def close(self) -> None:
151
+ pass
152
+
153
+ proc = MagicMock()
154
+ proc.pid = 1
155
+ proc.poll.return_value = None
156
+ proc.wait.return_value = 0
157
+ pipes = iter([(10, 11), (12, 13)])
158
+
159
+ with (
160
+ patch("webgpu_isolated.os.pipe", side_effect=lambda: next(pipes)),
161
+ patch("webgpu_isolated.os.close"),
162
+ patch("webgpu_isolated.os.fdopen", side_effect=[FakeIpcOut(), FakeIpcIn()]),
163
+ patch("webgpu_isolated.subprocess.Popen", return_value=proc),
164
+ ):
165
+ with self.assertRaises(RuntimeError) as ctx:
166
+ wi.WebGpuIsolatedSession.open("/tmp/x.onnx")
167
+ self.assertIn("boom", str(ctx.exception))
168
+ proc.kill.assert_called()
169
+
170
+
171
+ if __name__ == "__main__":
172
+ unittest.main()
@@ -0,0 +1,292 @@
1
+ """
2
+ WebGPU ORT sessions in a dedicated OS process.
3
+
4
+ ORT WebGPU EP (Dawn/Vulkan) SIGSEGVs when a second InferenceSession is created
5
+ in the same Python process (measured amd-proxmox 2026-10-10). Two processes,
6
+ one session each, work concurrently. This module:
7
+
8
+ - loads a WebGPU session (or CPU fallback) — used by the child worker;
9
+ - hosts the child worker entrypoint (`python webgpu_isolated.py --ipc-in N --ipc-out M`);
10
+ - exposes `WebGpuIsolatedSession` for the pool parent: one child per model.
11
+
12
+ IPC uses dedicated pipe fds (`pass_fds`), never stdin/stdout — ORT/Dawn log on
13
+ fd 1 and a yolov9 raw head on a shared stdout pickle channel truncated the
14
+ frame (2026-10-10). Pipe `read(n)` can also return short; framing uses
15
+ `_read_exact`.
16
+
17
+ OpenVINO / CoreML / classic ONNX stay in-process in inference_pool.py.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import pickle
23
+ import struct
24
+ import subprocess
25
+ import sys
26
+ import threading
27
+ from typing import Any, BinaryIO, Optional
28
+
29
+ ONNX_CPU_EP = "CPUExecutionProvider"
30
+ ONNX_WEBGPU_EP_REGISTRATION = "webgpu_ep_registration"
31
+
32
+ _HDR = struct.Struct(">I")
33
+
34
+
35
+ def _read_exact(stream: BinaryIO, n: int) -> bytes:
36
+ """Read exactly n bytes; pipe read() may return short without EOF."""
37
+ buf = bytearray()
38
+ while len(buf) < n:
39
+ chunk = stream.read(n - len(buf))
40
+ if not chunk:
41
+ raise EOFError("webgpu worker closed")
42
+ buf.extend(chunk)
43
+ return bytes(buf)
44
+
45
+
46
+ def _write_msg(stream: BinaryIO, obj: Any) -> None:
47
+ payload = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
48
+ stream.write(_HDR.pack(len(payload)))
49
+ stream.write(payload)
50
+ stream.flush()
51
+
52
+
53
+ def _read_msg(stream: BinaryIO) -> Any:
54
+ hdr = _read_exact(stream, _HDR.size)
55
+ (n,) = _HDR.unpack(hdr)
56
+ if n > 512 * 1024 * 1024:
57
+ raise RuntimeError(f"webgpu IPC frame too large: {n}")
58
+ data = _read_exact(stream, n)
59
+ return pickle.loads(data)
60
+
61
+
62
+ def load_onnx_webgpu_session(ort: Any, path: str) -> tuple[Any, list[str]]:
63
+ """Build an InferenceSession on the WebGPU plugin EP, CPU fallback on miss.
64
+
65
+ Same contract as the former inference_pool._load_onnx_webgpu_session.
66
+ """
67
+ try:
68
+ import onnxruntime_ep_webgpu as webgpu_ep # type: ignore
69
+ except Exception as err:
70
+ sys.stderr.write(f"ONNX WebGPU: plugin import failed ({err}); CPU fallback\n")
71
+ sys.stderr.flush()
72
+ return ort.InferenceSession(path, providers=[ONNX_CPU_EP]), [ONNX_CPU_EP]
73
+
74
+ try:
75
+ ort.register_execution_provider_library(
76
+ ONNX_WEBGPU_EP_REGISTRATION, webgpu_ep.get_library_path()
77
+ )
78
+ except Exception as err:
79
+ msg = str(err).lower()
80
+ if "already" not in msg and "exist" not in msg:
81
+ sys.stderr.write(f"ONNX WebGPU: register failed ({err}); CPU fallback\n")
82
+ sys.stderr.flush()
83
+ return ort.InferenceSession(path, providers=[ONNX_CPU_EP]), [ONNX_CPU_EP]
84
+
85
+ ep_name = webgpu_ep.get_ep_name()
86
+ webgpu_device = next(
87
+ (d for d in ort.get_ep_devices() if getattr(d, "ep_name", None) == ep_name),
88
+ None,
89
+ )
90
+ if webgpu_device is None:
91
+ sys.stderr.write(
92
+ f"ONNX WebGPU: no OrtEpDevice for {ep_name!r} "
93
+ f"(Vulkan/libvulkan missing?); CPU fallback\n"
94
+ )
95
+ sys.stderr.flush()
96
+ return ort.InferenceSession(path, providers=[ONNX_CPU_EP]), [ONNX_CPU_EP]
97
+
98
+ sess_options = ort.SessionOptions()
99
+ sess_options.add_provider_for_devices([webgpu_device], {})
100
+ session = ort.InferenceSession(path, sess_options=sess_options)
101
+ active = list(session.get_providers())
102
+ sys.stderr.write(f"ONNX WebGPU: providers={active} ep={ep_name} device=webgpu\n")
103
+ sys.stderr.flush()
104
+ return session, active
105
+
106
+
107
+ def _parse_ipc_fds(argv: list[str]) -> tuple[int, int]:
108
+ ipc_in: Optional[int] = None
109
+ ipc_out: Optional[int] = None
110
+ i = 0
111
+ while i < len(argv):
112
+ if argv[i] == "--ipc-in" and i + 1 < len(argv):
113
+ ipc_in = int(argv[i + 1])
114
+ i += 2
115
+ continue
116
+ if argv[i] == "--ipc-out" and i + 1 < len(argv):
117
+ ipc_out = int(argv[i + 1])
118
+ i += 2
119
+ continue
120
+ i += 1
121
+ if ipc_in is None or ipc_out is None:
122
+ raise SystemExit("webgpu_isolated worker needs --ipc-in FD --ipc-out FD")
123
+ return ipc_in, ipc_out
124
+
125
+
126
+ def _worker_main(argv: list[str]) -> None:
127
+ """One InferenceSession; commands on --ipc-in, replies on --ipc-out."""
128
+ import numpy as np
129
+ import onnxruntime as ort
130
+
131
+ fd_in, fd_out = _parse_ipc_fds(argv)
132
+ ipc_in = os.fdopen(fd_in, "rb", buffering=0)
133
+ ipc_out = os.fdopen(fd_out, "wb", buffering=0)
134
+
135
+ session = None
136
+ output_names: list[str] = []
137
+ while True:
138
+ try:
139
+ msg = _read_msg(ipc_in)
140
+ except EOFError:
141
+ break
142
+ cmd = msg.get("cmd")
143
+ if cmd == "shutdown":
144
+ _write_msg(ipc_out, {"ok": True})
145
+ break
146
+ if cmd == "load":
147
+ path = msg["path"]
148
+ try:
149
+ session, providers = load_onnx_webgpu_session(ort, path)
150
+ output_names = [o.name for o in session.get_outputs()]
151
+ inp = session.get_inputs()[0]
152
+ _write_msg(
153
+ ipc_out,
154
+ {
155
+ "ok": True,
156
+ "providers": providers,
157
+ "input_name": inp.name,
158
+ "input_shape": list(inp.shape),
159
+ "input_type": inp.type,
160
+ "output_names": output_names,
161
+ },
162
+ )
163
+ except Exception as exc:
164
+ _write_msg(ipc_out, {"ok": False, "error": f"{type(exc).__name__}: {exc}"})
165
+ continue
166
+ if cmd == "predict":
167
+ if session is None:
168
+ _write_msg(ipc_out, {"ok": False, "error": "no session"})
169
+ continue
170
+ try:
171
+ outputs = session.run(output_names, msg["inputs"])
172
+ payload = {
173
+ name: np.ascontiguousarray(out)
174
+ for name, out in zip(output_names, outputs)
175
+ }
176
+ _write_msg(ipc_out, {"ok": True, "outputs": payload})
177
+ except Exception as exc:
178
+ _write_msg(ipc_out, {"ok": False, "error": f"{type(exc).__name__}: {exc}"})
179
+ continue
180
+ _write_msg(ipc_out, {"ok": False, "error": f"unknown cmd {cmd!r}"})
181
+
182
+
183
+ class WebGpuIsolatedSession:
184
+ """Parent-side handle: one child process holding one WebGPU ORT session."""
185
+
186
+ def __init__(self, proc: subprocess.Popen, ipc_in: BinaryIO, ipc_out: BinaryIO, meta: dict):
187
+ self._proc = proc
188
+ self._ipc_in = ipc_in # parent reads replies
189
+ self._ipc_out = ipc_out # parent writes commands
190
+ self._lock = threading.Lock()
191
+ self.providers: list[str] = list(meta["providers"])
192
+ self.input_name: str = meta["input_name"]
193
+ self.input_shape = meta["input_shape"]
194
+ self.input_type: str = meta["input_type"]
195
+ self.output_names: list[str] = list(meta["output_names"])
196
+
197
+ @classmethod
198
+ def open(cls, path: str, *, python_exe: Optional[str] = None) -> "WebGpuIsolatedSession":
199
+ script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "webgpu_isolated.py")
200
+ exe = python_exe or sys.executable
201
+ # cmd_r ← parent writes commands; reply_r ← parent reads replies
202
+ cmd_r, cmd_w = os.pipe()
203
+ reply_r, reply_w = os.pipe()
204
+ try:
205
+ proc = subprocess.Popen(
206
+ [exe, script, "--ipc-in", str(cmd_r), "--ipc-out", str(reply_w)],
207
+ pass_fds=(cmd_r, reply_w),
208
+ stdin=subprocess.DEVNULL,
209
+ stdout=None, # inherit — ORT/Dawn logs stay in the pool log
210
+ stderr=None,
211
+ close_fds=True,
212
+ )
213
+ except Exception:
214
+ for fd in (cmd_r, cmd_w, reply_r, reply_w):
215
+ try:
216
+ os.close(fd)
217
+ except OSError:
218
+ pass
219
+ raise
220
+ # Parent keeps cmd_w + reply_r; close the ends the child owns.
221
+ os.close(cmd_r)
222
+ os.close(reply_w)
223
+ ipc_out = os.fdopen(cmd_w, "wb", buffering=0)
224
+ ipc_in = os.fdopen(reply_r, "rb", buffering=0)
225
+ try:
226
+ _write_msg(ipc_out, {"cmd": "load", "path": path})
227
+ reply = _read_msg(ipc_in)
228
+ except Exception:
229
+ ipc_out.close()
230
+ ipc_in.close()
231
+ proc.kill()
232
+ proc.wait(timeout=5)
233
+ raise
234
+ if not reply.get("ok"):
235
+ ipc_out.close()
236
+ ipc_in.close()
237
+ proc.kill()
238
+ proc.wait(timeout=5)
239
+ raise RuntimeError(reply.get("error") or "webgpu child load failed")
240
+ sys.stderr.write(
241
+ f"ONNX WebGPU: isolated child pid={proc.pid} providers={reply['providers']}\n"
242
+ )
243
+ sys.stderr.flush()
244
+ return cls(proc, ipc_in, ipc_out, reply)
245
+
246
+ def run(self, inputs: dict) -> dict:
247
+ with self._lock:
248
+ if self._proc.poll() is not None:
249
+ raise RuntimeError(
250
+ f"webgpu child dead (exit={self._proc.returncode})"
251
+ )
252
+ _write_msg(self._ipc_out, {"cmd": "predict", "inputs": inputs})
253
+ reply = _read_msg(self._ipc_in)
254
+ if not reply.get("ok"):
255
+ raise RuntimeError(reply.get("error") or "webgpu predict failed")
256
+ return reply["outputs"]
257
+
258
+ def close(self) -> None:
259
+ proc = self._proc
260
+ if proc.poll() is not None:
261
+ try:
262
+ self._ipc_out.close()
263
+ except Exception:
264
+ pass
265
+ try:
266
+ self._ipc_in.close()
267
+ except Exception:
268
+ pass
269
+ return
270
+ try:
271
+ _write_msg(self._ipc_out, {"cmd": "shutdown"})
272
+ proc.wait(timeout=5)
273
+ except Exception:
274
+ pass
275
+ try:
276
+ self._ipc_out.close()
277
+ except Exception:
278
+ pass
279
+ try:
280
+ self._ipc_in.close()
281
+ except Exception:
282
+ pass
283
+ if proc.poll() is None:
284
+ proc.kill()
285
+ try:
286
+ proc.wait(timeout=5)
287
+ except Exception:
288
+ pass
289
+
290
+
291
+ if __name__ == "__main__":
292
+ _worker_main(sys.argv[1:])