From f95804adfc115d54d62d26da7b5518fe63a97f9c Mon Sep 17 00:00:00 2001 From: David Rebbe Date: Tue, 15 Sep 2026 23:32:07 -0400 Subject: [PATCH 1/2] fix: honor device settings type override --- src/methods.cpp | 7 ++ tests/test_device_settings.py | 128 ++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 tests/test_device_settings.py diff --git a/src/methods.cpp b/src/methods.cpp index 1fde87fd..1d0dfce9 100644 --- a/src/methods.cpp +++ b/src/methods.cpp @@ -2200,6 +2200,11 @@ PyObject* meth_get_device_settings(PyObject* self, PyObject* args) if (!PyArg_ParseTuple(args, arg_parse("O|lI:", __FUNCTION__), &obj, &device_type_override, &vnet_slot_arg)) { return NULL; } + if (device_type_override != -1 && + (device_type_override < 0 || device_type_override >= DeviceSettingsTypeMax)) { + PyErr_SetString(PyExc_ValueError, "device_type must be -1 or a valid EDeviceSettingsType"); + return NULL; + } EPlasmaIonVnetChannel_t vnet_slot = static_cast(vnet_slot_arg); // Before we do anything, we need to grab the python s_device_settings ctype.Structure. @@ -2243,6 +2248,8 @@ PyObject* meth_get_device_settings(PyObject* self, PyObject* args) Py_DECREF(settings); return set_ics_exception(exception_runtime_error(), "icsneoGetDeviceSettingsType() Failed"); } + } else { + *setting_type = static_cast(device_type_override); } // int _stdcall icsneoGetDeviceSettings(void* hObject, SDeviceSettings* pSettings, int iNumBytes, // EPlasmaIonVnetChannel_t vnetSlot) diff --git a/tests/test_device_settings.py b/tests/test_device_settings.py new file mode 100644 index 00000000..e2978cb7 --- /dev/null +++ b/tests/test_device_settings.py @@ -0,0 +1,128 @@ +"""Hardware-free checks of the settings type passed across the native ABI.""" + +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + + +@pytest.fixture(scope="module") +def settings_library(tmp_path_factory): + directory = tmp_path_factory.mktemp("settings-library") + source = directory / "settings.c" + source.write_text(r""" +#ifdef _WIN32 +#define API __declspec(dllexport) +#define CALL __stdcall +#else +#define API +#define CALL +#endif +static int received = -99, discoveries, requests, slot, size; +API int CALL icsneoGetDeviceSettingsType(void* handle, int vnet, int* type) { + ++discoveries; + slot = vnet; + *type = 27; + return vnet != 98; +} +API int CALL icsneoGetDeviceSettings(void* handle, int* settings, int bytes, int vnet) { + ++requests; + received = *settings; + slot = vnet; + size = bytes; + return vnet != 99; +} +API int observed(int field) { + switch (field) { + case 0: return received; + case 1: return discoveries; + case 2: return requests; + case 3: return slot; + default: return size; + } +} +""") + if sys.platform == "win32": + compiler = shutil.which("clang-cl") + linker = shutil.which("lld-link") + if not compiler or not linker: + pytest.skip("LLVM clang-cl and lld-link are required for the mock DLL") + library = directory / "settings.dll" + obj = directory / "settings.obj" + target = "i686" if sys.maxsize <= 2**32 else "x86_64" + subprocess.run([compiler, f"--target={target}-pc-windows-msvc", "/nologo", "/c", "/GS-", "/Zl", + str(source), f"/Fo{obj}"], check=True, capture_output=True) + subprocess.run([linker, "/dll", "/noentry", "/nodefaultlib", f"/out:{library}", str(obj)], + check=True, capture_output=True) + else: + compiler = shutil.which("cc") + if not compiler: + pytest.skip("A C compiler is required for the mock library") + library = directory / ("settings.dylib" if sys.platform == "darwin" else "settings.so") + subprocess.run([compiler, "-dynamiclib" if sys.platform == "darwin" else "-shared", "-fPIC", + str(source), "-o", str(library)], check=True, capture_output=True) + return library + + +@pytest.mark.parametrize("scenario", ["override", "first", "last", "discovery", "sentinel", + "invalid", "overflow", "discovery_failure", "settings_failure"]) +def test_device_settings_type(settings_library, scenario): + # Isolate the process-global library override from all other tests. + script = r""" +import ctypes +import sys +import ics + +library, scenario = sys.argv[1:] +ics.override_library_name(library) +mock = ctypes.CDLL(library) +mock.observed.argtypes = [ctypes.c_int] +mock.observed.restype = ctypes.c_int +device = ics.PyNeoDeviceEx() +device._auto_handle_close = False +if scenario in ('invalid', 'overflow'): + values = (-2, ics.DeviceSettingsTypeMax, ics.DeviceSettingsTypeMax + 1, 0xFFFFFFFF) + if scenario == 'overflow': + values = (2**100, -(2**100)) + for value in values: + try: + ics.get_device_settings(device, value) + except (ValueError, OverflowError): + pass + else: + raise AssertionError(f'accepted invalid override {value}') + assert mock.observed(1) == mock.observed(2) == 0 +elif scenario in ('discovery_failure', 'settings_failure'): + discovery = scenario == 'discovery_failure' + name = 'icsneoGetDeviceSettingsType()' if discovery else 'icsneoGetDeviceSettings()' + try: + ics.get_device_settings(device, -1 if discovery else ics.DeviceFire3SettingsType, + 98 if discovery else 99) + except ics.RuntimeError as error: + assert name in str(error) + else: + raise AssertionError('native failure was ignored') + assert mock.observed(1) == int(discovery) + assert mock.observed(2) == int(not discovery) +else: + expected = {'override': ics.DeviceFire3SettingsType, 'first': 0, + 'last': ics.DeviceSettingsTypeMax - 1}.get(scenario, ics.DeviceRADJupiterSettingsType) + if scenario == 'discovery': + result = ics.get_device_settings(device) + else: + result = ics.get_device_settings(device, -1 if scenario == 'sentinel' else expected, 2) + assert mock.observed(0) == expected + assert result.DeviceSettingType == expected + assert mock.observed(1) == int(scenario in ('discovery', 'sentinel')) + assert mock.observed(2) == 1 + assert mock.observed(3) == (ics.PlasmaIonVnetChannelMain if scenario == 'discovery' else 2) + assert mock.observed(4) == ctypes.sizeof(result) +""" + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join(str(Path(path).resolve()) for path in sys.path) + result = subprocess.run([sys.executable, "-c", script, str(settings_library), scenario], + env=env, capture_output=True, text=True) + assert result.returncode == 0, result.stdout + result.stderr From 8aa997afc5df533f5b17bd5ea294365151d8f106 Mon Sep 17 00:00:00 2001 From: David Rebbe Date: Tue, 15 Sep 2026 23:34:38 -0400 Subject: [PATCH 2/2] test: fix x86 settings mock exports --- tests/test_device_settings.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/test_device_settings.py b/tests/test_device_settings.py index e2978cb7..6835a806 100644 --- a/tests/test_device_settings.py +++ b/tests/test_device_settings.py @@ -54,16 +54,21 @@ def settings_library(tmp_path_factory): obj = directory / "settings.obj" target = "i686" if sys.maxsize <= 2**32 else "x86_64" subprocess.run([compiler, f"--target={target}-pc-windows-msvc", "/nologo", "/c", "/GS-", "/Zl", - str(source), f"/Fo{obj}"], check=True, capture_output=True) - subprocess.run([linker, "/dll", "/noentry", "/nodefaultlib", f"/out:{library}", str(obj)], - check=True, capture_output=True) + str(source), f"/Fo{obj}"], check=True, capture_output=True, timeout=60) + # The wrapper resolves undecorated API names, also on 32-bit Windows. + exports = [] if target == "x86_64" else [ + "/export:icsneoGetDeviceSettings=_icsneoGetDeviceSettings@16", + "/export:icsneoGetDeviceSettingsType=_icsneoGetDeviceSettingsType@12", + ] + subprocess.run([linker, "/dll", "/noentry", "/nodefaultlib", f"/out:{library}", str(obj), *exports], + check=True, capture_output=True, timeout=60) else: compiler = shutil.which("cc") if not compiler: pytest.skip("A C compiler is required for the mock library") library = directory / ("settings.dylib" if sys.platform == "darwin" else "settings.so") subprocess.run([compiler, "-dynamiclib" if sys.platform == "darwin" else "-shared", "-fPIC", - str(source), "-o", str(library)], check=True, capture_output=True) + str(source), "-o", str(library)], check=True, capture_output=True, timeout=60) return library @@ -124,5 +129,5 @@ def test_device_settings_type(settings_library, scenario): env = os.environ.copy() env["PYTHONPATH"] = os.pathsep.join(str(Path(path).resolve()) for path in sys.path) result = subprocess.run([sys.executable, "-c", script, str(settings_library), scenario], - env=env, capture_output=True, text=True) + env=env, capture_output=True, text=True, timeout=30) assert result.returncode == 0, result.stdout + result.stderr