@camstack/addon-pipeline 1.2.349 → 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.
Files changed (35) 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-ae8UGV5D.js → custom-model-catalog-CPqbnIUh.js} +10 -7
  4. package/dist/{custom-model-catalog-B9iY9L4J.mjs → custom-model-catalog-DdHOXMKu.mjs} +10 -7
  5. package/dist/detection-pipeline/index.js +36 -17
  6. package/dist/detection-pipeline/index.mjs +36 -18
  7. package/dist/{dist-BrHqpGoN.js → dist-C2jSMJo_.js} +41 -2
  8. package/dist/{dist-Cr4QsawE.mjs → dist-DFCl7PZV.mjs} +41 -2
  9. package/dist/{model-catalogs-BHBzN67T.js → model-catalogs-CVhIiNhe.js} +1 -1
  10. package/dist/{model-catalogs-Bj9lxXcI.mjs → model-catalogs-Cind8NmH.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-CJ1zilVO.js → node-B7ZinEw5.js} +1 -1
  14. package/dist/{node-Dym0f_3g.mjs → node-C12l0K5V.mjs} +1 -1
  15. package/dist/pipeline-runner/index.js +3 -3
  16. package/dist/pipeline-runner/index.mjs +3 -3
  17. package/dist/{process-memory-CwDymrXT.mjs → process-memory-3F1cP1ou.mjs} +1 -1
  18. package/dist/{process-memory-CAMKwMJs.js → process-memory-C1s8YIw4.js} +1 -1
  19. package/dist/recorder/index.js +2 -2
  20. package/dist/recorder/index.mjs +2 -2
  21. package/dist/{segment-demux-js-CjTXPNsG.mjs → segment-demux-js-7l4LsdU4.mjs} +1 -1
  22. package/dist/{segment-demux-js-BEaJw3G9.js → segment-demux-js-B2IxTWms.js} +1 -1
  23. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CFZDNGBf.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BgC3mSLA.mjs} +2 -2
  24. package/dist/stream-broker/demux-worker-child.js +1 -1
  25. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  26. package/dist/stream-broker/{hostInit-Bx_U_Tc7.mjs → hostInit-DfOhCBj-.mjs} +2 -2
  27. package/dist/stream-broker/index.js +3 -3
  28. package/dist/stream-broker/index.mjs +3 -3
  29. package/dist/stream-broker/remoteEntry.js +1 -1
  30. package/package.json +1 -1
  31. package/python/inference_pool.py +87 -45
  32. package/python/requirements-webgpu.txt +7 -0
  33. package/python/test_inference_pool_device_selection.py +10 -0
  34. package/python/test_webgpu_isolated.py +172 -0
  35. package/python/webgpu_isolated.py +292 -0
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.337",
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",
@@ -1,4 +1,4 @@
1
- const require_segment_demux_js = require("../segment-demux-js-BEaJw3G9.js");
1
+ const require_segment_demux_js = require("../segment-demux-js-B2IxTWms.js");
2
2
  let node_worker_threads = require("node:worker_threads");
3
3
  //#region src/stream-broker/recorded/demux-worker-child.ts
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { t as demuxSegmentFullJs } from "../segment-demux-js-CjTXPNsG.mjs";
1
+ import { t as demuxSegmentFullJs } from "../segment-demux-js-7l4LsdU4.mjs";
2
2
  import { parentPort } from "node:worker_threads";
3
3
  //#region src/stream-broker/recorded/demux-worker-child.ts
4
4
  /**
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.2.337",
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,
@@ -3,12 +3,12 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-BrHqpGoN.js");
7
- const require_node = require("../node-CJ1zilVO.js");
6
+ const require_dist = require("../dist-C2jSMJo_.js");
7
+ const require_node = require("../node-B7ZinEw5.js");
8
8
  const require_restream_intent = require("../restream-intent-7uDNqMKs.js");
9
9
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
10
10
  const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
11
- const require_segment_demux_js = require("../segment-demux-js-BEaJw3G9.js");
11
+ const require_segment_demux_js = require("../segment-demux-js-B2IxTWms.js");
12
12
  let node_os = require("node:os");
13
13
  node_os = require_chunk.__toESM(node_os);
14
14
  let node_crypto = require("node:crypto");
@@ -1,10 +1,10 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { $n as discriminatedUnion, An as DeviceFeature, At as gifFromMp4Args, Cn as AUDIO_PRESETS, D as G711_SCALE_CORRECTION_DB, Dt as ffmpegRate, En as buildFfmpegArgs, Fn as CAM_PROFILE_ORDER, Gn as makeSourceBrokerId, Ht as memoizePinnedFfmpeg, In as CamProfileSchema, It as loadContributionCapability, K as RingBuffer, Kn as nodePin, Lt as logChannelsCapability, Mn as errMsg, Nn as BOOT_RECOVERY_BACKOFF_MS, On as invocationFromEncodeProfile, Pn as BaseAddon, Qn as boolean, R as RATE_CONTROL_RELAXED, Rn as createEvent, Sn as webrtcSessionCapability, Un as isEvent, Vn as expandAudioChunkToF32le, Wn as makeProfileBrokerId, X as addonWidgetsSourceCapability, Xn as _enum, Zn as array, ar as record, at as cameraStreamsCapability, bt as egressTransportFromRequest, c as CLIP_STILL_REASON_CODE_HEADER, cr as EventCategory, dt as createLogChannelsProvider, en as rankStreams, ft as declareLogChannel, jn as DeviceType, kn as isSoftwareDecode, kt as gifDeriveSpeed, mt as deriveBatteryPresence, nr as number, nt as batteryCapability, o as BatteryStatusSchema, or as string, qn as parseProfileBrokerId, rr as object, s as CLIP_BROKER_PACED_RATES, sn as resolveEgressDecodeHwAccel, sr as union, st as clipExportFailureOf, tr as literal, ut as createHwAccelCache, vn as streamBrokerCapability, w as EncodeProfileSchema, yt as egressTranscodeSharingKey, z as RATE_CONTROL_TIGHT, zt as maskUrlCredentials } from "../dist-Cr4QsawE.mjs";
3
- import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-Dym0f_3g.mjs";
2
+ import { $n as discriminatedUnion, An as DeviceFeature, At as gifFromMp4Args, Cn as AUDIO_PRESETS, D as G711_SCALE_CORRECTION_DB, Dt as ffmpegRate, En as buildFfmpegArgs, Fn as CAM_PROFILE_ORDER, Gn as makeSourceBrokerId, Ht as memoizePinnedFfmpeg, In as CamProfileSchema, It as loadContributionCapability, K as RingBuffer, Kn as nodePin, Lt as logChannelsCapability, Mn as errMsg, Nn as BOOT_RECOVERY_BACKOFF_MS, On as invocationFromEncodeProfile, Pn as BaseAddon, Qn as boolean, R as RATE_CONTROL_RELAXED, Rn as createEvent, Sn as webrtcSessionCapability, Un as isEvent, Vn as expandAudioChunkToF32le, Wn as makeProfileBrokerId, X as addonWidgetsSourceCapability, Xn as _enum, Zn as array, ar as record, at as cameraStreamsCapability, bt as egressTransportFromRequest, c as CLIP_STILL_REASON_CODE_HEADER, cr as EventCategory, dt as createLogChannelsProvider, en as rankStreams, ft as declareLogChannel, jn as DeviceType, kn as isSoftwareDecode, kt as gifDeriveSpeed, mt as deriveBatteryPresence, nr as number, nt as batteryCapability, o as BatteryStatusSchema, or as string, qn as parseProfileBrokerId, rr as object, s as CLIP_BROKER_PACED_RATES, sn as resolveEgressDecodeHwAccel, sr as union, st as clipExportFailureOf, tr as literal, ut as createHwAccelCache, vn as streamBrokerCapability, w as EncodeProfileSchema, yt as egressTranscodeSharingKey, z as RATE_CONTROL_TIGHT, zt as maskUrlCredentials } from "../dist-DFCl7PZV.mjs";
3
+ import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-C12l0K5V.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
6
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
7
- 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-CjTXPNsG.mjs";
7
+ 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-7l4LsdU4.mjs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as os$1 from "node:os";
10
10
  import { networkInterfaces, tmpdir } from "node:os";
@@ -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-CFZDNGBf.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.349",
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",
@@ -468,6 +468,8 @@ def _resolve_onnx_providers(
468
468
  Deliberate adaptations for camstack:
469
469
  - an operator device pin is honored: "cuda"/"coreml" put that EP first
470
470
  (CPU still last as fallback); "cpu" compiles CPU only.
471
+ - "webgpu" is NOT handled here — it uses an isolated child process
472
+ (`WebGpuIsolatedSession` / webgpu_isolated.py; D823).
471
473
  - hardware entries not present in `available`
472
474
  (ort.get_available_providers()) are dropped — camstack ships the
473
475
  plain `onnxruntime` wheel on most nodes, and requesting an EP the
@@ -481,8 +483,9 @@ def _resolve_onnx_providers(
481
483
  ordered.append((ONNX_CUDA_EP, {"device_id": cuda_device_id}))
482
484
  elif dev == "coreml":
483
485
  ordered.append(ONNX_COREML_EP)
484
- elif dev == "cpu":
485
- pass # explicit CPU pin — no hardware EPs
486
+ elif dev in ("cpu", "webgpu"):
487
+ # webgpu uses the plugin path; if we fall through here it must be CPU only.
488
+ pass
486
489
  else:
487
490
  # AUTO/default — platform-driven ordering.
488
491
  if plat == "darwin":
@@ -497,6 +500,12 @@ def _resolve_onnx_providers(
497
500
  return providers
498
501
 
499
502
 
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
507
+
508
+
500
509
  # ---------------------------------------------------------------------------
501
510
  # CoreML persistent compiled-model cache
502
511
  # ---------------------------------------------------------------------------
@@ -1328,51 +1337,75 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
1328
1337
  elif _runtime == "onnxruntime":
1329
1338
  ort = _runtime_lib
1330
1339
  ort_device = str(config.get("device", "") or "")
1331
- # Ordered providers — hardware EP first, CPU LAST as
1332
- # fallback (see _resolve_onnx_providers): a "cuda"/"coreml" pin puts
1333
- # that EP first, a "cpu" pin compiles CPU only, and the AUTO/default
1334
- # case derives the hardware EP from the platform.
1335
- try:
1336
- available_eps = list(ort.get_available_providers())
1337
- except Exception:
1338
- available_eps = [ONNX_CPU_EP]
1339
- providers = _resolve_onnx_providers(
1340
- ort_device, sys.platform, platform.machine(), available_eps,
1341
- )
1342
- session = ort.InferenceSession(path, providers=providers)
1343
- # Report the EP that ACTUALLY initialized (CPU stripped from the log)
1344
- # so a silent CPU fallback is visible.
1345
- _active_eps = [p for p in session.get_providers() if p != ONNX_CPU_EP]
1346
- _requested = [p[0] if isinstance(p, tuple) else p for p in providers]
1347
- sys.stderr.write(
1348
- f"ONNX: execution provider {', '.join(_active_eps) or ONNX_CPU_EP} "
1349
- f"(device={ort_device or 'auto'}, requested={_requested}, "
1350
- f"available={available_eps})\n"
1351
- )
1352
- sys.stderr.flush()
1353
- output_names = [o.name for o in session.get_outputs()]
1354
- slot.config["_input_name"] = session.get_inputs()[0].name
1355
- # Declared input layout (ArcFace/CLIP are NHWC) → correct preprocess.
1356
- try:
1357
- slot.config["_input_shape"] = session.get_inputs()[0].shape
1358
- except Exception:
1359
- pass
1360
- # Declared input DTYPE. super-gradients / Frigate YOLO-NAS exports bake
1361
- # their preprocessing into the graph and take `tensor(uint8)` — the
1362
- # preprocess must feed raw uint8 pixels, not the float32 [0,1] tensor
1363
- # (a hard onnxruntime type error otherwise).
1364
- try:
1365
- if session.get_inputs()[0].type == "tensor(uint8)":
1366
- slot.config["_input_dtype"] = "uint8"
1367
- except Exception:
1368
- pass
1340
+ # WebGPU uses the plugin-EP SessionOptions path (ORT ≥1.24); everything
1341
+ # else uses the classic providers= list (see _resolve_onnx_providers).
1342
+ if ort_device.strip().lower() == "webgpu":
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
1347
+ _active_eps = [p for p in active_list if p != ONNX_CPU_EP]
1348
+ sys.stderr.write(
1349
+ f"ONNX: execution provider {', '.join(_active_eps) or ONNX_CPU_EP} "
1350
+ f"(device=webgpu, isolated)\n"
1351
+ )
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
1369
1363
 
1370
- def predict(inp_dict: dict) -> dict:
1371
- outputs = session.run(output_names, inp_dict)
1372
- return {name: out for name, out in zip(output_names, outputs)}
1364
+ def predict(inp_dict: dict, _iso=isolated) -> dict:
1365
+ return _iso.run(inp_dict)
1373
1366
 
1374
- slot.model = session
1375
- slot.predict_fn = predict
1367
+ slot.model = isolated
1368
+ slot.predict_fn = predict
1369
+ else:
1370
+ try:
1371
+ available_eps = list(ort.get_available_providers())
1372
+ except Exception:
1373
+ available_eps = [ONNX_CPU_EP]
1374
+ providers = _resolve_onnx_providers(
1375
+ ort_device, sys.platform, platform.machine(), available_eps,
1376
+ )
1377
+ session = ort.InferenceSession(path, providers=providers)
1378
+ _active_eps = [p for p in session.get_providers() if p != ONNX_CPU_EP]
1379
+ _requested = [p[0] if isinstance(p, tuple) else p for p in providers]
1380
+ sys.stderr.write(
1381
+ f"ONNX: execution provider {', '.join(_active_eps) or ONNX_CPU_EP} "
1382
+ f"(device={ort_device or 'auto'}, requested={_requested}, "
1383
+ f"available={available_eps})\n"
1384
+ )
1385
+ sys.stderr.flush()
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
1402
+
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)}
1406
+
1407
+ slot.model = session
1408
+ slot.predict_fn = predict
1376
1409
 
1377
1410
  elif _runtime == "edgetpu":
1378
1411
  Interpreter = _runtime_lib["Interpreter"]
@@ -1445,6 +1478,15 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
1445
1478
 
1446
1479
 
1447
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()
1448
1490
  slot.model = None
1449
1491
  slot.predict_fn = None
1450
1492
  slot.input_names = frozenset()
@@ -0,0 +1,7 @@
1
+ # ONNX Runtime WebGPU plugin EP — AMD iGPU (RADV) and other Vulkan adapters.
2
+ # Requires a compatible core ORT (plugin refuses older builds at register time).
3
+ # Installed lazily when the node offers `onnx:webgpu` (see runtime-packages /
4
+ # shouldInstallWebgpu). Does NOT replace requirements-onnxruntime.txt for the
5
+ # CPU/CUDA path — a node that only needs the floor keeps the lighter pin.
6
+ onnxruntime>=1.24.4,<2
7
+ onnxruntime-ep-webgpu>=0.4.0,<1
@@ -232,6 +232,16 @@ class ResolveOnnxProvidersTest(unittest.TestCase):
232
232
  [ONNX_CPU_EP],
233
233
  )
234
234
 
235
+ def test_webgpu_pin_is_cpu_only_in_classic_resolver(self) -> None:
236
+ # WebGPU uses the plugin SessionOptions path; the classic resolver
237
+ # must not inject CUDA when device=webgpu (would race the plugin load).
238
+ self.assertEqual(
239
+ _resolve_onnx_providers(
240
+ "webgpu", "linux", "x86_64", [ONNX_CUDA_EP, ONNX_CPU_EP],
241
+ ),
242
+ [ONNX_CPU_EP],
243
+ )
244
+
235
245
  def test_cuda_pin_orders_cuda_first_cpu_last(self) -> None:
236
246
  self.assertEqual(
237
247
  _resolve_onnx_providers(
@@ -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()