Skip to content

Instantly share code, notes, and snippets.

@devdanzin
Created April 23, 2026 08:40
Show Gist options
  • Select an option

  • Save devdanzin/bb73cbfce9e421fd853c53652804ac68 to your computer and use it in GitHub Desktop.

Select an option

Save devdanzin/bb73cbfce9e421fd853c53652804ac68 to your computer and use it in GitHub Desktop.
VTK full analysis

VTK 9.6.1 — Stable ABI (abi3) Feasibility Audit

Executive summary

VTK 9.6.1 does not claim Py_LIMITED_API anywhere — not in wrap-infra (Wrapping/PythonCore/), not in the generator (Wrapping/Tools/vtkWrapPython*.c), not in any CMakeLists.txt or .cmake module. There is no setup.py/pyproject.toml at the VTK root; Python wheels are built via CMake (CMake/vtkWheelPreparation.cmake, CMake/vtkWheelFinalization.cmake) and none of those files reference abi3, LIMITED_API, py_limited_api, or equivalent. The only mentions of the limited API in the entire scoped tree are (a) a single forward-looking comment in PyVTKObject.cxx:294 (// XXX(python3-abi3): all types will be heap types in abi3) and (b) two conditionally-compiled #ifdef PY_LIMITED_API blocks in vtkPythonUtil.cxx lines 12 and 530 that use PyType_GetName. These indicate that the maintainer has thought about abi3 and planted migration markers, but no production build targets it.

Verdict: Moderate with a caveat. VTK's architecture is surprisingly well-positioned for abi3: (a) the generator is a single code-emission choke point, so one rewrite of 3 fprintf sites (vtkWrapPythonClass.c:454, vtkWrapPythonType.c:669, vtkWrapPythonEnum.c:187) converts all ~2,257 static PyTypeObject across 1,828 generated bindings into PyType_FromSpec calls atomically; (b) much of the wrap-infra already uses PyType_GetSlot/PyType_GetFlags/PyType_GetName with PY_VERSION_HEX >= 0x030A0000 guards, showing an in-progress migration; (c) only 2 hand-written private-API call sites (_PyType_Lookup at PyVTKReference.cxx:226 and :247) need to be replaced. The caveat: PyVTKNamespace_Type and PyVTKTemplate_Type use &PyModule_Type as tp_base, and PyMethodDescrObject/PyDescr_TYPE/PyDescr_NAME in PyVTKMethodDescriptor.cxx reach into a private CPython descriptor layout. These two spots are architectural rather than mechanical and account for most of the "hard" work.

Top 3 blockers: (1) _PyType_Lookup in PyVTKReference.cxx — hard blocker because no direct abi3 replacement exists; must be worked around via PyObject_GetAttr/PyObject_GetOptionalAttr. (2) All ~2,257 generated static PyTypeObject literals (3 generator emission sites) must be converted to PyType_Spec. (3) PyMethodDescrObject internals in PyVTKMethodDescriptor.cxx — need to replace with a VTK-owned descriptor struct.

Recommended Python floor: 3.10 (for PyType_GetSlot(Py_tp_base), Py_TPFLAGS_DISALLOW_INSTANTIATION, PyUnicode_AsUTF8AndSize in abi3) with pythoncapi-compat for 3.11+ features (PyType_GetName, PyObject_GetOptionalAttr, managed-dict flags).

Verdict table

Metric Value
Claims Py_LIMITED_API No — zero #define Py_LIMITED_API; no build flag; #ifdef PY_LIMITED_API blocks are guarded for use-when-defined only
Private API call sites (_Py*) — wrap-infra 2 (_PyType_Lookup x2 in PyVTKReference.cxx) + 1 version-guarded (_Py_HashPointer in vtkPythonUtil.cxx, only for PY<3.13)
Private API call sites — generator 0 (no emissions produce _Py* calls)
Static PyTypeObject — wrap-infra 7 literal definitions (PyVTKReference x4, PyVTKNamespace, PyVTKTemplate, PyVTKMethodDescriptor)
Static PyTypeObject — generated bindings ~2,257 across 1,730 files (1,828 total generated files in scope)
Static PyTypeObject emission sites in generator 3 (vtkWrapPythonClass.c:454, vtkWrapPythonType.c:669, vtkWrapPythonEnum.c:187)
Direct struct field access — wrap-infra ~48 lines across 8 files (most are ->tp_base/->tp_dict, almost all have 3.10+ guards already)
Direct struct field access — generator-emitted 2 patterns (pytype->tp_base = ... 1,730+ times; Py%s_Type.tp_dict = enumdict for all enums with constants)
Non-limited macros (PyTuple_GET_ITEM etc.) 0 in scope
Non-limited headers 0 in scope (only dictobject.h, structmember.h, both abi3-allowed)
Static auxiliary method tables (PyNumberMethods etc.) wrap-infra: 7; generator: 2 sites (vtkWrapPythonNumberProtocol.c:76, vtkWrapPythonType.c:497)
PyLong_Type.tp_new direct call 1 (PyVTKEnum.cxx:30)
PyDescr_TYPE / PyDescr_NAME / offsetof(PyDescrObject, …) 4 sites in PyVTKMethodDescriptor.cxx
Generator-fixable fraction of all static-type violations ~99% (only 7 wrap-infra types are hand-written)
Hand-written abi3 fixes required ~15 files in Wrapping/PythonCore/
Recommended Python floor 3.10
Migration effort rating Moderate — ~2-4 engineer-weeks generator work + ~2-4 weeks wrap-infra + ~2 weeks test/build-system

Category 1: Private API usage (_Py*)

Finding 1.1: _PyType_Lookup in PyVTKReference.cxx (the confirmed hard blocker)

  • Files: /home/danzin/projects/laruche/repositories/vtk-source/VTK-9.6.1/Wrapping/PythonCore/PyVTKReference.cxx
  • Lines: 226 (in PyVTKReference_Trunc), 247 (in PyVTKReference_Round)
  • Category: private_api
  • Classification: CONSIDER
  • Confidence: HIGH
// Line 226 — __trunc__
PyObject* meth = _PyType_Lookup(Py_TYPE(ob), attr);
// Line 247 — __round__
PyObject* meth = _PyType_Lookup(Py_TYPE(ob), attr);

Description: _PyType_Lookup is an internal-only CPython symbol with no promise of stability. The prior informed review correctly identified this as the "hard 3.15 blocker" and correctly located it in PyVTKReference.cxx. The calls look up __trunc__ / __round__ on a wrapped object's underlying value type without walking the MRO manually.

Alternative: No direct abi3 equivalent exists. Viable replacements:

  1. PyObject_GetAttr(ob, attr) — picks up instance dict first, functionally nearly equivalent for these specific dunder methods since reference wrappers don't typically have instance attributes.
  2. PyObject_GetOptionalAttr (3.13+, via pythoncapi-compat backport) — returns NULL without setting an error when the attr is missing.
  3. Re-implement the MRO walk using PyType_GetSlot / PyObject_GenericGetAttr.

Migration notes: Option 1 (PyObject_GetAttr on the value, not the type) is the cleanest for these two specific dunder-lookups because the PyVTKReference wraps a simple value object whose type will own __trunc__/__round__ as a slot; for tp-level lookups the generic attribute machinery handles the MRO walk the same way _PyType_Lookup does. The behavioral difference (descriptor protocol vs type-only lookup) is negligible for __trunc__/__round__ which are defined on numeric types. Estimated effort: 30 minutes including testing.

Finding 1.2: _Py_HashPointer (already properly guarded)

  • File: /home/danzin/projects/laruche/repositories/vtk-source/VTK-9.6.1/Wrapping/PythonCore/vtkPythonUtil.cxx
  • Lines: 37-42
  • Category: private_api
  • Classification: CONSIDER (not actually a problem)
  • Confidence: HIGH
// Py_HashPointer added in 3.13 and _Py_HashPointer deprecated in 3.14.
#if PY_VERSION_HEX >= 0x030D0000
#define PY_HASHPOINTER Py_HashPointer
#else
#define PY_HASHPOINTER _Py_HashPointer
#endif

Description: This is already using the public Py_HashPointer on 3.13+ and falling back to the private _Py_HashPointer only on older Pythons. Py_HashPointer is in the stable ABI since 3.13. Under abi3 with floor 3.10, this fallback triggers for every supported version — but _Py_HashPointer exists as an exported symbol in 3.10-3.12, just not formally in the limited API. For an abi3 build, the workaround is pythoncapi-compat.h which provides Py_HashPointer on older Pythons. No hard blocker.

Alternative: Use pythoncapi_compat.h (which backports Py_HashPointer to 3.5+). No code change in the definition itself — just ensure the header is included in the #else branch.

Migration notes: Straightforward swap. Effort: 15 minutes.


Category 2: Static PyTypeObject declarations

Full inventory of every static PyTypeObject in scope. The wrap-infra hand-written count is 7 (plus 1 declared-but-pointer-only in PyVTKObject.cxx). The generator-emitted count is ~2,257 across 1,730 distinct files — all three emission points are in the generator.

2.1 Hand-written static types (wrap-infra)

# Type symbol File Line Purpose Base abi3 path
1 PyVTKMethodDescriptor_Type PyVTKMethodDescriptor.cxx 149 VTK method descriptor (wraps PyMethodDef) default (object) PyType_FromSpec with Py_tp_members, Py_tp_getset, Py_tp_descr_get, plus Category 3 rewrite for PyDescrObject access
2 PyVTKTemplate_Type PyVTKTemplate.cxx 253 Template dispatch object &PyModule_Type PyType_FromSpecWithBasesblocker: PyModule_Type as base
3 PyVTKReference_Type PyVTKReference.cxx 735 Python passthrough reference default (object) PyType_FromSpec
4 PyVTKNumberReference_Type PyVTKReference.cxx 788 Numeric-subclass reference PyVTKReference_Type PyType_FromSpecWithBases with dynamically-created parent
5 PyVTKStringReference_Type PyVTKReference.cxx 841 String-subclass reference PyVTKReference_Type PyType_FromSpecWithBases
6 PyVTKTupleReference_Type PyVTKReference.cxx 894 Tuple-subclass reference PyVTKReference_Type PyType_FromSpecWithBases
7 PyVTKNamespace_Type PyVTKNamespace.cxx 49 C++ namespace shim &PyModule_Type PyType_FromSpecWithBasesblocker: PyModule_Type as base

Note on PyVTKObject_Type (PyVTKObject.cxx:33): This is static PyTypeObject* PyVTKObject_Type = nullptr; — a cached pointer to vtkObjectBase's type, set dynamically once on first class add. Not a static type definition; no abi3 concern beyond the existing assignment logic.

Classification: CONSIDER for all 7. Special concern for #2 and #7 (POLICY): using &PyModule_Type as tp_base in a heap-type PyType_FromSpec is supported in Python since it's a public type, but many PEP 384 toolchains flag it. VTK's use of PyModule_Type subclassing is already unusual; this is a design-level question for Ben whether to keep the "namespace as module-subclass" approach under abi3.

2.2 Generator-emitted static types

Total: 2,257 distinct static PyTypeObject definitions spread across 1,730 files out of 1,828 total generated binding files.

Three emission sites in the generator produce all of them:

Emission site What it emits Replication factor
vtkWrapPythonClass.c:454 (vtkWrapPython_GenerateObjectType) static PyTypeObject Py%s_Type = { PyVarObject_HEAD_INIT(&PyType_Type, 0) ... } for every VTK-derived class Every vtkObjectBase-derived class wrapped — the majority of the 1,828 bindings
vtkWrapPythonType.c:669 (vtkWrapPython_GenerateSpecialType) Static types for "special" value-semantics types (e.g. vtkVariant, vtkRect, vtkColor3d) Only for value-type wrappers; tens to low hundreds
vtkWrapPythonEnum.c:187 (vtkWrapPython_GenerateEnumType) Static types for wrapped C++ enums, with &PyLong_Type as tp_base; includes sizeof(PyLongObject) as tp_basicsize One per enum type; the heaviest-hit files are templated containers (vtkSOADataArrayTemplatePython.cxx has 69 enum types)

Classification: CONSIDER. Replacement is mechanical — each fprintf emitting the static struct becomes:

/* emit array of PyType_Slot */
fprintf(fp, "static PyType_Slot Py%s_Slots[] = {\n", classname);
fprintf(fp, "  {Py_tp_dealloc, (void *)PyVTKObject_Delete},\n");
fprintf(fp, "  {Py_tp_repr, (void *)PyVTKObject_Repr},\n");
/* ... all the non-null slots ... */
fprintf(fp, "  {0, NULL}\n};\n");
/* emit PyType_Spec */
fprintf(fp, "static PyType_Spec Py%s_Spec = {\n", classname);
fprintf(fp, "  .name = \"%s.%s\",\n", module, classname);
fprintf(fp, "  .basicsize = sizeof(PyVTKObject),\n");
fprintf(fp, "  .flags = Py_TPFLAGS_DEFAULT|Py_TPFLAGS_HAVE_GC|Py_TPFLAGS_BASETYPE,\n");
fprintf(fp, "  .slots = Py%s_Slots,\n", classname);
fprintf(fp, "};\n");

Then PyVTKClass_Add / PyVTKSpecialType_Add / PyVTKEnum_Add are modified to call PyType_FromSpec/PyType_FromSpecWithBases and cache the resulting PyObject* (not a static pointer). Estimated effort per emission site: 2-4 days, including testing the full matrix of classes/enums/specialtypes, buffer protocol slots, and superclass chains.

Critical leverage insight: The enum emission (vtkWrapPythonEnum.c) hard-codes sizeof(PyLongObject) as tp_basicsize. PyLongObject has non-stable layout (it's in types_without_stable_layout in the audit reference data). An abi3-compliant enum must use sizeof(PyObject) + basicsize inherited from PyLong_Type via PyType_FromSpecWithBases. This is a one-line change in the generator but it's material.


Category 3: Direct struct field access

3.1 Hand-written struct access (wrap-infra)

Distribution of ->tp_* accesses by file (excluding the struct literal definitions themselves):

File ->tp_ count Mostly Guard status
PyVTKObject.cxx 16 pytype->tp_dict, pytype->tp_base, pytype->tp_init, pytype->tp_name Mostly unguarded — the main rewrite target
PyVTKSpecialObject.cxx 10 pytype->tp_dict, type->tp_base, type->tp_str, type->tp_as_sequence->sq_* Already migrated to PyType_GetSlot at lines 53-115 with direct fallback only used elsewhere
vtkPythonUtil.cxx 8 pytype->tp_dict, pytype->tp_base, pytype->tp_name Mostly guarded with PY_VERSION_HEX >= 0x030A0000
vtkPythonOverload.cxx 7 Py_TYPE(arg)->tp_base, basetype->tp_base Already fully guarded — uses PyType_GetSlot(…, Py_tp_base) on 3.10+
PyVTKNamespace.cxx 3 type->tp_base, PyType_GetSlot already Already fully migrated
PyVTKTemplate.cxx 2 internal layout
PyVTKReference.cxx 1 Py_TYPE(opn)->tp_as_number Guarded above, but struct access in the #else branch
PyVTKEnum.cxx 1 pytype->tp_new = nullptr Guarded #if PY_VERSION_HEX < 0x030A0000

Specific concrete violations requiring fixes (sampling, not exhaustive):

  • PyVTKObject.cxx:85PyDict_SetItemString(typeobj->tp_dict, "__override__", type); — still direct access, no version guard. Replacement: after switching to heap types, use PyObject_SetAttrString on the type (which modifies the dict via tp_setattro).
  • PyVTKObject.cxx:101PyDict_DelItemString(typeobj->tp_dict, "__override__") — same pattern.
  • PyVTKObject.cxx:155pytype->tp_dict = PyDict_New(); — assignment, unavoidable with heap types; instead, the dict is set up as part of slot initialization.
  • PyVTKObject.cxx:159, 166, 174, 222PyDict_SetItemString(pytype->tp_dict, …) — replace with PyObject_SetAttrString or pre-populate via a __dict__ slot in PyType_Spec.
  • PyVTKObject.cxx:777pytype->tp_init != nullptr / :788 — pytype->tp_init((PyObject*)self, …) — replace with PyType_GetSlot(pytype, Py_tp_init) and call.
  • PyVTKObject.cxx:805message += pytype->tp_name; — replace with PyType_GetName (3.11+, pythoncapi-compat to 3.9).
  • PyVTKSpecialObject.cxx:244, 250, 256, 264 — similar pytype->tp_dict pattern.
  • PyVTKSpecialObject.cxx:62, 64type->tp_base and type->tp_str walks — lines 57-59 already use PyType_GetSlot, but lines 62/64 duplicate the walk in the #else branch.
  • vtkPythonUtil.cxx:534return pytype->tp_name; (inside #else !PY_LIMITED_API) — under abi3, use PyType_GetName (3.11+, pythoncapi-compat back to 3.9).
  • vtkPythonUtil.cxx:605, 614, 969, 1242, 1245, 1260, 1262pytype->tp_base, pytype->tp_dict accesses. Mostly already guarded with PY_VERSION_HEX >= 0x030A0000 where PyType_GetSlot is used instead; a few unguarded ones need the same treatment.
  • PyVTKEnum.cxx:30PyLong_Type.tp_new(pytype, args, nullptr) — direct access on the global PyLong_Type. Replace with PyType_GetSlot(&PyLong_Type, Py_tp_new).

Classification: CONSIDER. Effort is proportional — most of these are 1-line mechanical edits, and the template for the conversion is already visible in vtkPythonOverload.cxx and PyVTKSpecialObject.cxx. Total estimated effort for all hand-written struct-access fixes: 2 engineer-weeks, mostly testing.

3.2 Generator-emitted struct access

Two patterns, both with a single emission site:

Finding 3.2a: pytype->tp_base = (PyTypeObject *)Py%s_ClassNew() and its FindBaseTypeObject variants

  • Emitter: vtkWrapPythonClass.c:377, 384, 389 (class generator) and vtkWrapPythonType.c:872, 877 (special-type generator)
  • Typical emitted line:
    /home/danzin/projects/labeille/cext-builds/vtk/build/CMakeFiles/vtkCommonCorePython/vtkEventDataPython.cxx:627
    /home/danzin/projects/labeille/cext-builds/vtk/build/CMakeFiles/vtkCommonCorePython/vtkCommandPython.cxx:937
    /home/danzin/projects/labeille/cext-builds/vtk/build/CMakeFiles/vtkCommonCorePython/vtkMathPython.cxx:4991
    
    All contain pytype->tp_base = (PyTypeObject *)PyvtkObjectBase_ClassNew(); (or equivalent for parent class).
  • Replication factor: All VTK object classes — ~1,730 files, ~1,828 total occurrences (some classes carry the emission twice for a VTK parent + Special parent).
  • Classification: CONSIDER.
  • Fix: After PyType_FromSpec migration, the superclass becomes the bases argument to PyType_FromSpecWithBases, so this line disappears entirely from the emitted output. Single generator change → ~1,800 file cleanups.

Finding 3.2b: pytype->tp_dict access emitted for class-level constants and enum tp_dict assignment

  • Emitters:
    • vtkWrapPythonClass.c:418PyObject *d = pytype->tp_dict; (for classes with constants/enums)
    • vtkWrapPythonType.c:895 — same pattern for special types
    • vtkWrapPythonEnum.c:99Py%s%s%s_Type.tp_dict = enumdict; (enum dict initialization)
  • Typical emitted line:
    /home/danzin/projects/labeille/cext-builds/vtk/build/CMakeFiles/vtkCommonCorePython/vtkCommandPython.cxx:939
        PyObject *d = pytype->tp_dict;
    
    /home/danzin/projects/labeille/cext-builds/vtk/build/CMakeFiles/vtkCommonCorePython/vtkEventDataPython.cxx:2096,2135,2175
        PyvtkEventDataDevice_Type.tp_dict = enumdict;
    
  • Replication factor: Hundreds of files; every enum with constants produces a tp_dict = assignment.
  • Classification: CONSIDER.
  • Fix: Replace pytype->tp_dict reads with PyObject_GetAttrString(pytype, "__dict__") or better: seed the dict contents via PyType_Spec (for class-level constants) or via PyObject_SetAttrString for runtime-assigned enum values. For enum tp_dict = enumdict — use PyObject_SetAttrString(pytype, "__dict__", enumdict) … except the type's dict is meant to be read-only after PyType_Ready. More idiomatic: iterate the enum constants and call PyObject_SetAttrString(pytype, name, enumval) on the heap-type after creation. Effort: 1-2 days in the generator.

Category 4: Non-limited macros

No occurrences found of PyTuple_GET_ITEM, PyList_GET_ITEM, PyBytes_AS_STRING, PyUnicode_DATA, PyUnicode_READ, PyUnicode_KIND, PyList_GET_SIZE, PyTuple_GET_SIZE, PyCFunction_GET_FUNCTION across the entire scoped tree (wrap-infra + generator).

VTK consistently uses the bounds-checking equivalents: PyBytes_AsString, PyBytes_Size, PyBytes_AsStringAndSize, PyUnicode_AsUTF8AndSize, PyList_GetItem, PyTuple_GetItem. All of these are in the limited API (PyUnicode_AsUTF8AndSize added in 3.10, others from 3.2).

Classification: N/A — this is a non-issue.


Category 5: Non-limited headers

No non-limited-API headers found in scope. Only two non-Python.h Python headers are included:

Header Includer abi3 status
<dictobject.h> PyVTKObject.cxx:29 Allowed (in limited_api_headers list)
<structmember.h> PyVTKMethodDescriptor.cxx:8 Allowed (in limited_api_headers list)

No cpython/, internal/, pycore_*, or any private header path. No frameobject.h use in scope (only PyThreadState by pointer in vtkPythonCommand, which is abi3-safe).

Classification: N/A — this is a non-issue.


Category 6: Generator-emitted violations — the leverage view

This is the highest-ROI section. Every abi3 violation in all 1,828 generated binding files traces back to one of these 6 generator lines.

# Generator site (file:line) What it emits Typical output (file:line) Replication factor Fix description
G1 vtkWrapPythonClass.c:454 static PyTypeObject Py%s_Type = { PyVarObject_HEAD_INIT(&PyType_Type, 0) ...} block for every VTK class vtkObjectPython.cxx:1496 (and ~1,700 more) Every vtkObjectBase-derived class — the majority Rewrite to emit PyType_Spec + PyType_Slot[], construct via PyType_FromSpecWithBases at first PyvtkXxx_ClassNew() call. Single-digit-hours code change; days of testing.
G2 vtkWrapPythonType.c:669 Same, for "special" value types (e.g. vtkVariant, vtkRectd) vtkEventDataPython.cxx — subset for value types All special types, low hundreds Same rewrite as G1 with sizeof(PyVTKSpecialObject).
G3 vtkWrapPythonEnum.c:187 Same, for enum types, emits sizeof(PyLongObject) as tp_basicsize and &PyLong_Type as tp_base — unstable-layout struct reference vtkEventDataPython.cxx:several enum types Every wrapped C++ enum — thousands across all modules Rewrite to use PyType_FromSpecWithBases([&PyLong_Type]) + spec with Py_tp_base=PyLong_Type. Drop sizeof(PyLongObject) — inherit from base.
G4 vtkWrapPythonClass.c:377, 384, 389 and vtkWrapPythonType.c:872, 877 pytype->tp_base = (PyTypeObject *)Py%s_ClassNew(); vtkEventDataPython.cxx:627, 1178, 2059 ~1,828 occurrences Subsumed by G1/G2 fix — the base becomes an argument to PyType_FromSpecWithBases; line disappears.
G5 vtkWrapPythonClass.c:418, 529; vtkWrapPythonType.c:895; vtkWrapPythonEnum.c:99 PyObject *d = pytype->tp_dict; and Py%s_Type.tp_dict = enumdict; for constant population vtkCommandPython.cxx:939; vtkEventDataPython.cxx:2096, 2135, 2175 Several hundred occurrences Replace with PyObject_SetAttrString(pytype, name, value) in a loop after PyType_FromSpec returns.
G6 vtkWrapPythonNumberProtocol.c:76, vtkWrapPythonType.c:497 static PyNumberMethods Py%s_AsNumber = { ... } / static PySequenceMethods Py%s_AsSequence = { ... } Emitted per class that has number/sequence protocol Dozens of classes Replace with Py_nb_* / Py_sq_* slots in the type's PyType_Slot[]. Since slots live in the type spec, the separate method tables go away.

Secondary (but negligible-effort) emissions:

  • vtkWrapPythonClass.c:497, 529offsetof(PyVTKObject, vtk_weakreflist) and offsetof(PyVTKObject, vtk_dict). Under abi3 with 3.11+ floor: use Py_TPFLAGS_MANAGED_WEAKREF and Py_TPFLAGS_MANAGED_DICT, removing these offset slots entirely. Under a 3.10 floor: keep offsets but emit them as Py_tp_weaklistoffset/Py_tp_dictoffset slots in the spec.
  • vtkWrapPythonClass.c:533, vtkWrapPythonEnum.c:239, vtkWrapPythonType.c:750PyObject_GC_Del/PyObject_Del as tp_free. Both symbols are stable ABI; they just need to be referenced as Py_tp_free slot values, not inline struct fields. No behavior change.
  • vtkWrapPythonType.c:134, 144PyObject_Del(self) emitted in cleanup paths. abi3-safe as-is.

Leverage summary: 6 generator files, ~20 fprintf/emission edits, fixes all 1,828 generated bindings atomically. This is the "fix once, fix 1830" ROI anchor for Ben.


Category 7: Build system

Files examined

  • /home/danzin/projects/laruche/repositories/vtk-source/VTK-9.6.1/Wrapping/PythonCore/CMakeLists.txt — no Py_LIMITED_API or abi3.
  • /home/danzin/projects/laruche/repositories/vtk-source/VTK-9.6.1/CMake/vtkModuleWrapPython.cmake — no abi3 configuration; generator invocation does not pass any limited-API flag.
  • /home/danzin/projects/laruche/repositories/vtk-source/VTK-9.6.1/CMake/vtkWheelPreparation.cmake, vtkWheelFinalization.cmake — produce wheels tagged with specific Python versions (cp311-cp311, etc.), not abi3/cp3*-abi3-*.
  • No setup.py, pyproject.toml, or setup.cfg at the VTK root (the only pyproject.toml under Wrapping/ path prefix is Examples/Build/VTKPythonExtensions/pyproject.toml which is an example of an extension using VTK, not VTK itself).

What would need to change for abi3 build

  1. Add target_compile_definitions(... PRIVATE Py_LIMITED_API=0x030A0000) (or higher floor) to Wrapping/PythonCore/CMakeLists.txt — the WrappingPythonCore module — AND to the generated-binding add_library calls in vtkModuleWrapPython.cmake. Searching for the actual add_library call in that CMake module shows generated bindings each become their own target — each needs the flag.
  2. Update wheel tag generation in vtkWheelPreparation.cmake / vtkWheelFinalization.cmake to emit abi3 wheel tags.
  3. Linker flags: on Windows, libraries compiled with Py_LIMITED_API must link against python3.lib rather than the version-specific python3X.lib. CMake change in whichever module handles Python linking.

Classification: POLICY (whether to enable it at all) + CONSIDER (the mechanical changes).


Migration assessment

Per-Python-version feasibility

Floor Viable? Required workarounds
3.9 Marginal Need pythoncapi-compat for: PyType_GetName (3.11), managed-dict flags (3.11), PyObject_GetOptionalAttr (3.13). Py_TPFLAGS_DISALLOW_INSTANTIATION (3.10) absent — enums cannot use it cleanly. Direct tp_new = nullptr hack on 3.9 static types no longer possible with PyType_FromSpec. Not recommended.
3.10 Recommended pythoncapi-compat fills: PyType_GetName, managed flags, PyObject_GetOptionalAttr. Py_TPFLAGS_DISALLOW_INSTANTIATION native. PyType_GetSlot native. The existing PY_VERSION_HEX >= 0x030A0000 guards throughout the wrap-infra already target exactly this version.
3.11 Good Drops need for PyType_GetName backport, Py_TPFLAGS_MANAGED_DICT/_WEAKREF native (removes offsetof(PyVTKObject, vtk_dict) construct entirely). Smaller effort than 3.10 but locks out 3.9/3.10 users.
3.12 Best from code-quality standpoint Py_NewRef/Py_XNewRef native, PyCFunction_CheckExact improvements. But 3.12 dropped a lot of userbase from enterprise environments; probably too aggressive for VTK.

Recommendation: floor at 3.10 with pythoncapi-compat as a thin bridge. Matches existing VTK version-guard style.

What's a one-line-generator fix vs. architectural rewrite

One-line-generator fixes (covered in Category 6):

  • All 2,257 static PyTypeObject -> PyType_Spec conversions
  • All pytype->tp_base writes in generated bindings
  • All Py%s_Type.tp_dict = enumdict writes
  • sizeof(PyLongObject) abuse in enum emission
  • Number/Sequence/Mapping/Buffer static method tables

Architectural rewrites (wrap-infra, must be done by hand):

  1. PyVTKMethodDescriptor.cxx — replaces a private CPython descriptor layout with a VTK-owned structure. The biggest wrap-infra task: define a new PyVTKDescrObject { PyObject_HEAD PyTypeObject *d_type; PyObject *d_name; PyMethodDef *d_method; }, own the layout, emit a heap type for it with appropriate members in the spec. Replaces PyDescr_TYPE/PyDescr_NAME with direct field access on the VTK-owned struct. Effort: 1-2 weeks.
  2. PyVTKReference.cxx _PyType_Lookup — 2 lines → replaced by PyObject_GetAttr on the value object (see Finding 1.1). Effort: 30 minutes.
  3. PyVTKTemplate/PyVTKNamespace subclassing PyModule_Type — this design pattern (&PyModule_Type as tp_base) is supported but unusual. The migration to PyType_FromSpecWithBases([&PyModule_Type]) is mechanical, but should be validated that module-subclass behavior is preserved under heap types. Effort: 1 week including testing Python API behavioral compat.
  4. PyVTKReference number-subtype hierarchyPyVTKNumberReference_Type / PyVTKStringReference_Type / PyVTKTupleReference_Type inherit from PyVTKReference_Type. Under heap types this becomes a two-phase setup (first create parent, then create children with PyType_FromSpecWithBases([parent_type])). Effort: 2-3 days.
  5. Cached PyVTKObject_Type pointer — currently a file-static. After migration this can be a PyObject* populated once during module init. Effort: 2 hours.

Estimated effort

Phase Effort
Generator rewrite (G1-G6) 2-3 engineer-weeks of code + testing
Wrap-infra rewrite (5 architectural tasks above) 2-3 engineer-weeks
Build system (CMake abi3 flags, wheel tag, Windows lib) 1 engineer-week
Cross-version test matrix (3.10, 3.11, 3.12, 3.13) + CI 1-2 engineer-weeks
Total 6-9 engineer-weeks (1.5-2 engineer-months)

What's unavoidable

  1. _PyType_Lookup in PyVTKReference.cxx — no abi3 replacement, but workable alternatives exist (Finding 1.1 alternatives). Not a true hard blocker after workaround applied.
  2. PyModule_Type as tp_base for PyVTKTemplate/PyVTKNamespace — not technically a blocker (it's a public type) but puts these types in unusual territory. May be considered architecturally risky — some PEP 489 guidance deprecates subclassing PyModule_Type in favor of PyModule_FromDefAndSpec. Ben should consider whether to redesign these to be plain types with a _module attribute rather than module-subclasses.
  3. PyMethodDescrObject layout access in PyVTKMethodDescriptor.cxx — removable by owning the layout, requires replacing the CPython builtin descriptor with a VTK equivalent. Moderate effort, fully fixable.

Recommendation for Ben

Pursue abi3 adoption targeting a Python 3.10 floor, via a generator-first migration plan. VTK is much better positioned than the raw binding count suggests, because the 1,828 generated files collapse to 6 generator edits.

Phased plan

Phase 0 — Preparation (1 week)

  • Adopt pythoncapi-compat.h as a vendored header in Wrapping/PythonCore/.
  • Add CI test runs against multiple CPython versions (3.10-3.13) to catch regressions early.
  • Pick a single module (e.g. vtkCommonCorePython) as the abi3 canary.

Phase 1 — Wrap-infra hard blockers (2 weeks)

  • Replace _PyType_Lookup in PyVTKReference.cxx (Finding 1.1) — smallest, highest-priority fix; unblocks 3.15.
  • Rewrite PyVTKMethodDescriptor.cxx to own its descriptor layout — removes PyDescr_TYPE/PyDescr_NAME/offsetof(PyDescrObject, …).
  • Convert the 7 wrap-infra static PyTypeObject to PyType_FromSpec — gives you the framework before touching the generator.

Phase 2 — Generator rewrite (2-3 weeks)

  • Rewrite G1 (vtkWrapPythonClass.c:454) to emit PyType_Spec/PyType_Slot[] + PyType_FromSpecWithBases call.
  • Rewrite G2 (vtkWrapPythonType.c:669) identically.
  • Rewrite G3 (vtkWrapPythonEnum.c:187) — and crucially drop sizeof(PyLongObject), inherit basicsize from PyLong_Type.
  • Update G5 (dict population of constants) and G6 (number/sequence protocol tables).
  • G4 (pytype->tp_base =) disappears as side-effect.

Phase 3 — Build system (1 week)

  • Py_LIMITED_API=0x030A0000 in Wrapping/PythonCore/CMakeLists.txt and generated module targets.
  • Update wheel tagging in vtkWheelPreparation.cmake / vtkWheelFinalization.cmake to emit abi3 wheels.
  • Windows: link against python3.lib under Py_LIMITED_API.

Phase 4 — Validation (1-2 weeks)

  • Run VTK's test suite on 3.10, 3.11, 3.12, 3.13, 3.14 with the same wheel.
  • Verify no behavior changes for: VTK object Python subclassing, enum instantiation, smartpointer lifetime, callback invocation under GIL.
  • Exercise the buffer protocol (tp_as_buffer via Py_bf_getbuffer/Py_bf_releasebuffer slots) on vtkDataArray subclasses.

Known risks / items for Ben's decision

  1. Namespace-as-module-subclass: PyVTKNamespace_Type and PyVTKTemplate_Type subclass PyModule_Type. This is working in static form but is an unusual idiom under PyType_FromSpecWithBases. Ben should decide whether to preserve this design or migrate to a "namespace-as-plain-type-with-module-behavior" pattern.
  2. Performance baseline: PyType_GetSlot has a small cost vs direct ->tp_ access. For vtkPythonOverload.cxx inner loops (CheckArg called per method dispatch), profile before/after. Worst case, cache the slot function pointer once per type at class-register time.
  3. pythoncapi-compat dependency: Adds one vendored header. Alternative: implement a VTK-local vtkPythonCompat.h with just the handful of backports needed (Py_HashPointer, PyType_GetName, PyObject_GetOptionalAttr). Either way, small surface area.
  4. Simultaneous free-threading push: If VTK is also pursuing free-threading readiness, abi3 migration is compatible — PyType_FromSpec types can carry Py_TPFLAGS_IMMORTAL-like flags, and the move away from static types actually simplifies concurrent type-creation in subinterpreter scenarios.

Payoff

  • One wheel per platform per architecture, not one per Python version. Reduces VTK's wheel matrix from N×5 (py3.9–3.13) to N.
  • Forward compatibility with 3.14, 3.15, … without recompilation. The _PyType_Lookup 3.15 removal no longer breaks VTK.
  • Cleaner module code — the heap-type pattern is the CPython-idiomatic direction and removes the VTK_WRAP_PYTHON_SUPPRESS_UNINITIALIZED hack scattered throughout (needed only because PyTypeObject layout grows across versions, which doesn't happen with PyType_Spec).

Bottom line: this is a moderate migration, well within scope for a focused 2-month engineering effort by one person familiar with the code, and VTK's generator architecture makes it dramatically cheaper than the raw count of 1,828 binding files would suggest. The team has already planted the // XXX(python3-abi3) markers and started using PyType_GetSlot-guarded code — they're prepared for this.

VTK 9.6.1 — Actionable Items for Ben Boeckel

This list distills three analyses into a single prioritized, shippable punch list:

  • vtk_report.md — C-extension correctness (findings F1–F49)
  • vtk_ft_report.md — free-threading thread safety (R1–R14, U1–U6, P1–P6, M1–M10)
  • vtk_python_report.md — pure-Python layer (findings #1–#105)

Work through it top-to-bottom; items within each tier are ordered easy-and-proven first. Every item has Source: cross-references back to the originating report for full context.


At a glance

Total: 80 actionable items across 6 tiers (consolidated from ~187 raw findings across three streams; duplicates merged).

Reproducibility

Reproducibility Count Notes
Live crash-class (SEGV / abort / deadlock) 11 R1–R6, R11, R13, F22, F18, F41
Live-reproduced incorrect behavior 18 F8, F10, F16, F25, #1, #2, #4, #7, #13, #14, #15, #22, #26, #38, #39, #42, #45, #47, #48
Partial live (libfiu/OOM) 4 F14, F19, F21, F27
Source-confirmed, not runtime-observable 41 see per-item verdict
Policy / architectural / meta 6 T6 items

Fix-priority schedule

Week Theme Items
Week 1 Crashes + assert-under-O memory safety 1–10
Week 2 Type-slot trio + generator emission bugs (1,830× amplifier) 11–23
Week 3 C + Python correctness cluster 24–50
Week 4 FT hardening + silent-failure sweep 51–69
Month 2+ Cleanup / policy / docs 70–80

Single highest-impact intervention

#70 (Python layer #96): add ruff to CI with select=["F","E","UP","B"]. A single config file at Wrapping/Python/pyproject.toml plus one CI step catches findings #1–#15 (every F821 bug) and prevents recurrence of an entire class of bugs that reached a stable release because no linter runs on the Python layer. Half-day of engineering, enormous ROI.

Prerequisites

Most of the FT fixes are one coordinated change that should land as a single PR. A recommended PR structure is at the end of this document ("Suggested PR grouping").


Tier 1 — Crash-class (fix Week 1)

Eleven items. Eight of eleven are live-reproducible SEGVs / aborts / deadlocks under pip install vtk + Python 3.14t. Every one of these has a standalone 20-40 line reproducer in the FT appendix (vtk_ft_report_appendix.md).


1. ObjectMap unprotected std::map — rb-tree SEGV on concurrent construction

Severity: CRITICAL Classification: RACE Source: [R1] (FT) merges [F1] (cext) "9 shared maps, zero synchronization" Verdict: CONFIRMED CRASH (30/30 runs on 3.14t with 8-thread hammer)

Where:

  • Wrapping/PythonCore/vtkPythonUtil.cxx:
    • vtkPythonObjectMap::add lines 91–105
    • vtkPythonObjectMap::remove lines 106–125
    • AddObjectToMap lines 310–322
    • RemoveObjectFromMap lines 325–382
    • FindObject lines 385–422

Why: The ObjectMap is std::map<vtkObjectBase*, std::pair<PyObject*, std::atomic<int32_t>>>. The atomic value-type refcount is the only sync primitive and protects only itself — the rb-tree's parent/child pointer metadata is unsynchronized. Concurrent inserts from two Python threads produce null child pointers; the next rebalance walks null → SEGV at address 0x0.

Two labeille TSan stress scenarios SEGV here (mis-classified as "passed" in tsan_metadata.json because TSAN_OPTIONS=exitcode=0 swallowed the abort; their raw .txt files show ThreadSanitizer:DEADLYSIGNAL + ABORTING). A standalone PyPI-wheel reproducer also crashes reliably with 6 threads × 400 iterations.

Current code (schematic):

// vtkPythonUtil.cxx around line 310
void vtkPythonUtil::AddObjectToMap(PyObject* obj, vtkObjectBase* ptr) {
    ((PyVTKObject*)obj)->vtk_ptr = ptr;
    vtkPythonMap->ObjectMap->add(ptr, obj);   // unprotected std::map insert
}

Fix (Option A, minimal — 1 mutex, ~30 LOC):

// vtkPythonUtil.cxx (top of file)
#include <mutex>
static std::mutex g_wrapperRegistryMutex;

void vtkPythonUtil::AddObjectToMap(PyObject* obj, vtkObjectBase* ptr) {
    std::lock_guard<std::mutex> lock(g_wrapperRegistryMutex);
    ((PyVTKObject*)obj)->vtk_ptr = ptr;
    vtkPythonMap->ObjectMap->add(ptr, obj);
}

// Repeat `std::lock_guard` wrapping in every public method on
// vtkPythonMap's 9 containers: 23 call-sites total (FindObject,
// AddObjectToMap, RemoveObjectFromMap, FindClass, AddClassToMap,
// FindSpecialType, AddSpecialTypeToMap, FindNamespace, AddNamespace,
// RemoveNamespace, AddEnumToMap, FindEnum, AddModule,
// RegisterPythonCommand, UnRegisterPythonCommand, ...).

Fix (Option B, fine-grained — Pass 3 recipe, ~80 LOC):

Per-table PyMutex guards with shared wrapper_registry_mutex covering ObjectMap + GhostMap (they transition atomically — see item 2). Append-only tables (ClassMap / SpecialTypeMap / EnumMap) use a std::shared_mutex read-mostly variant. Full lock-ordering diagram in vtk_pass3_sync_plan.md §Q1.

Recommendation: ship Option A now to close the crash, revisit Option B if profiling shows contention. Option A is correct and has trivial blast radius.

Reproducer (standalone, PyPI wheel):

import os, subprocess, sys, textwrap

child = textwrap.dedent("""
    import signal; signal.alarm(20)
    import threading
    import vtk
    THREADS, ITERS = 6, 400
    def worker():
        for _ in range(ITERS):
            a = vtk.vtkDoubleArray()
            b = vtk.vtkIntArray()
            c = vtk.vtkStringArray()
            del a, b, c
    ts = [threading.Thread(target=worker) for _ in range(THREADS)]
    for t in ts: t.start()
    for t in ts: t.join()
    import os; os._exit(0)
""")
env = {**os.environ, "PYTHON_GIL": "0"}
r = subprocess.run([sys.executable, "-c", child], env=env,
                   capture_output=True, timeout=60)
stderr = r.stderr.decode("utf-8", errors="replace")
print("CRASH" if (r.returncode < 0 or "SEGV" in stderr) else "OK")
# Expected: CRASH (AddressSanitizer: SEGV at address 0x0 in
# std::_Rb_tree_insert_and_rebalance called from vtkPythonObjectMap::add)

2. GhostMap / vtkWeakPointerBase use-after-free

Severity: CRITICAL Classification: RACE Source: [R2] (FT) — a sub-issue of [F1] (cext) but deserves separate treatment Verdict: CONFIRMED CRASH + 5 TSan data-race warnings in labeille artifacts

Where: Wrapping/PythonCore/vtkPythonUtil.cxx:

  • RemoveObjectFromMap lines 325–382 (GhostMap erase at 352–365; insert at 368)
  • FindObject lines 385–422 (ghost-reclaim at 409)

Why: When the last PyVTKObject wrapper is dropped but the underlying vtkObject is still alive (a VTK filter holds the C++ ref), the wrapper is moved to a "ghost map" so a future lookup can resurrect it. If a fresh wrapper is created for the same pointer while another thread is tearing down the ghost entry, ~vtkWeakPointerBase (a value-type member of PyVTKObjectGhost) runs inside the map's erase() and frees memory that FindObject on thread T1 is still dereferencing. The 5 TSan warnings and trailing 0xdd…dd-poisoned SEGV at PyType_IsSubtype(vtk_class, …) in tsan_ghostmap_resurrection.txt trace to this.

Fix: Share g_wrapperRegistryMutex from item 1 (GhostMap and ObjectMap transition atomically). Additionally, move the PyVTKObjectGhost value out of the map via std::move into a local before releasing the lock, so ~vtkWeakPointerBase's free() does not alias with Python refcount paths under the critical section.

// Sketch inside RemoveObjectFromMap:
std::unique_ptr<PyVTKObjectGhost> evicted;
{
    std::lock_guard<std::mutex> lock(g_wrapperRegistryMutex);
    // ... existing erase/insert logic, but instead of letting
    //     the map destroy the ghost, std::move it into `evicted`
    auto it = vtkPythonMap->GhostMap->find(ptr);
    if (it != vtkPythonMap->GhostMap->end()) {
        evicted = std::make_unique<PyVTKObjectGhost>(std::move(it->second));
        vtkPythonMap->GhostMap->erase(it);
    }
}
// `evicted` destructor (including ~vtkWeakPointerBase) runs here,
// outside the lock, with no other thread able to reach the freed memory.

Reproducer:

import os, subprocess, sys, textwrap
child = textwrap.dedent("""
    import signal; signal.alarm(25)
    import threading
    import vtk
    THREADS, ITERS = 4, 300
    keepalive = vtk.vtkCollection()
    def worker():
        for _ in range(ITERS):
            obj = vtk.vtkPolyData()
            keepalive.AddItem(obj)
            obj.custom_attr = 'ghost bait'
            del obj
            r = keepalive.GetItemAsObject(keepalive.GetNumberOfItems()-1)
            _ = getattr(r, 'custom_attr', None)
    ts = [threading.Thread(target=worker) for _ in range(THREADS)]
    for t in ts: t.start()
    for t in ts: t.join()
    import os; os._exit(0)
""")
r = subprocess.run([sys.executable, "-c", child],
                   env={**os.environ, "PYTHON_GIL": "0"},
                   capture_output=True, timeout=90)
# Expected: SEGV on unknown address 0x0

3. vtkPythonCommand::obj unsynchronized pointer + racing destroy

Severity: CRITICAL Classification: RACE Source: [R3] (FT); also covered structurally by [F3] (cext) "vtkPython.h neuters PyGILState" Verdict: CONFIRMED CRASH (3 pystate-assertion scenarios in labeille + SEGV in PyPI wheel)

Where: Wrapping/PythonCore/vtkPythonCommand.cxx:

  • SetObject lines 30–35 (non-atomic store)
  • Execute lines 65–247 (non-atomic read; PyObject_Call at 223)
  • ~vtkPythonCommand lines 19–28 (writes this->obj = nullptr at 27)

Why: The destructor can null this->obj while another thread is inside Execute about to call PyObject_Call(this->obj, arglist, nullptr). Under the old GIL model this was serialized implicitly by the PyGILState_Ensure in the RAII class, but under FT vtkPythonScopeGilEnsurer is compile-time inert (see item 72 for the vtkPython.h macro that stubs it out) so the object pointer, refcount ordering, and CPython tstate state are all unsynchronized.

TSan produced three different pystate.c assertion crashes (tstate_wait_attach, tstate_activate); a PyPI-wheel reproducer produces a plain SEGV at address 0x8. Same underlying race.

Fix — three coordinated changes in vtkPythonCommand.{h,cxx}:

// 1. vtkPythonCommand.h — atomic pointer
#include <atomic>
class vtkPythonCommand : public vtkCommand {
    ...
    std::atomic<PyObject*> obj;   // was: PyObject* obj
};

// 2. SetObject — atomic store
void vtkPythonCommand::SetObject(PyObject* o) {
    Py_INCREF(o);
    PyObject* old = this->obj.exchange(o, std::memory_order_acq_rel);
    Py_XDECREF(old);
}

// 3. Execute — capture strong reference at entry
void vtkPythonCommand::Execute(vtkObject* ptr, unsigned long eventtype, void* callData) {
    if (!Py_IsInitialized()) return;
    PyObject* callable = this->obj.load(std::memory_order_acquire);
    if (!callable) return;
    Py_INCREF(callable);                   // strong ref for this call
    // ... use `callable` everywhere instead of this->obj ...
    PyObject* result = PyObject_Call(callable, arglist, nullptr);
    Py_DECREF(callable);
    // ... rest of Execute
}

// 4. ~vtkPythonCommand — atomic exchange, DECREF outside registry lock
vtkPythonCommand::~vtkPythonCommand() {
    vtkPythonUtil::UnRegisterPythonCommand(this);
    PyObject* old = this->obj.exchange(nullptr, std::memory_order_acq_rel);
    if (old && Py_IsInitialized()) {
        Py_DECREF(old);
    }
}

Depends on item 4 (PythonCommandList mutex) to close the full race.

Reproducer:

import os, subprocess, sys, textwrap
child = textwrap.dedent("""
    import signal; signal.alarm(20)
    import threading
    import vtk
    shared = vtk.vtkObject()
    def worker(tid):
        cb = lambda c, e: None
        for _ in range(300):
            tag = shared.AddObserver('ModifiedEvent', cb)
            shared.Modified()
            shared.RemoveObserver(tag)
    ts = [threading.Thread(target=worker, args=(i,)) for i in range(4)]
    for t in ts: t.start()
    for t in ts: t.join()
    import os; os._exit(0)
""")
r = subprocess.run([sys.executable, "-c", child],
                   env={**os.environ, "PYTHON_GIL": "0"},
                   capture_output=True, timeout=60)
# Expected: SEGV at 0x8, or tstate_wait_attach assertion on a TSan build

4. PythonCommandList register/unregister race

Severity: HIGH Classification: RACE Source: [R4] (FT) Verdict: CONFIRMED CRASH (tagged-pointer SEGV, PyPI wheel reproducer)

Where: Wrapping/PythonCore/vtkPythonUtil.cxx:

  • RegisterPythonCommand lines 258–264 (push)
  • UnRegisterPythonCommand lines 267–273 (linear-scan + erase)
  • ~vtkPythonCommandList lines 174–189 (walks list writing ->obj=nullptr)

Why: PythonCommandList is a std::vector<vtkWeakPointer<vtkPythonCommand>> accessed by every command ctor/dtor with no synchronization. The shutdown destructor walks the list and writes command->obj = nullptr for every registered command — concurrent with any thread still inside Execute. A PyPI-wheel reproducer produces a distinctive tagged-pointer SEGV (0x001000000a1c) consistent with torn vector-slot read.

Fix: Dedicated mutex separate from g_wrapperRegistryMutex because ~vtkPythonCommand may run on a C++ thread without any Python state:

// vtkPythonUtil.cxx
static std::mutex g_commandListMutex;    // NOT PyMutex — may be held without GIL

void vtkPythonUtil::RegisterPythonCommand(vtkPythonCommand* c) {
    std::lock_guard<std::mutex> lock(g_commandListMutex);
    vtkPythonMap->PythonCommandList->push_back(c);
}
void vtkPythonUtil::UnRegisterPythonCommand(vtkPythonCommand* c) {
    std::lock_guard<std::mutex> lock(g_commandListMutex);
    auto& v = *vtkPythonMap->PythonCommandList;
    v.erase(std::remove(v.begin(), v.end(), c), v.end());
}
// ~vtkPythonCommandList walks list under the same lock.

Reproducer — same family as item 3 but focused on add-remove churn rather than Modified():

child = textwrap.dedent("""
    import signal; signal.alarm(20)
    import threading; import vtk
    def worker():
        for _ in range(400):
            o = vtk.vtkObject()
            tag = o.AddObserver('ModifiedEvent', lambda c, e: None)
            o.RemoveObserver(tag)
            del o
    ts = [threading.Thread(target=worker) for _ in range(6)]
    for t in ts: t.start()
    for t in ts: t.join()
    import os; os._exit(0)
""")
# Expected: SEGV with tagged pointer

5. PyVTKObject::vtk_observers grow-by-copy double-free

Severity: CRITICAL Classification: RACE Source: [R5] (FT) Verdict: CONFIRMED CRASH (AddressSanitizer: attempting double-free)

Where: Wrapping/PythonCore/PyVTKObject.cxx:

  • PyVTKObject_AddObserver lines 822–854
  • PyVTKObject_Traverse lines 255–289 (in-place compaction at 270–276)

Why: vtk_observers is an unsigned long[] grown via new[2*m] / copy / delete[] tmp / self->vtk_observers = olist — a non-atomic pointer publish. Meanwhile PyVTKObject_Traverse reads the same pointer and compacts the array in-place. With 6 threads each adding 300 observers to a shared vtkObject while another thread runs gc.collect() in a loop, ASan reliably fires attempting double-free on the old array.

Current code (simplified):

// PyVTKObject.cxx around line 850
unsigned long* tmp = olist;
olist = new unsigned long[2 * m];             // racing alloc
for (unsigned long i = 0; i < n; i++) olist[i] = tmp[i];
delete[] tmp;                                 // racing free
((PyVTKObject*)obj)->vtk_observers = olist;   // non-atomic publish

Fix: Py_BEGIN_CRITICAL_SECTION(obj) around both PyVTKObject_AddObserver and PyVTKObject_Traverse (per-PyObject lock — observer adds on different objects don't contend).

void PyVTKObject_AddObserver(PyObject* obj, unsigned long id) {
    Py_BEGIN_CRITICAL_SECTION(obj);
    // ... existing body ...
    Py_END_CRITICAL_SECTION();
}

int PyVTKObject_Traverse(PyObject* o, visitproc visit, void* arg) {
    int err = 0;
    Py_BEGIN_CRITICAL_SECTION(o);
    // ... existing body ...
    Py_END_CRITICAL_SECTION();
    return err;
}

Reproducer:

child = textwrap.dedent("""
    import signal; signal.alarm(25)
    import threading, gc
    import vtk
    shared = vtk.vtkObject()
    stop = False
    def adder():
        tags = [shared.AddObserver('ModifiedEvent', lambda c, e: None)
                for _ in range(300)]
        for t in tags[::2]: shared.RemoveObserver(t)
    def gcer():
        while not stop: gc.collect()
    g = threading.Thread(target=gcer, daemon=True); g.start()
    ts = [threading.Thread(target=adder) for _ in range(6)]
    for t in ts: t.start()
    for t in ts: t.join()
    import os; os._exit(0)
""")
# Expected: AddressSanitizer: attempting double-free

6. PyVTKObject::vtk_buffer lazy double-free

Severity: HIGH Classification: RACE Source: [R6] (FT) Verdict: CONFIRMED CRASH (AddressSanitizer: attempting double-free)

Where: Wrapping/PythonCore/PyVTKObject.cxx: PyVTKObject_AsBuffer_GetBuffer lines 549–627; racy block 580–593 (delete[] + new[] on self->vtk_buffer).

Why: Buffer-protocol entry point inspects self->vtk_buffer[...] dimensions, delete[]s on mismatch, allocates new, overwrites. Critical detail: the labeille stress was clean on this because buffer_protocol_concurrent used uniform ndim (hitting an early-return path). Two threads calling memoryview(arr) with different SetNumberOfComponents values across threads reliably double-free.

Fix:

// PyVTKObject.cxx:580 area
Py_BEGIN_CRITICAL_SECTION(obj);
// ... existing block 580-593 (delete[] + new[] + assign) ...
Py_END_CRITICAL_SECTION();

Reproducer:

child = textwrap.dedent("""
    import signal; signal.alarm(25)
    import threading; import vtk
    arr = vtk.vtkDoubleArray()
    def shape_a():
        for _ in range(200):
            arr.SetNumberOfComponents(1); arr.SetNumberOfTuples(8)
            try: _ = bytes(memoryview(arr))
            except Exception: pass
    def shape_b():
        for _ in range(200):
            arr.SetNumberOfComponents(3); arr.SetNumberOfTuples(4)
            try: _ = bytes(memoryview(arr))
            except Exception: pass
    ts = [threading.Thread(target=shape_a), threading.Thread(target=shape_b),
          threading.Thread(target=shape_a), threading.Thread(target=shape_b)]
    for t in ts: t.start()
    for t in ts: t.join()
    import os; os._exit(0)
""")
# Expected: double-free

7. .override() races py_type reads

Severity: HIGH Classification: RACE Source: [R11] (FT) Verdict: CONFIRMED CRASH (SEGV)

Where: Wrapping/PythonCore/PyVTKObject.cxx (PyVTKClass::py_type mutation from .override()); ~15 readers across the wrap layer.

Why: User-facing API target.override(new_class) rewrites the py_type pointer while other threads are reading it for type dispatch. With one thread calling .override() in a hot loop and two others creating instances, ASan reports a SEGV within ~300 iterations.

Fix: std::atomic<PyObject*> for py_type with acquire/release semantics. Most reads can use load(std::memory_order_relaxed); the post-override consistency requirement is handled by the acquire side.

// PyVTKObject.h
class PyVTKClass {
    ...
    std::atomic<PyObject*> py_type;   // was: PyObject* py_type
};

// Every reader:
PyObject* t = cls->py_type.load(std::memory_order_acquire);

// override() writer:
cls->py_type.store(new_type, std::memory_order_release);

Reproducer:

child = textwrap.dedent("""
    import signal; signal.alarm(20)
    import threading; import vtk
    class MyA(vtk.vtkDoubleArray): tag = 'A'
    class MyB(vtk.vtkDoubleArray): tag = 'B'
    target = vtk.vtkDoubleArray
    def overrider():
        for i in range(300):
            try: target.override(MyA if i&1 else MyB)
            except Exception: pass
    def creator():
        for _ in range(600):
            try:
                a = vtk.vtkDoubleArray(); _ = type(a).__name__; del a
            except Exception: pass
    ts = [threading.Thread(target=overrider),
          threading.Thread(target=creator),
          threading.Thread(target=creator)]
    for t in ts: t.start()
    for t in ts: t.join()
    try: target.override(None)
    except Exception: pass
    import os; os._exit(0)
""")
# Expected: SEGV at 0x0

8. GC vs construction — deadlock (_PyEval_StopTheWorld can't interrupt pure C++)

Severity: HIGH Classification: RACE (deadlock) Source: [R13] (FT) Verdict: CONFIRMED DEADLOCK (15s hang)

Why: Python GC's _PyEval_StopTheWorld requires all threads at a safepoint. VTK's ObjectMap operations spend long windows inside std::map::operator[]/insert/erase with no Python API call — STW cannot interrupt pure-C++ code. One thread blocked on STW while another is deep inside the rb-tree = neither makes progress.

Fix: Item 1's g_wrapperRegistryMutex bounds the in-map window (the std::map operations complete in microseconds under the lock). Additionally, item 72's Py_MOD_GIL_NOT_USED declaration in the generator removes a separate thread-state class from the deadlock equation (prevents GIL auto-re-enable at import).

The deadlock resolves transitively once items 1 and 72 land. No standalone fix.

Reproducer:

child = textwrap.dedent("""
    import signal; signal.alarm(15)
    import threading, gc; import vtk
    stop = threading.Event()
    def churn():
        for _ in range(2000):
            a = vtk.vtkDoubleArray(); b = vtk.vtkPolyData(); c = vtk.vtkIntArray()
            del a, b, c
    def gc_loop():
        while not stop.is_set(): gc.collect()
    g = threading.Thread(target=gc_loop, daemon=True); g.start()
    ts = [threading.Thread(target=churn) for _ in range(4)]
    for t in ts: t.start()
    for t in ts: t.join()
    stop.set(); g.join(timeout=2)
    print('completed')
    import os; os._exit(0)
""")
# Expected: hang 15s, killed by alarm

9. UAF via __vtk__() in GetPointerFromObject

Severity: CRITICAL (security-adjacent) Classification: REFCOUNT (UAF) Source: [F22] (cext) Verdict: SOURCE-ONLY (latent; requires specific user code pattern)

Where: Wrapping/PythonCore/vtkPythonUtil.cxx:643–689.

Why: The coercion path DECREFs the __vtk__() method result before reading IsA() / GetClassName() on the underlying vtkObjectBase*. When a user __vtk__ returns a freshly-constructed VTK object (no other references), Py_DECREF(result) frees the underlying C++ object and the subsequent read is a read-after-free.

Reachable from every generated binding that accepts a VTK argument.

Current code (schematic):

PyObject* result = PyObject_CallMethod(obj, "__vtk__", nullptr);
vtkObjectBase* ptr = /* extract from result */;
Py_DECREF(result);            // frees C++ object if it was the last ref
/* ... read ptr here — UAF ... */
if (ptr->IsA(className)) ...

Fix: Capture the values needed from ptr before DECREFing:

PyObject* result = PyObject_CallMethod(obj, "__vtk__", nullptr);
vtkObjectBase* ptr = /* extract */;
const char* cname = ptr->GetClassName();      // read BEFORE Py_DECREF
bool isTarget = ptr->IsA(className);
// or: ptr->Register(nullptr) to take a C++ ref before the DECREF
Py_DECREF(result);
// ... use cname / isTarget

Reproducer (requires user code defining the hazardous pattern):

import vtk

class DefaultsToFresh:
    def __vtk__(self):
        return vtk.vtkPolyData()       # fresh, no other refs

# Pass to any VTK method expecting a vtk.vtkObject argument
app = vtk.vtkAppendFilter()
app.AddInputData(DefaultsToFresh())    # UAF reachable during coercion

Status: 50k-iteration stress did not crash under current CPython allocator; the timing window is narrow. The bug is real — the code is unambiguous — but timing + allocator reuse protects current users. Fix before a user does trip it.


10. assert-based validation vanishes under python -O (memory-safety)

Severity: CRITICAL Classification: CORRECTNESS / MEMORY-SAFETY Source: [#20] (Python layer) Verdict: CONFIRMED runtime — under -O, complex/3D/non-contiguous numpy arrays silently accepted

Where: Wrapping/Python/vtkmodules/util/numpy_support.py lines 134, 135, 137, 218.

Why: numpy_to_vtk and vtk_to_numpy use assert to validate inputs (contiguity, shape < 3, non-complex, type-in-map). Under -O all four asserts are no-ops → bad numpy inputs produce vtkTypeFloat64Array with silently-discarded imaginary parts, wrong layout, or uninitialized data. That memory flows into VTK C++ code.

Current code:

# util/numpy_support.py:134
assert not numpy.issubdtype(num_array.dtype, numpy.complexfloating), \
    "Complex numpy arrays cannot be converted to vtk arrays."
assert len(shape) < 3, "Only arrays of dimensionality 2 or lower are allowed"
assert num_array.flags.contiguous, "Only contiguous arrays are supported."

Fix: Use explicit raise:

if numpy.issubdtype(num_array.dtype, numpy.complexfloating):
    raise TypeError("Complex numpy arrays cannot be converted to vtk arrays.")
if len(shape) >= 3:
    raise ValueError("Only arrays of dimensionality 2 or lower are allowed.")
if not num_array.flags.contiguous:
    raise ValueError("Only contiguous arrays are supported; use numpy.ascontiguousarray.")

Reproducer:

python3 -O -c "
from vtkmodules.util import numpy_support as ns
import numpy as np
r = ns.numpy_to_vtk(np.array([1+2j], dtype=np.complex128))
print(type(r).__name__, '— complex silently accepted')
r = ns.numpy_to_vtk(np.zeros((2,2,2)))
print(type(r).__name__, '— 3D silently accepted')
r = ns.numpy_to_vtk(np.arange(100, dtype=np.float64).reshape(10,10)[:,::2])
print(type(r).__name__, '— non-contig silently accepted')
"
# Observed: all three silently return vtkTypeFloat64Array
# (non-debug Python drops the assert entirely, skipping validation).

Tier 2 — Type-slot correctness (1,830× amplifier — Week 1–2)

Six items. The generator emits identical slot wiring per-class (not inherited via tp_base), so each hand-written fix in PyVTKObject.cxx or each generator-template fix propagates to every one of ~1,830 generated VTK types.


11. PyVTKObject_Traverse doesn't visit vtk_dict → uncollectible cycles

Severity: HIGH Classification: TYPE-SLOT / REFCOUNT Source: [F8] (cext) with [#48] architectural corroboration Verdict: CONFIRMED live — cycle through user attribute not collected

Where: Wrapping/PythonCore/PyVTKObject.cxx lines 255–289.

Why: The per-instance dict at PyVTKObject::vtk_dict, exposed via tp_dictoffset at Wrapping/Tools/vtkWrapPythonClass.c:529, is mutable — users do obj.whatever = value. But Traverse never calls Py_VISIT(self->vtk_dict). Cycles through user-assigned attributes are invisible to the cyclic GC and never reclaimed.

Current code:

// PyVTKObject.cxx:255
int PyVTKObject_Traverse(PyObject* o, visitproc visit, void* arg) {
    // walks vtk_observers and maybe others — but NOT vtk_dict
    // ...
    return 0;
}

Fix (3 lines):

int PyVTKObject_Traverse(PyObject* o, visitproc visit, void* arg) {
    Py_VISIT(((PyVTKObject*)o)->vtk_dict);   // add this
    // ... existing body ...
    return 0;
}

Reproducer:

import gc, weakref, vtk
class MyPlane(vtk.vtkPlane): pass
obj = MyPlane()
obj.self_ref = obj         # cycle via per-instance dict
wr = weakref.ref(obj)
del obj
gc.collect()
assert wr() is not None    # STILL ALIVE — bug
print("BUG CONFIRMED" if wr() is not None else "fixed")

12. PyVTKObject_Type.tp_clear = nullptr — cycles can't be broken

Severity: HIGH Classification: TYPE-SLOT Source: [F9] (cext) Verdict: SOURCE-ONLY (pairs with item 11 — observable effect is item 11's)

Where: Wrapping/Tools/vtkWrapPythonClass.c:495 hardcodes nullptr for tp_clear in every emitted VTK class type.

Why: Even if Traverse WERE fixed (item 11), cycles couldn't be broken because there's no clear function. Ship both.

Fix — add a minimal tp_clear that drops the per-instance dict reference:

// New hand-written function in PyVTKObject.cxx:
int PyVTKObject_Clear(PyObject* o) {
    PyVTKObject* self = (PyVTKObject*)o;
    Py_CLEAR(self->vtk_dict);     // drop dict ref; GC can continue
    return 0;
}

// Generator template change at vtkWrapPythonClass.c:495:
// Before:
fprintf(fp, "  nullptr,                    // tp_clear\n");
// After:
fprintf(fp, "  PyVTKObject_Clear,          // tp_clear\n");

Reproducer: same as item 11 — the cycle survives GC precisely because no clear function runs.


13. Heap-type Py_DECREF(Py_TYPE(self)) missing — per-instance type-ref leak

Severity: HIGH Classification: REFCOUNT Source: [F10] (cext) Verdict: CONFIRMED live — exactly 1 type-ref leaked per subclass instance

Where: Wrapping/PythonCore/PyVTKObject.cxx:737 (INCREF); missing DECREF in PyVTKObject_Delete at lines 361–381.

Why: For subclasses (class MyFoo(vtkFoo): pass) the type is a heap type. FromPointer INCREFs the type at line 737. The matching DECREF in PyVTKObject_Delete is absent. Over a long-running program that creates many subclass instances, type objects accumulate permanent references.

Current code:

// PyVTKObject.cxx:361 (PyVTKObject_Delete)
static void PyVTKObject_Delete(PyObject* op) {
    PyVTKObject* self = (PyVTKObject*)op;
    // ... existing cleanup ...
    PyObject_GC_Del(op);
    // BUG: if Py_TYPE was INCREF'd at line 737, missing DECREF here
}

Fix (1–3 lines at the end of PyVTKObject_Delete):

PyTypeObject* tp = Py_TYPE(op);
PyObject_GC_Del(op);
if (PyType_HasFeature(tp, Py_TPFLAGS_HEAPTYPE)) {
    Py_DECREF(tp);
}

Reproducer:

import sys, vtk
class MyAlgorithm(vtk.vtkAlgorithm): pass
rc0 = sys.getrefcount(MyAlgorithm)
for _ in range(1000):
    o = MyAlgorithm()
    del o
print(f"type refcount delta: {sys.getrefcount(MyAlgorithm) - rc0}")
# Expected: 1000 (one leaked per instance)

14. PyVTKReference mutable but not GC-tracked

Severity: MEDIUM Classification: TYPE-SLOT / FLAGS Source: [F11] (cext) Verdict: SOURCE-ONLY

Where: PyVTKReference.cxx lines 759, 812, 865, 918.

Why: Four PyVTKReference* types declared Py_TPFLAGS_DEFAULT (no _HAVE_GC). But they ARE mutable — PyVTKReference_SetValue modifies self->value. User-built cycles through these leak permanently.

Fix: Add Py_TPFLAGS_HAVE_GC to the flags at all four declarations and implement tp_traverse (visit self->value) + tp_clear.


15. PyVTKTemplate + PyVTKNamespace GC flag mismatch with base

Severity: MEDIUM Classification: TYPE-SLOT / FLAGS Source: [F12] (cext) Verdict: SOURCE-ONLY (behavior depends on which flags CPython treats as inheritance-eligible)

Where: PyVTKTemplate.cxx lines 277, 288; PyVTKNamespace.cxx lines 73, 84.

Why: Both subclass GC-enabled PyModule_Type but declare Py_TPFLAGS_DEFAULT without _HAVE_GC. Flag-inheritance hazard — the base has HAVE_GC, subclasses may or may not propagate depending on CPython version.

Fix: Add Py_TPFLAGS_HAVE_GC explicitly (matches the base's implicit requirement).


16. PyType_Ready return value ignored in generator (latent OOM)

Severity: LOW (latent — only manifests under OOM) Classification: ERROR-HANDLING Source: [F45] (cext) Verdict: SOURCE-ONLY

Where: vtkWrapPythonClass.c:437, vtkWrapPythonType.c:906, vtkWrapPythonEnum.c:89, PyVTKExtras.cxx:101-103.

Why: On PyType_Ready failure (OOM during slot-walk) the emitted code continues as if success.

Fix:

// In each generator template:
fprintf(fp, "  if (PyType_Ready(&%s_Type) < 0) { return nullptr; }\n", className);

Tier 3 — Generator emission bugs (1,830× amplifier — Week 2)

Seven items. Each is a few-line diff in Wrapping/Tools/vtkWrapPython*.c that propagates to ~1,830 generated bindings.


17. Inverted DECREFs in namespace / class / enum registration (3 emission sites)

Severity: HIGH Classification: REFCOUNT / ERROR-HANDLING Source: [F15] (cext) Verdict: SOURCE-ONLY (code-confirmed in spot-check of generated bindings)

Where: 3 generator templates:

  • vtkWrapPython.c:487-490 — namespace registration
  • vtkWrapPython.c:552-555 — class registration
  • vtkWrapPythonEnum.c:151-156 — enum registration
  • plus PyVTKExtras.cxx:101-103 (hand-written; same bug class)

Why: Each emits a PyDict_SetItemString(d, name, obj) followed by Py_DECREF(obj) in the WRONG branch. On successful registration, PyDict_SetItemString refs the value (not steals); the DECREF should fire unconditionally to release the caller's reference. In some emission sites the DECREF is guarded by the failure path — leaking on success. In others the DECREF is emitted twice on the same path — double-free on failure.

Current code (representative, vtkWrapPython.c:552 area):

fprintf(fp,
    "  if (PyDict_SetItemString(d, \"%s\", py_class) != 0)\n"
    "  {\n"
    "    Py_DECREF(py_class);\n"                    // correct for failure
    "    return nullptr;\n"
    "  }\n"
    // BUG: missing Py_DECREF(py_class) on the success path
    , classname);

Fix — emit a single unconditional DECREF after the dict insert:

fprintf(fp,
    "  if (PyDict_SetItemString(d, \"%s\", py_class) != 0)\n"
    "  {\n"
    "    Py_DECREF(py_class);\n"
    "    return nullptr;\n"
    "  }\n"
    "  Py_DECREF(py_class);\n"                      // add this line
    , classname);

Audit each of the 4 emission sites individually — the pattern varies slightly between namespace / class / enum / Extras.


18. PyErr_Clear() in emitted RichCompare template — silently swallows MemoryError

Severity: HIGH Classification: PYERR_CLEAR / ERROR-HANDLING Source: [F16] (cext) Verdict: CONFIRMED live — vtkVector3d == EvilSeq() returns False after swallowing MemoryError

Where: Wrapping/Tools/vtkWrapPythonType.c:299. Propagates to ~40 special-object types (spot-check: vtkVectorPython.cxx alone has 5+ instances across richcompare specializations).

Why: The emitted template does:

if (so1 == nullptr) {
    PyErr_Clear();                          // unguarded — ANY exception
    Py_INCREF(Py_NotImplemented);
    return Py_NotImplemented;
}

MemoryError / KeyboardInterrupt / SystemExit from the arg-conversion path all get silently replaced with NotImplemented; the caller's == then returns False with sys.exc_info() == (None, None, None).

Current generator code:

// vtkWrapPythonType.c:299
fprintf(fp, "      PyErr_Clear();\n");

Fix (minimum diff):

fprintf(fp,
    "      if (!PyErr_ExceptionMatches(PyExc_TypeError)) {\n"
    "        Py_XDECREF(n1); Py_XDECREF(n2);\n"
    "        return nullptr;\n"
    "      }\n"
    "      PyErr_Clear();\n");

Reproducer:

import sys, vtk
class EvilSeq:
    def __len__(self): return 3
    def __getitem__(self, i): raise MemoryError(f"OOM at [{i}]")
    def __iter__(self): raise MemoryError("OOM during iter")

v = vtk.vtkVector3d()
v.SetX(1.0); v.SetY(2.0); v.SetZ(3.0)
result = (v == EvilSeq())
print(f"result={result}, sys.exc_info={sys.exc_info()}")
# Expected: result=False, sys.exc_info=(None, None, None)
#   (MemoryError silently swallowed)

19. PyErr_Clear() in emitted AddObserver override template

Severity: HIGH Classification: PYERR_CLEAR Source: [F17] (cext) Verdict: SOURCE-ONLY (generator-code-confirmed)

Where: vtkWrapPythonMethodDef.c:776. Emitted exactly once (guarded by strcmp("vtkObject", classname) == 0 at line 730), but the call site (obj.AddObserver(...)) is the most common VTK Python API call — multiplier is every AddObserver in every VTK Python program.

Why: Same unguarded-clear pattern as item 18 — any exception from observer-arg conversion is silently dropped.

Fix: Same shape as item 18 — guard the PyErr_Clear() with PyErr_ExceptionMatches(PyExc_TypeError).


20. Emitted Py<Class>_RShift has 6 unchecked allocations

Severity: HIGH Classification: NULL-SAFETY / OOM Source: [F18] (cext) Verdict: CONFIRMED ABRT under libfiu OOM injection

Where: Wrapping/Tools/vtkWrapPythonNumberProtocol.c:25–73. Emits nb_rshift wrappers for pipeline-chaining (alg1 >> alg2) with unchecked PyObject_HasAttrString / PyObject_GetAttrString / PyTuple_Pack / PyObject_Call at 6 sites.

Fix — each site needs a NULL check and cleanup of partially-built state. Audit the template and emit a PyErr_NoMemory() + early return nullptr after each allocation:

fprintf(fp,
    "  PyObject* tmp = PyObject_GetAttrString(%s, \"%s\");\n"
    "  if (!tmp) { Py_DECREF(earlier_obj); return nullptr; }\n"
    , ...);

Reproducer (libfiu OOM — see vtk_report_appendix.md for setup):

LD_PRELOAD=/path/to/fiu_run_preload.so:/path/to/fiu_posix_preload.so python3 -c "
import fiu, vtk
a = vtk.vtkAlgorithm(); b = vtk.vtkAlgorithm()
fiu.enable('libc/mm/malloc')
a >> b  # exit 134 / SIGABRT
"

21. Emitted Py<Class>_update unchecked allocations + Py_DECREF(NULL)

Severity: MEDIUM Classification: NULL-SAFETY / UB Source: [F19] (cext) Verdict: PARTIAL LIVE under libfiu

Where: vtkWrapPythonMethodDef.c:1305-1344.

Why: Similar unchecked-allocation cluster, plus Py_DECREF(NULL) is formal UB.

Fix: NULL-check each allocation; use Py_XDECREF where a NULL is possible on the error path.


22. Emitted PyvtkAlgorithm_Call — NULL flows into GetPointerFromObject

Severity: MEDIUM Classification: NULL-SAFETY Source: [F20] (cext) Verdict: SOURCE-ONLY

Where: vtkWrapPythonTemplate.c:283.

Why: SequenceGetItem(seq, i) result passes directly to GetPointerFromObject(obj) without NULL check. Reachable via algorithm.__call__([input]) with an input that can't be sequenced.

Fix: Add NULL check before forwarding.


23. Emitted exec wrapper: PyTuple_New(numOutputPorts) then PyTuple_SetItem unchecked

Severity: MEDIUM Classification: NULL-SAFETY Source: [F21] (cext) Verdict: PARTIAL LIVE under libfiu

Where: vtkWrapPythonMethodDef.c:1265-1274.

Fix: NULL-check PyTuple_New; early-return on NULL.


Tier 4 — Correctness cluster (Week 2–3)

Twenty-seven items. Mix of C-side (from the cext report), FT source-confirmed items, and Python-layer bugs from the third report. Most are one-line or few-line diffs.


24. vtkPythonOverload::CheckArg swallows MemoryError / KeyboardInterrupt

Severity: HIGH Classification: PYERR_CLEAR / ERROR-HANDLING Source: [F25] (cext) Verdict: CONFIRMED live — 3 methods produce empty-message TypeError instead of MemoryError

Where: Wrapping/PythonCore/vtkPythonOverload.cxx lines 304, 334, 375, 638.

Why: The method-dispatcher probes each overload signature; on coercion failure it PyErr_Clear()s and tries the next. A user __bool__/__int__/__float__ raising MemoryError is silently cleared; dispatch picks a wrong-but-acceptable overload or returns TypeError("no overload matches"). Original fatal exception is gone.

Fix (informed-pass recipe, ~15 LOC, no ABI break):

// vtkPythonArgs.h
enum vtkPythonArgPenalties {
    // ... existing values ...
    VTK_PYTHON_FATAL_ERROR = 65536,
};

// vtkPythonOverload.cxx — at each PyErr_Clear site (lines 304, 334, 375, 638):
if (PyErr_Occurred() &&
    !PyErr_ExceptionMatches(PyExc_TypeError) &&
    !PyErr_ExceptionMatches(PyExc_ValueError))
{
    return VTK_PYTHON_FATAL_ERROR;  // don't clear; signal dispatcher
}
PyErr_Clear();
// ... rest of signature-mismatch handling

Then in vtkPythonOverload::CallMethod's overload loop, short-circuit when any CheckArg returns VTK_PYTHON_FATAL_ERROR.

Reproducer:

import vtk
class EvilBool:
    def __bool__(self): raise MemoryError("OOM marker")

for m in ['SetAbortExecute', 'SetReleaseDataFlag']:
    a = vtk.vtkAlgorithm()
    try:
        getattr(a, m)(EvilBool())
    except MemoryError:
        print(f"{m}: MemoryError (correct)")
    except TypeError as e:
        print(f"{m}: BUG — got TypeError({e!r}) instead of MemoryError")

# Expected output:
#   SetAbortExecute: BUG — got TypeError(TypeError('SetAbortExecute argument 1: '))
#   SetReleaseDataFlag: BUG — got TypeError(TypeError('SetReleaseDataFlag argument 1: '))

25. PyVTKTemplate_HasKey / PyVTKTemplate_Get borrowed-reference bugs

Severity: HIGH (masked on 3.12+ by immortal objects; real on 3.8–3.11) Classification: REFCOUNT Source: [F23, F24] (cext) Verdict: MASKED (CPython 3.12+ immortal-object semantics absorb the bug)

Where: Wrapping/PythonCore/PyVTKTemplate.cxx:

  • HasKey line 56 (Py_DECREF on borrowed ref)
  • Get lines 148–153 (returns borrowed as new)

Why: PyDict_GetItem(self->dict, key) returns a borrowed reference. HasKey then Py_DECREFs it (corrupting the stored template-class entry). Get returns it without Py_INCREF, and the caller DECREFs (silently stealing the refcount from the stored entry).

On Python 3.12+ the stored type is immortal (_Py_IMMORTAL_REFCNT = 0xC0000000), so the DECREFs are absorbed — the bug is invisible. Before immortals (3.8-3.11), or if CPython ever reverses immortality, the stored class refcount drifts to zero and subsequent __getitem__ returns a dangling pointer.

Fix — classic borrowed-ref corrections:

// PyVTKTemplate.cxx:56 (HasKey)
PyObject* rval = PyDict_GetItem(self->dict, key);
// DO NOT Py_DECREF(rval) — it's borrowed
return (rval != nullptr);

// PyVTKTemplate.cxx:148 (Get)
PyObject* rval = PyDict_GetItem(self->dict, key);
Py_XINCREF(rval);   // add this — caller expects a new reference
return rval;

Reproducer — there isn't one on current CPython. Include the code audit in the fix PR description to justify the change even though TSan doesn't flag it.


26. vtkPythonUtil::AddModule — 6-call chain with zero NULL checks + Py_DECREF(nullptr) UB

Severity: HIGH Classification: NULL-SAFETY / OOM Source: [F26] (cext) Verdict: SOURCE-ONLY (crashes at module load time if vtkmodules import fails)

Where: Wrapping/PythonCore/vtkPythonUtil.cxx lines 1061–1075. Called from every VTK module's PyInit_*.

Current code:

void vtkPythonUtil::AddModule(PyObject* module) {
    PyObject* m   = PyImport_ImportModule("vtkmodules");      // may return NULL
    PyObject* f   = PyObject_GetAttrString(m, "addModule");   // unchecked
    PyObject* arg = PyTuple_New(1);                            // unchecked
    PyObject* nm  = PyUnicode_FromString(PyModule_GetName(module));
    PyTuple_SetItem(arg, 0, nm);
    PyObject* execVal = PyObject_CallObject(f, arg);
    Py_DECREF(execVal);                                        // nullptr if call fails → UB
}

Fix — standard cleanup-with-goto:

void vtkPythonUtil::AddModule(PyObject* module) {
    PyObject* m = PyImport_ImportModule("vtkmodules");
    if (!m) return;
    PyObject* f = PyObject_GetAttrString(m, "addModule");
    Py_DECREF(m);
    if (!f) return;
    PyObject* arg = PyTuple_New(1);
    if (!arg) { Py_DECREF(f); return; }
    PyObject* nm = PyUnicode_FromString(PyModule_GetName(module));
    if (!nm) { Py_DECREF(arg); Py_DECREF(f); return; }
    PyTuple_SET_ITEM(arg, 0, nm);        // steals ref
    PyObject* result = PyObject_CallObject(f, arg);
    Py_DECREF(arg);
    Py_DECREF(f);
    Py_XDECREF(result);                  // X: may be NULL on call failure
}

27. Property accessor PyTuple_New unchecked, passed to user callback

Severity: MEDIUM Classification: NULL-SAFETY Source: [F27] (cext) Verdict: PARTIAL LIVE under libfiu (GetInformation() hits MemoryError path)

Where: Wrapping/PythonCore/PyVTKObject.cxx lines 389–408. Every property accessor allocates an arg-tuple and passes to user callback without NULL check.

Fix: NULL-check PyTuple_New at each of the 3 sites.


28. 14 of 15 typed-buffer variants leak UTF-8 bytes on string input

Severity: MEDIUM Classification: REFCOUNT / LEAK Source: [F28] (cext) Verdict: SOURCE-ONLY

Where: Wrapping/PythonCore/vtkPythonArgs.cxx — the vtkPythonGetValue(const void*&, Py_buffer*, btype) template family.

Why: Only the void* specialization with btype='\0' correctly cleans up the intermediate PyUnicode_AsUTF8String result; the other 14 variants leak the bytes object on every string-input call.

Fix: Apply the btype='\0' cleanup pattern to all 15 variants (likely factor out a helper).


29. vtkPythonCommand::Execute — NULL arglist leak + pathlib UAF

Severity: HIGH Classification: NULL-SAFETY + REFCOUNT (UAF) Source: [F30] (cext) Verdict: SOURCE-ONLY

Where:

  • vtkPythonCommand.cxx:223 — two branches fall through with arglist = nullptr (unknown CallDataType int; non-"string0" CallDataType string). Then PyObject_Call(obj, NULL, NULL) is called, followed by Py_DECREF(NULL).
  • vtkPythonArgs.cxxvtkPythonGetFilePath(const char*&) borrows char* from PyUnicode_AsUTF8String, then DECREFs the bytes object before the char* is copied.

Fix:

For the NULL arglist path in Execute:

if (!arglist) {
    // unknown CallDataType — error out rather than NULL into PyObject_Call
    vtkErrorMacro("vtkPythonCommand::Execute: unknown CallDataType " << callDataType);
    return;
}

For the pathlib UAF in vtkPythonGetFilePath:

// Copy the UTF-8 into a caller-owned std::string before DECREF:
PyObject* bytes = PyUnicode_AsUTF8String(pathStr);
if (!bytes) return false;
std::string buffer(PyBytes_AsString(bytes), PyBytes_Size(bytes));
Py_DECREF(bytes);
// Use `buffer.c_str()` for the borrowed `char*` — owned by `buffer`

30. _PyType_Lookup × 2 — Python 3.15 break

Severity: MEDIUM (forward compat) Classification: VERSION-COMPAT / PRIVATE-API Source: [F31, F46] (cext) Verdict: SOURCE-ONLY (3.15 not yet shipped)

Where: Wrapping/PythonCore/PyVTKReference.cxx lines 226, 247.

Why: Private API on 3.15 removal trajectory. Current use treats result as borrowed reference (correct under the private API). The informed-pass recommendation is to replace with a public-API substitution that ALSO fixes two other bugs on the same code paths (interned-string leak; refcount-semantics mismatch that the naive runs would have introduced).

Fix: Replace with PyObject_CallMethod(ob, "__trunc__", nullptr) (for int coercion) / "__round__" (for float). Stable ABI since 3.2. Fixes 3 bugs at once.


31. PyVTKObject_FromPointer NULL-derefs on OOM

Severity: MEDIUM (OOM-only) Classification: NULL-SAFETY / OOM Source: [F14] (cext), also tracked as [R9] partial-init publish in FT Verdict: PARTIAL LIVE under libfiu (MemoryError path reached)

Where: Wrapping/PythonCore/PyVTKObject.cxx:753-789.

Why: Unchecked PyDict_New and PyObject_GC_New results → crash or leak under OOM.

Fix: NULL-check each allocation; early-return with proper cleanup.

Simultaneously addresses [R9] "partial-init publish" by letting you restructure so the ObjectMap insert happens LAST (after all fields are set, with NULL checks intermediate). See vtk_ft_report.md §R9 for the full reasoning.


32. ManglePointer returns pointer to process-wide static char[128]

Severity: MEDIUM (race real; Python-level observation window too small) Classification: RACE / THREAD-SAFETY Source: [R8] (FT), also [F5] (cext) Verdict: SOURCE-ONLY (tested 20k iterations, 0 Python-visible clobber; TSan-observable)

Where: vtkPythonUtil.cxx:1079-1090 (line 1081 is the static).

Why: Concurrent callers race on the buffer. Caller PyVTKObject_GetThis wraps it in PyUnicode_FromString immediately, but the window is real.

Fix — return std::string by value:

std::string vtkPythonUtil::ManglePointer(const void* ptr, const char* type) {
    char buf[128];
    std::snprintf(buf, sizeof(buf), "_%p_%s", ptr, type);
    return buf;
}
// Callers: use .c_str() on the returned string

API-level change but small diff. Alternative: thread_local char ptrText[128] (one-line change; keeps the API).


33–40. F821 undefined-name bugs (Python layer)

Eight items batched. These are all runtime-breaking bugs that reached a stable release because no linter runs on the Python layer (see item 70 for the root-cause fix). Each is a 1–5 line fix.

33. algs.reciprocal = numpy.sqrt — silent numerical corruption

Severity: CRITICAL Classification: CORRECTNESS / COPY-PASTE Source: [#1] (Python layer) Verdict: CONFIRMED live — algs.reciprocal(4.0) == 2.0 (should be 0.25)

Where: Wrapping/Python/vtkmodules/numpy_interface/algorithms.py:790.

# Current:
reciprocal = deprecated(...)(npalgs._make_ufunc(numpy.sqrt))
# Fix:
reciprocal = deprecated(...)(npalgs._make_ufunc(numpy.reciprocal))

Reproducer:

import warnings
warnings.simplefilter('ignore', DeprecationWarning)
from vtkmodules.numpy_interface import algorithms as algs
import numpy as np
print(f"algs.reciprocal(4.0) = {algs.reciprocal(4.0)}")   # 2.0 — WRONG
print(f"expected            = {np.reciprocal(4.0)}")      # 0.25

34. type_string undefined in pickle unserialize error path

Severity: HIGH Source: [#2] (Python layer) Verdict: CONFIRMED live — NameError instead of intended TypeError

Where: Wrapping/Python/vtkmodules/util/pickle_support.py:49.

# Current:
raise TypeError("Could not find type " + type_string + " in ...")
# Fix:
raise TypeError("Could not find type " + state["Type"] + " in ...")

35. vtkPNGWriter not imported in util/misc.py:114

Severity: HIGH Source: [#3] (Python layer) Verdict: STATIC-CONFIRMED (ruff F821)

Fix: Add from vtkmodules.vtkIOImage import vtkPNGWriter at the top of the file.

36. len(lhs == 0) — always TypeError (Pipeline DSL)

Severity: HIGH Source: [#4] (Python layer) Verdict: CONFIRMED live — [] >> select_ports(...) crashes

Where: util/execution_model.py:127.

# Current:
if len(lhs == 0):
# Fix:
if len(lhs) == 0:
# or better:
if not lhs:

The sibling Pipeline.__rrshift__ at line 244 already does len(lhs) == 0 correctly. Copy-paste typo.

37. Five more F821 bugs (batch fix)

Finding File:Line Fix
#5 web/testing.py:694testScriptfile (typo) testScriptFile
#6 web/render_window_serializer.py:1015mapper undefined Add mapper parameter or redesign function
#7 generate_pyi.py:621errflag UnboundLocalError Move errflag = False to before the loop
#9 gtk/GtkVTKRenderWindowInteractor.py:48vtkRenderWindow undefined See item 64 — delete gtk/
#10 tk/vtkTkImageViewerWidget.py:293xrange (Py2) range
#11 test/rtImageTest.py:136argv undefined sys.argv

All caught by a single ruff check --select F821 run (see item 70).


38. is [] identity comparison (dead branch; ValueError leaks)

Severity: MEDIUM Source: [#13] (Python layer) Verdict: CONFIRMED live

Where: numpy_interface/numpy_algorithms.py:222, 262, 295.

# Current:
if clean_list is []:           # always False (fresh literal identity)
    return ... # dead branch
# Fix:
if not clean_list:
    return ...

The "all-empty composite" safety branch in max/min/all was meant to catch empty input and early-return; the is [] identity comparison is always False, so the intended fallback never fires and numpy's ValueError: zero-size array to reduction operation leaks through.


39. DataaObjectKey typo alias (10+ years old)

Severity: MEDIUM Source: [#14] (Python layer) Verdict: CONFIRMED live

Where: util/keys.py:4.

# Current:
from vtkmodules.vtkCommonCore import vtkInformationDataObjectKey as DataaObjectKey
# Fix:
from vtkmodules.vtkCommonCore import vtkInformationDataObjectKey as DataObjectKey

The misspelled name works, the correctly-spelled one is missing.


40. substract typo in deprecation message

Severity: LOW (user-facing wrongness; doesn't break anything) Source: [#15] (Python layer) Verdict: CONFIRMED live

Where: numpy_interface/algorithms.py:995.

Deprecation warning says "Use np.substract() instead of algs.substract()" — np.substract doesn't exist (it's np.subtract). The live function is correctly named; only the warning message is wrong.


41. pickle_support error messages swapped

Severity: MEDIUM (misleading debugging) Source: [#22] (Python layer) Verdict: CONFIRMED live

Where: util/pickle_support.py:55, 72.

unserialize_VTK_data_object says "Marshaling data object failed" (it's un-marshaling); serialize_VTK_data_object says "UnMarshaling data object failed" (it's marshaling). Swap the strings.


42. 11 mutable default arguments

Severity: MEDIUM Source: [#26] (Python layer) Verdict: CONFIRMED live — VTKCompositeDataArray shares _Arrays across instances

Where: numpy_interface/dataset_adapter.py:492; web/dataset_builder.py lines 58, 126, 150, 517; test/BlackBox.py:20, 47; test/Testing.py:139, 146.

Worst offender is VTKCompositeDataArray.__init__(arrays=[]) — the most-instantiated class in numpy_interface.

Fix (11 call sites):

# Before:
def __init__(self, arrays=[]):
    self._Arrays = arrays if arrays else []

# After:
def __init__(self, arrays=None):
    self._Arrays = arrays if arrays else []

Auto-fixable with ruff check --select=B006 --fix.


43. _ArrayMemoryError fallback missing in data_model.py::set_array

Severity: MEDIUM Classification: ERROR-HANDLING / DIVERGENCE Source: [#34] (Python layer) Verdict: CODE-REVIEW (parallel implementations diverged)

Where:

  • numpy_interface/dataset_adapter.py:961-1044 — has the OOM fallback
  • util/data_model.py:104-181 — missing it

Same operation, divergent error handling. An OOM on a large-array assignment silently recovers in one wrapper, raises in the other.

Fix: Port the try/except _ArrayMemoryError block from dataset_adapter.py to data_model.py::set_array. Better long-term: extract shared _array_append.py helper (see item 76 — architectural).


44. getReferenceId bare except: swallows KeyboardInterrupt

Severity: HIGH (catches Ctrl-C mid-serialization) Source: [#38] (Python layer) Verdict: CONFIRMED live

Where: Web/Python/vtkmodules/web/__init__.py:54-62.

# Current:
try:
    return obj.__this__[-10:]
except:                    # catches KeyboardInterrupt, MemoryError, SystemExit
    return str(obj)[-11:]  # returns fabricated 11-char fake ID
# Fix:
except AttributeError:

45. if self._timeindex: misses timestep 0

Severity: MEDIUM (silent wrong-data bug) Source: [#39] (Python layer) Verdict: CONFIRMED live

Where: util/xarray_support.py:235.

Integer 0 is falsy; first timestep of any xarray dataset silently skips time-filtering.

# Current:
if self._timeindex:
# Fix:
if self._timeindex is not None:

46. os.environ.get("PATH").split(';') crashes when PATH unset

Severity: MEDIUM Source: [#42] (Python layer) Verdict: CONFIRMED live

Where: generate_pyi.py:634.

# Current:
for p in os.environ.get("PATH").split(';'):
# Fix:
for p in os.environ.get("PATH", "").split(os.pathsep):

Also ; is wrong on POSIX — use os.pathsep.


47. deprecated() decorator missing stacklevel=2

Severity: MEDIUM (46 existing deprecation warnings become actionable) Source: [#45] (Python layer) Verdict: CONFIRMED live — warnings point to util/misc.py:28, not caller

Where: util/misc.py:28.

# Current:
warnings.warn(warn, DeprecationWarning)
# Fix:
warnings.warn(warn, DeprecationWarning, stacklevel=2)

One-line change; 46 existing warnings instantly become debuggable.


48. Readthedocs snapshot pinned to 9.2.6

Severity: LOW (docs hygiene) Source: [#47] (Python layer) Verdict: CONFIRMED live — installed 9.6.1, docs show 9.2.6

Where: Documentation/docs/vtkmodules.__init__.py:1-3, 211.

Fix: Regenerate from a live 9.6 wheel, or integrate autodoc2 prep into CMake build. The @todo we need to make this automatic comment in vtk_documentation.py:148 has sat across three releases.


49. Two parallel DataSet class hierarchies (architectural)

Severity: HIGH (user-visible confusion) Source: [#48] (Python layer) Verdict: CONFIRMED live — vtkPolyData() vs WrapDataObject(vtkPolyData()) yield different classes

Where:

  • util/data_model.py (861 LOC, 21 classes, @.override style)
  • numpy_interface/dataset_adapter.py (1,501 LOC, wrapper style)

Share 7 identically-named classes (DataSet, PointSet, PolyData, UnstructuredGrid, DataSetAttributes, CompositeDataSetAttributes, CompositeDataIterator) with incompatible APIs (snake_case vs PascalCase, property vs method setters, association vs Association, __eq__ identity vs content).

Fix (interim — Week 3-4): Add forwarding aliases between the two hierarchies so both APIs become drop-in compatible. Example:

# In util/data_model.py, add PascalCase aliases for the public API:
class DataSet(...):
    point_data = property(...)
    PointData = point_data           # forward for numpy_interface consumers
    cell_data = property(...)
    CellData = cell_data

Fix (long-term — Month 2+, item 77): Publish a canonical dataset-wrapping approach, deprecate the other.


50. F32–F43 NULL-safety / realloc cluster (bulk fix)

Severity: MEDIUM Classification: NULL-SAFETY / OOM Source: [F32–F43] (cext) — 17 clang-tidy NullDereference sites + 26 bugprone-suspicious-realloc-usage sites across 10 files Verdict: SOURCE-ONLY (clang-analyzer confirmed)

Where (summary): vtkWrapPythonTemplate.c, vtkWrapPython.c, vtkWrapPythonOverload.c, vtkWrapPythonMethod.c, vtkWrapText.c, vtkParsePreprocess.c, vtkParseHierarchy.c, vtkWrapHierarchy.c, vtkParseString.c, vtkParseExtras.c.

Why: Generator-side C code with unchecked allocation results and the classic p = realloc(p, n); if (!p) pattern (leaks old p + NULL derefs on the same line).

Fix — single macro closes the realloc cluster (26 sites):

// In a shared header:
#define SAFE_REALLOC(ptr, newsize) do {                       \
    void* _tmp = realloc((ptr), (newsize));                   \
    if (!_tmp) { free(ptr); (ptr) = nullptr; return; }        \
    (ptr) = _tmp;                                             \
} while (0)

For the 17 NullDereference sites, each is an individual if (!x) return / if (!x) goto cleanup addition. See cext report section 5 for the full list. The generator-side ones propagate at build time — they're not runtime-visible to VTK users but they cause build failures under OOM (e.g., in highly-parallel CI).


Tier 5 — FT hardening + silent-failure sweep (Week 3–4)

Nineteen items. Source-confirmed but not runtime-observable under normal workloads; close these to harden against future stress patterns.


51. vtk_flags non-atomic RMW (source-only — only 1 bit in 9.6.1)

Severity: LOW (source-only; requires ≥2 concurrent bit sets to exhibit) Source: [R7] (FT) Verdict: SOURCE-ONLY

Where: PyVTKObject.cxx:861-871 (SetFlag); :856-859 (GetFlags).

Why: Mechanism for non-atomic |=/&=~ RMW is real, but the generator emits exactly one flag bit (VTK_PYTHON_IGNORE_UNREGISTER at vtkWrapPythonMethod.c:642). Lost-update requires ≥2 concurrent bits. The mechanism is real; current behavior is benign.

Fix (proactive):

// PyVTKObject.h
std::atomic<unsigned int> vtk_flags;

// PyVTKObject.cxx:861
void PyVTKObject_SetFlag(PyObject* obj, unsigned int flag, int val) {
    PyVTKObject* self = (PyVTKObject*)obj;
    if (val) self->vtk_flags.fetch_or(flag, std::memory_order_relaxed);
    else     self->vtk_flags.fetch_and(~flag, std::memory_order_relaxed);
}

52. ClassMap / SpecialTypeMap / NamespaceMap / EnumMap / ModuleList unprotected

Severity: HIGH (unobserved at user level; source-confirmed racy) Source: [R10] (FT) Verdict: NOT-OBSERVED (import lock serializes first-registration)

Where: vtkPythonUtil.cxx — 5 more process-global tables alongside ObjectMap.

Why: Same unprotected-std::map pattern as item 1, for 5 tables not exercised by the current stress (no concurrent submodule import, etc.). Python's import lock serializes first-time registration; steady-state is read-only on append-only structures (node-stable for std::map). NamespaceMap has an erase path (line 898-911) and is the exception.

Fix: If item 1 uses Option A (single mutex covering all tables), this is closed automatically. If Option B (per-table mutex), add 5 more guards. Full recipe in vtk_pass3_sync_plan.md §Q1.


53. vtkSmartPyObject::Object non-atomic pointer

Severity: MEDIUM Source: [R12] (FT) Verdict: SOURCE-ONLY (C++ internal; not Python-exposed directly)

Where: vtkSmartPyObject.cxx + header.

Fix:

// vtkSmartPyObject.h
std::atomic<PyObject*> Object;

// Every assignment: atomic exchange + DECREF of old.

54. PyVTKObject_Delete vs GhostMap capture ordering (N6)

Severity: HIGH (subsumed by item 2's mutex) Source: [R14] (FT, Pass 2 finding N6) Verdict: SUBSUMED by item 2 once g_wrapperRegistryMutex lands.


55. GetPointerFromSpecialObject holds iterator across PyObject_Call

Severity: HIGH (fragile pattern; not currently reachable) Source: [U2] (FT) Verdict: NOT-OBSERVED (SpecialTypeMap append-only + node-stable)

Where: vtkPythonUtil.cxx:792-875.

Fix: Copy the needed fields out of info into locals before releasing any ref / calling into Python:

// Under g_specialTypeMapMutex:
auto it = vtkPythonMap->SpecialTypeMap->find(typeName);
PyObject* py_type  = (it != end) ? it->second.py_type : nullptr;
PyObject* py_copy  = (it != end) ? it->second.py_copy : nullptr;
Py_XINCREF(py_type); Py_XINCREF(py_copy);
// Release lock, then call PyObject_Call with local copies.

56. PyThreadState_Swap in vtkPythonCommand::Execute (conditional)

Severity: MEDIUM (only reached if SetThreadState is set) Source: [U3] (FT) Verdict: SOURCE-ONLY

Where: vtkPythonCommand.cxx:92, 244.

Fix — gate with _PyThreadState_UncheckedGet():

PyThreadState* current = _PyThreadState_UncheckedGet();
if (!current && this->ThreadState) {
    prevThreadState = PyThreadState_Swap(this->ThreadState);
}

57. PyErr_Fetch / PyErr_Restore deprecated (2 sites)

Severity: LOW Classification: VERSION-COMPAT / MIGRATION Source: [U6] (FT), [F47] (cext) Verdict: SOURCE-ONLY

Where: vtkPythonArgs.cxx around lines 1548, 1560 (RefineArgTypeError).

Fix:

// Before:
PyObject *exc, *val, *tb;
PyErr_Fetch(&exc, &val, &tb);
// ... refine ...
PyErr_Restore(exc, val, tb);

// After (3.12+):
PyObject* exc = PyErr_GetRaisedException();
// ... refine ...
PyErr_SetRaisedException(exc);

Add pythoncapi-compat polyfill for older Python support.


58. PyVTKSpecialObject::vtk_hash lazy cache

Severity: LOW (TSan-only; deterministic result masks the race) Source: [P3] (FT) Verdict: TSAN-ONLY

Where: PyVTKSpecialObject.cxx; generator at vtkWrapPythonType.c:540-572.

Fix: std::atomic<Py_hash_t> with relaxed ordering.


59. Static singleton init (PyVTKObject_Type, vtkPythonMap)

Severity: LOW (init-time only; serialized by module import lock) Source: [P4, P5] (FT), [F44] (cext module-state) Verdict: SOURCE-ONLY

Where:

  • PyVTKObject.cxx:33, 143-146PyVTKObject_Type
  • vtkPythonUtil.cxx:195, 205-212vtkPythonMap

Fix: std::atomic<PyTypeObject*> or std::call_once:

static std::once_flag initFlag;
vtkPythonMap* vtkPythonUtil::CreateIfNeeded() {
    std::call_once(initFlag, [](){
        vtkPythonMap = new vtkPythonMap();
        Py_AtExit(vtkPythonUtilDelete);
    });
    return vtkPythonMap;
}

60. vtkParse_NewString string-cache realloc clobber

Severity: MEDIUM (generator-side; OOM-only) Source: [F41] (cext) Verdict: SOURCE-ONLY

Where: Wrapping/Tools/vtkParseString.c:822.

Why: Foundational string cache used throughout the parser. OOM leaks every cached string then NULL-derefs. Use SAFE_REALLOC macro from item 50.


61. vtkparse_string_replace buffer leak on no-substitution path

Severity: LOW Source: [F42] (cext) Verdict: SOURCE-ONLY

Where: Wrapping/Tools/vtkParseExtras.c:249.

Fix: free the pre-scan buffer before returning the no-sub input.


62. vtkWrapHierarchy_ReadHierarchyFile 2-D cleanup bug

Severity: LOW Source: [F43] (cext) Verdict: SOURCE-ONLY

Where: Wrapping/Tools/vtkWrapHierarchy.c:598, 644.

Why: Frees only outer lines; leaks every lines[i].

Fix:

for (int i = 0; i < n; ++i) free(lines[i]);
free(lines);

63. 14+ bare except: clauses across Python layer

Severity: MEDIUM (silently catches KeyboardInterrupt, MemoryError, SystemExit) Source: [#21] (Python layer) Verdict: STATIC-CONFIRMED (grep count 14)

Where (sample):

  • Web/Python/vtkmodules/web/testing.py lines 43, 52, 56, 65, 73, 404, 475 (6-clause cluster around Py2 imports)
  • Web/Python/vtkmodules/web/__init__.py:58 (item 44 fixes this one)
  • Wrapping/Python/vtkmodules/test/Testing.py:274, 311, 333, 571
  • Wrapping/Python/vtkmodules/wx/wxVTKRenderWindowInteractor.py:406
  • See item 21 in the Python report for full list.

Fix: Narrow each to the specific exception type. Auto-catch by ruff check --select=E722 --fix (gives a file-level flag; still needs manual narrowing).


64. serializeInstance returns None instead of raising

Severity: MEDIUM Source: [#37] (Python layer) Verdict: STATIC-CONFIRMED (web disabled in test build)

Where: Web/Python/vtkmodules/web/render_window_serializer.py:184-186.

Fix: Raise an explicit exception; callers shouldn't proceed with None.


65. wxVTKRenderWindowInteractor.OnSize bare except:

Severity: MEDIUM Source: [#40] (Python layer)

Where: Wrapping/Python/vtkmodules/wx/wxVTKRenderWindowInteractor.py:404-408.

Fix: except TypeError: (the comment says that's what was intended).


66. test_file_io bare except: + leaked fd

Severity: LOW Source: [#41] (Python layer)

Where: test/Testing.py:272-276.

Fix:

# Before:
try:
    f = open(path)
    f.close()
except:
    return False
return True
# After:
return os.path.isfile(path)

67. PyQtImpl NameError in Qt autodetect

Severity: LOW Source: [#36] (Python layer)

Where: qt/QVTKRenderWindowInteractor.py:57-77.

Fix: Initialize PyQtImpl = None BEFORE the try block so the if PyQtImpl is None: test works even on early import failure.


68. sampling_dimesions typo (web/dataset_builder)

Severity: LOW Source: [#43] (Python layer)

Where: web/dataset_builder.py:331.

Fix: Rename parameter to sampling_dimensions consistently.


69. protocols.py FIXME sebshowAxis silently ignored

Severity: LOW Source: [#44] (Python layer)

Where: web/protocols.py:205, 218 (updateOrientationAxesVisibility, updateCenterAxesVisibility).

Fix: actually use the showAxis parameter, or raise NotImplementedError.


Tier 6 — Cleanup / policy / docs (Month 2+)

Eleven items. Mostly non-shippable on a tight schedule but each pays long-term dividends.


70. HIGHEST META-IMPACT: Add ruff to CI

Source: [#96] (Python layer POLICY) Effort: half-day; prevents a whole class of recurrence

What: Create Wrapping/Python/pyproject.toml:

[tool.ruff]
target-version = "py39"

[tool.ruff.lint]
select = [
    "F",      # pyflakes — catches F821 (items 33-37, #5-15), F811, F841
    "E",      # pycodestyle
    "UP",     # pyupgrade — catches Py2 residue
    "B",      # bugbear — B006 mutable defaults (item 42), B905 zip strict=
]
ignore = [
    "E501",   # line length — VTK style doesn't require
    "N",      # naming — VTK intentionally uses PascalCase for C++ mirrors
]

Plus one CI step in the existing .github/workflows/ or GitLab CI:

- run: cd Wrapping/Python && pip install ruff && ruff check

This alone would have caught items 33–42, 46, most of 63, plus 100+ other ruff findings. Ship this first in Tier 6; everything else depends on keeping the Python layer clean.


71. Ship py.typed + hand-written stubs

Source: [#101] (Python layer POLICY) Effort: multi-week; start with small modules

generate_pyi.py writes py.typed but only for generated stubs. Hand-written modules (util.execution_model, util.data_model, numpy_interface.dataset_adapter, web.protocols) ship alongside the stubs and inherit the claim without any annotations.

Fix: Either annotate in-place or add sibling .pyi. Start with util/execution_model.py (small, high-visibility) and util/data_model.py.


72. Declare Py_MOD_GIL_NOT_USED officially (after Tier 1 lands)

Source: [M2] (FT), [F48] (cext) Verdict: ABSENT — verified with readelf .rodata on wheel

Where: Generator template Wrapping/Tools/vtkWrapPythonInit.c — the per-module init emits PyModuleDef_Slot but does NOT set Py_mod_gil. Add:

fprintf(fp,
    "  {Py_mod_gil, Py_MOD_GIL_NOT_USED},\n");

Critical ordering: this MUST come AFTER items 1–10 land. Declaring FT support before the races are fixed is worse than not declaring it — users get crashes instead of an auto-reenable safety net.

The cext report claimed the slot IS emitted (at vtkWrapPythonInit.c:102-104); objdump of the 9.6.1 wheel shows the string is absent. Either the claim predates the wheel build or the wheel strips the slot — confirm against your CI wheel build before landing. Either way, the right state after Week 1 is to EMIT it.


73. Remove or rename VTK_PYTHON_FULL_THREADSAFE dead knob

Source: [M5] (FT)

Where: Utilities/Python/vtkPython.h:97 silently #undefs the flag under Py_GIL_DISABLED, making it a dead knob under FT. Either remove the CMake option entirely, or rename to something less misleading (VTK_PYTHON_GIL_ENSURE — it controls whether the RAII class does Ensure/Release, not thread safety).


74. Generator #ifdef gate cleanup

Source: [M6] (FT), and one pass-3 recommendation

Where: vtkWrapPythonMethod.c:818-826, 993-1001.

Generator emits:

#ifdef VTK_PYTHON_FULL_THREADSAFE
    PyThreadState *ts = PyEval_SaveThread();
#endif
// ... C++ method call ...
#ifdef VTK_PYTHON_FULL_THREADSAFE
    PyEval_RestoreThread(ts);
#endif

Under FT this block is compiled out (dead code). Pass 3 recommends gating instead on !defined(Py_GIL_DISABLED) for clarity. No functional change; just clearer intent.

Also: item [F2] from the cext report flagged this as a try/catch bug — the PyEval_SaveThread/RestoreThread bracket around a C++ call that can throw permanently leaks the GIL if the C++ side throws. Fix in the same emission edit:

fprintf(fp,
    "#if !defined(Py_GIL_DISABLED)\n"
    "    PyThreadState *ts = PyEval_SaveThread();\n"
    "    try { result = op->%s(...); } catch (...) {\n"
    "      PyEval_RestoreThread(ts); throw;\n"
    "    }\n"
    "    PyEval_RestoreThread(ts);\n"
    "#else\n"
    "    result = op->%s(...);\n"
    "#endif\n");

75. Delete gtk/ subpackage (1,698 LOC Py2-only)

Source: [#8, #99] (Python layer) Verdict: CONFIRMED — fails at import pygtk on any Python 3

Where: Wrapping/Python/vtkmodules/gtk/ (4 files, 1,698 LOC). Calls apply() (Python 2 builtin, removed in Py3) and pygtk.require('2.0') (no Py3 backport).

Fix: Delete subpackage and remove from Wrapping/Python/CMakeLists.txt. Zero user impact — it's impossible to import this code on any supported Python.

Closes items #8, #9, #31 (redundant import) as a side effect.


76. Delete or modernize web/testing.py (broken on 3.12+)

Source: [#12, #100] (Python layer) Verdict: STATIC-CONFIRMED — imp removed in 3.12; wrapped in bare-except

Where: Web/Python/vtkmodules/web/testing.py (788 LOC). Uses imp and Queue at module scope; failure wrapped in a bare except: that silently masks the import error.

Fix (delete): rm web/testing.py + remove from CMakeLists.txt:9. Zero in-tree callers.

Fix (modernize): replace impimportlib.util, Queuequeue, narrow all bare excepts. ~half-day.

Recommendation: delete unless an out-of-tree user is known to depend on it.


77. Canonical dataset-wrapping decision (VTK 10 target)

Source: [#48, #97] (Python layer POLICY)

Decision needed: either (a) composition wrapper (dataset_adapter.py style, numpy-interface native) or (b) .override inheritance (data_model.py style, strictly more Pythonic). Publish a style guide, deprecate the other direction with user-visible DeprecationWarnings, mark a removal version.

Interim (item 49): add forwarding aliases so both APIs remain drop-in compatible.


78. Document naming rule

Source: [#98] (Python layer POLICY)

"VTK-C++ style (PascalCase/camelCase, vtk* prefix) for anything that mirrors a C++ class/method; snake_case for internal helpers and Pythonic APIs." Commit as a CONTRIBUTING note; configure ruff to ignore N801/N802/N803/N806 project-wide rather than per-module.


79. Split monolith files (optional — not urgent)

Source: [#49] (Python layer)

Candidates for natural-boundary splits:

  • numpy_interface/dataset_adapter.py (1,501 LOC, 22 classes) → arrays/
    • datasets/ + composite/
  • web/protocols.py (842 LOC, 8 RPC classes) → one-file-per-protocol-class
  • Web/Python/vtkmodules/web/render_window_serializer.py (1,410 LOC) — actually well-decomposed internally (37 per-class serializers); splitting would make navigation worse. Leave.

80. Docstring campaign (modularly)

Source: [#76–82] (Python layer)

Zero-docstring clusters worth addressing:

  • web/render_window_serializer.py — 2 of 37 public fns documented
  • web/dataset_builder.py — 0 of 7 public classes
  • web/camera.pySphericalCamera / CylindricalCamera / CubeCamera
    • quaternion math, no convention spec
  • util/vtkAlgorithm.py — canonical pattern for writing VTK filters in Python, no module docstring
  • util/data_model.py@.override mechanism entirely undocumented, critical for understanding module behavior

Start with util/data_model.py (explain @.override) and util/vtkAlgorithm.py (canonical pattern) — highest leverage per sentence written.


Suggested PR grouping

Given the scope, a single mega-PR is impractical. Recommended batching:

PR 1 — "Free-threading: close observed crashes" (Tier 1 items 1–8)

A focused FT fix. Changes:

  • Add g_wrapperRegistryMutex + g_commandListMutex in vtkPythonUtil.cxx
  • std::atomic<PyObject*> for vtkPythonCommand::obj
  • Py_BEGIN_CRITICAL_SECTION in PyVTKObject_AddObserver, PyVTKObject_Traverse, PyVTKObject_AsBuffer_GetBuffer
  • std::atomic<PyObject*> for PyVTKClass::py_type

Ship this first. It closes every observed crash in the FT stress run.

PR 2 — "Type-slot trio" (Tier 2 items 11–13 + 16)

  • Py_VISIT(vtk_dict) in PyVTKObject_Traverse
  • Add PyVTKObject_Clear; wire into generator template
  • Py_DECREF(Py_TYPE) for heap types in PyVTKObject_Delete
  • NULL-check PyType_Ready returns (4 generator sites)

1,830× amplifier; ships many bugs with small diffs.

PR 3 — "Generator emission cleanup" (Tier 3 items 17–23)

Generator-template changes; all in Wrapping/Tools/vtkWrapPython*.c. 1,830× amplifier. Each is a few-line diff; ship together because they share regression-test machinery.

PR 4 — "C-side correctness" (Tier 4 items 24–32, 50)

Hand-written correctness: CheckArg, AddModule, PyVTKTemplate refs, F28 typed buffers, F30 pathlib UAF, F31 _PyType_Lookup, NULL-safety.

PR 5 — "Python layer F821 sweep + ruff CI" (items 70, 33–48)

ruff config first (item 70), then the set of auto-fixable + manual F821 / mutable-default / bare-except fixes it surfaces. Shipping the config and the fixes together lets CI enforce no regressions from day one.

PR 6 — "FT hardening" (Tier 5 items 51–62)

Source-only FT items that don't yet crash but should close before the next FT stress run. Lower urgency than PRs 1-5.

PR 7 — "Declare FT support" (item 72)

Only after PRs 1, 2, 3, 6 are green under stress. Declares Py_MOD_GIL_NOT_USED to stop auto-re-enabling the GIL at import. Advertises what you've built.

PR 8+ — Cleanup / policy / docs (Tier 6, rest)


Cross-reference index

For traceability back to the source reports, here is the mapping from each finding number to the actionable item(s) that consume it. Use this when reviewing PRs against the original analyses.

Cext report (F1–F49) → actionable items

F# Item(s) Notes
F1 1, 2, 52 (Option A covers all tables) The umbrella FT finding
F2 74 Generator PyEval_SaveThread try/catch
F3 72, 73, 74 vtkPython.h macro — architectural
F4–F7 3, 56, 32 FT call sites
F8 11 Traverse missing vtk_dict
F9 12 tp_clear = nullptr
F10 13 Heap-type DECREF
F11 14 PyVTKReference
F12 15 PyVTKTemplate/Namespace
F13 Enum allocator — deferred (DISALLOW_INSTANTIATION saves us)
F14 31 OOM NULL-deref
F15 17 Inverted DECREFs
F16 18 RichCompare PyErr_Clear
F17 19 AddObserver PyErr_Clear
F18 20 RShift unchecked
F19 21 update unchecked
F20 22 PyvtkAlgorithm_Call
F21 23 exec PyTuple_New
F22 9 UAF via vtk
F23-F24 25 PyVTKTemplate borrowed-ref (masked on 3.12+)
F25 24 CheckArg swallows MemoryError
F26 26 AddModule chain
F27 27 Property accessor PyTuple_New
F28 28 typed-buffer UTF-8 leak
F29 Complexity-bug correlation (gets resolved by fixing F32-43)
F30 29 Execute NULL arglist + pathlib UAF
F31, F46 30 _PyType_Lookup (3.15 break)
F32–F43 50 NULL-safety + realloc cluster (bulk)
F41 60 vtkParse_NewString
F42 61 vtkparse_string_replace
F43 62 vtkWrapHierarchy 2D cleanup
F44 59, 72 Module state
F45 16 PyType_Ready ignored
F47 63 (via Python layer sweep) 22 dead pre-3.9 guards
F48 70, 72 pythoncapi-compat + MOD_GIL_NOT_USED
F49 78 CMake build floor

FT report (R1–R14 / U1–U6 / P1–P6 / M1–M10) → actionable items

Finding Item
R1 1
R2 2
R3 3
R4 4
R5 5
R6 6
R7 51
R8 32
R9 31 (same fix subsumes it)
R10 52
R11 7
R12 53
R13 8
R14 54
U1 73, 74 (macro and generator gate)
U2 55
U3 56
U4 — (templates registered serially; deferred)
U5 56 (duplicate of U3)
U6 57
P1 — (safe today; documented invariant)
P2 — (not found; confirmed in Pass 3)
P3 58
P4 59
P5 59
P6 53 (via R12)
M1 73
M2 72
M3 32
M4 57
M5 73
M6 74
M7 79 (optional design)
M8 — (optional design)
M9 53
M10 — (test-plan note)

Python report (#1–#105) → actionable items

Finding Item
#1 33
#2 34
#3 35
#4 36
#5 37
#6 37
#7 37
#8 75
#9 37 + 75
#10 37
#11 37
#12 76
#13 38
#14 39
#15 40
#16–18 — (docstring SyntaxErrors; fix via sweep)
#20 10
#21 63
#22 41
#26 42
#33, #34 43
#36 67
#37 64
#38 44
#39 45
#40 65
#41 66
#42 46
#43 68
#44 69
#45 47
#46 — (1-line __all__ addition; do during sweep)
#47 48
#48 49, 77
#49 79
#57 — (PyQt4/PySide fallback; removal decision)
#58, 59, 61, 62, 63, 64 auto-fixable in item 70
#76–82 80
#88–91 — (docs fixes; batch with #47)
#96 70
#97 77
#98 78
#99 75
#100 76
#101 71

Refuted findings ({#19, #35, #60}) carry no action.


Final notes

Live-reproducible items: every Tier 1 item is reproducible with a 15–40 line standalone Python snippet against pip install vtk==9.6.1 on free-threaded 3.14t. Reproducer scripts are in cext-review-toolkit/reports/vtk_ft_reproducers/ and inline in vtk_ft_report_appendix.md. Every Python-layer item in cext-review-toolkit/reports/vtk_python_report_appendix.md has an inline reproducer.

Source files referenced:

  • VTK tree: vtk-source/VTK-9.6.1/
  • Full C-side report: vtk_report.md
  • Full FT report: vtk_ft_report.md + Pass 1/2/3
  • Full Python report: vtk_python_report.md
  • Reproducer appendices: *_appendix.md for each

No git history was available at analysis time — the VTK source was extracted from a release tarball. Future reviews against a git clone should cover v9.5.0..HEAD for fix-propagation gaps and verification of the VTK_PYTHON_HAS_GIL consolidation path.

If an item here no longer applies (upstream fix already landed, or a claim turns out to be wrong like #19/#35/#60 in the Python report), mark it DONE in the PR description with a one-sentence note and leave the item number intact so downstream cross-refs keep working.

VTK 9.6.1 — Free-Threading Analysis Report

Scope: hand-written Python wrap layer at VTK-9.6.1/Wrapping/PythonCore/ (15 .cxx/.h files, ~9,545 LOC).

Audience: Ben Boeckel and VTK maintainers.

Methodology: three-pass review — Pass 1 naive static analysis (shared-state-auditor, atomic-candidate-finder, unsafe-api-detector, lock-discipline-checker); Pass 2 TSan-informed re-examination using artifacts from a Python 3.14.3+td TSan-instrumented stress run of tsan_stress_vtk.py (12 scenarios, 4 threads × 500 iterations, PYTHON_GIL=0); Pass 3 qualitative synchronization-primitive plan. Full per-pass detail in vtk_pass{1,2,3}_*.md alongside this report.

TSan instrumentation caveat: Python was TSan-instrumented, VTK's own .so files were NOT (built without -fsanitize=thread). TSan sees races only where VTK touches memory Python also touches. Races purely inside VTK C++ are mostly invisible to TSan unless they corrupt memory Python later notices. Warning count is a lower bound.


Migration Status

Attribute Current state
Py_MOD_GIL_NOT_USED declared No
GIL auto-re-enable at import Yes (stress run used PYTHON_GIL=0 to override)
Synchronization primitives in wrap layer Zerogrep mutex|std::lock|critical_section is empty
Atomic operations Exactly one (std::atomic<int32_t> in ObjectMap value type)
Existing FT opt-in code None
First-time analysis Yes

Executive Summary

Readiness: Far. VTK's Python wrap layer was designed around a single-threaded-attached GIL model and has zero synchronization primitives. Under free-threaded Python 3.14t, a standard TSan stress test produces 3 distinct crash classes and 1 deadlock in 12 scenarios.

TSan crash clusters (6 of 12 scenarios crashed or deadlocked)

  1. ObjectMap std::_Rb_tree_insert_and_rebalance SEGV — 2 scenarios. Null-pointer dereference in libstdc++ rb-tree rebalance called from vtkPythonObjectMap::addPyVTKObject_FromPointerPyVTKObject_New. Two scenarios (construct_destruct_arrays, parallel_first_class_touch) are misclassified as "passed" in the TSan metadata because TSAN_OPTIONS=exitcode=0 swallowed the abort — their raw stderr files contain full SEGV + ABORTING.

  2. GhostMap / vtkWeakPointerBase use-after-free — 1 scenario (ghostmap_resurrection), 5 TSan data-race warnings plus a trailing 0xdd…dd-poisoned SEGV during PyType_IsSubtype. Weak-pointer destructor on thread T2 frees memory that FindObject / _Py_MergeZeroLocalRefcount on thread T1 is still dereferencing.

  3. CPython pystate.c assertion crashes — 3 scenarios (observer_add_remove_invoke, callback_calls_back_into_vtk, invoke_with_calldata) all with tstate_wait_attach or tstate_activate assertion failure during PyObject_Call in vtkPythonCommand::Execute. Unsynchronized vtkPythonCommand::obj + racing PythonCommandList register/unreg

    • concurrent ObjectMap access via GetObjectFromPointer.
  4. Deadlock — 1 scenario (gc_collect_vs_construct) hung 91 s. Python GC's _PyEval_StopTheWorld cannot interrupt long-running pure-C++ code inside VTK's std::map operations.

Finding counts

  • RACE: 14 (4 TSan-confirmed, 10 source-confirmed)
  • UNSAFE: 6
  • PROTECT: 6
  • MIGRATE: 10 (including 1 architectural keystone)
  • SAFE: 4 patterns

Total: 36 findings across the wrap layer. Full inventory follows; detail in Pass 1/2 documents.

Fix structure

Pass 3 produced a concrete synchronization plan with 8 primitives and 1 structural change. The crashes cluster tightly enough that roughly 5 fixes close all observed crashes; the remaining 31 findings address unobserved hazards and long-tail hardening.


RACE Findings (fix immediately)

R1. ObjectMap unprotected std::map insert/find/erase

  • File: Wrapping/PythonCore/vtkPythonUtil.cxx
  • Lines: vtkPythonObjectMap::add 91-105; vtkPythonObjectMap::remove 106-125; AddObjectToMap 310-322; RemoveObjectFromMap 325-382; FindObject 385-422
  • Severity: CRITICAL
  • Evidence: TSan SEGV in construct_destruct_arrays and parallel_first_class_touch (both misclassified "passed"); ThreadSanitizer:DEADLYSIGNAL + ABORTING in both raw .txt files.
  • Description: The ObjectMap is std::map<vtkObjectBase*, std::pair<PyObject*, std::atomic<int32_t>>>. The atomic value-type refcount is the only sync primitive and protects only itself. The rb-tree's metadata (parent/child pointers) is not synchronized. Concurrent inserts from two threads racing rebalance produces null child pointer → dereference → SEGV at address 0x0.
  • Fix: Add a file-static PyMutex wrapper_registry_mutex; in vtkPythonUtil.cxx that guards every ObjectMap->* access. Under the lock, FindObject captures the PyObject*, INCREFs it, and returns; the caller is safe because the INCREF happens under the lock (no UAF window on release). Full recipe with lock-ordering invariants in vtk_pass3_sync_plan.md §Q1.

R2. GhostMap / weak-pointer destructor races ObjectMap insert

  • File: Wrapping/PythonCore/vtkPythonUtil.cxx
  • Lines: RemoveObjectFromMap 325-382 (GhostMap insert at 368, erase at 352-365); FindObject 385-422 (ghost-reclaim at 409)
  • Severity: CRITICAL
  • Evidence: 5 TSan data-race warnings in ghostmap_resurrection; trailing 0xdd…dd-poisoned SEGV at PyType_IsSubtype(vtk_class, …) after a freed PyVTKObjectGhost::vtk_class.
  • Description: When the last PyVTKObject wrapper is dropped but the underlying vtkObject is still alive (e.g., a VTK filter holds a reference), the wrapper is moved to a "ghost map" so a future lookup can resurrect it. If a fresh wrapper is created for the same pointer while the ghost entry is being torn down by another thread, the rebuilt PyVTKObject is racing the ~vtkWeakPointerBase destructor in the ghost entry. vtkWeakPointerBase is a value-type member of PyVTKObjectGhost, so its destructor runs inside the map's own erase().
  • Fix: Share R1's wrapper_registry_mutex (GhostMap and ObjectMap transition atomically). Additionally, move the PyVTKObjectGhost value out of the map via std::move into a local before releasing the lock, so ~vtkWeakPointerBase's free() does not alias with Python refcount paths under the critical section. Pass 3 §Q2.

R3. vtkPythonCommand::obj unsynchronized pointer + racing destroy

  • File: Wrapping/PythonCore/vtkPythonCommand.cxx
  • Lines: SetObject 30-35 (non-atomic store); Execute 65-247 (non-atomic read + PyObject_Call at 223); ~vtkPythonCommand 19-28 (writes this->obj = nullptr at line 27)
  • Severity: CRITICAL
  • Evidence: 3 scenarios with CPython pystate.c assertion crashes — tstate_wait_attach: state == _Py_THREAD_DETACHED, tstate_activate: bound_gilstate || tstate == gilstate_tss_get.
  • Description: The destructor can null out this->obj while another thread is inside Execute about to call PyObject_Call(this->obj, arglist, nullptr) at line 223. Also, SetObject from one thread races with Execute on another. Under the old GIL model this was serialized by PyGILState_Ensure in the RAII class; under FT, vtkPythonScopeGilEnsurer is a no-op (the macro at Utilities/Python/vtkPython.h:101-106 stubs both PyGILState_Ensure and PyGILState_Release to empty) so no synchronization occurs.
  • Fix: Three changes:
    1. PyObject* obj;std::atomic<PyObject*> obj; in vtkPythonCommand.h.
    2. In Execute, capture a strong reference at entry:
      PyObject* callable = this->obj.load(std::memory_order_acquire);
      if (!callable) return;
      Py_INCREF(callable);                    // strong ref for this call
      // ... use `callable` throughout instead of this->obj ...
      PyObject* result = PyObject_Call(callable, arglist, nullptr);
      Py_DECREF(callable);
    3. ~vtkPythonCommand: PyObject* old = this->obj.exchange(nullptr); then Py_XDECREF(old) under PythonCommandList lock (see R4). Pass 3 §Q3.

R4. PythonCommandList register/unregister race in destructor path

  • File: Wrapping/PythonCore/vtkPythonUtil.cxx
  • Lines: RegisterPythonCommand 258-264; UnRegisterPythonCommand 267-273; vtkPythonCommandList destructor 174-189
  • Severity: HIGH
  • Evidence: source-confirmed. Implicated by R3's crash path (destruction of a vtkPythonCommand from a C++ thread while another thread is in Execute).
  • Description: PythonCommandList is a std::vector<vtkWeakPointer<vtkPythonCommand>> accessed by every command constructor/destructor with no synchronization. Shutdown destructor (line 174-189) walks the list and writes command->obj = nullptr / command->ThreadState = nullptr for every registered command — concurrent with any thread still inside Execute.
  • Fix: Dedicated PyMutex command_list_mutex (not wrapper_registry_mutex — they have different lifetimes). The command destructor may run on a C++ thread without the GIL, so std::mutex is the right choice if PyMutex requires Python state; otherwise PyMutex. Pass 3 §Q3.

R5. PyVTKObject::vtk_observers grow-by-copy + Traverse mutation

  • File: Wrapping/PythonCore/PyVTKObject.cxx
  • Lines: PyVTKObject_AddObserver 822-854; PyVTKObject_Traverse 255-289 (does in-place compaction at 270-276)
  • Severity: CRITICAL
  • Evidence: Source-confirmed. Unobserved in stress because observer_add_remove_invoke crashed on R3 before exercising the grow path heavily.
  • Description: vtk_observers is an unsigned long[] allocated lazily and grown via new[2*m] → copy → delete[] tmp → self->vtk_observers = olist (a non-atomic pointer publish). Meanwhile PyVTKObject_Traverse reads the same pointer and mutates the array in-place (cleaning up dead observers). Classic lost-update and use-after-free hazard.
  • Fix: Py_BEGIN_CRITICAL_SECTION(obj) / Py_END_CRITICAL_SECTION() around both PyVTKObject_AddObserver and PyVTKObject_Traverse. The critical section is per-PyObject, so observer adds on different objects do not contend. Pass 3 §Q4.

R6. PyVTKObject::vtk_buffer lazy double-free

  • File: Wrapping/PythonCore/PyVTKObject.cxx
  • Lines: PyVTKObject_AsBuffer_GetBuffer 549-627 (the block 580-593 does delete[] + new[] without atomicity)
  • Severity: HIGH
  • Evidence: Source-confirmed. Unobserved in stress because buffer_protocol_concurrent ran on uniform-dimension arrays that hit an early-return path.
  • Description: The buffer-protocol entry point checks self->vtk_buffer[...] dimensions, delete[]s the old buffer on mismatch, allocates a new one, and overwrites self->vtk_buffer. Two threads calling memoryview(arr) with different shape views on the same vtkDataArray double-free or UAF each other.
  • Fix: Wrap block 580-593 in Py_BEGIN_CRITICAL_SECTION(obj). Pass 3 §Q4.

R7. PyVTKObject::vtk_flags non-atomic RMW

  • File: Wrapping/PythonCore/PyVTKObject.cxx
  • Lines: PyVTKObject_SetFlag 861-871; PyVTKObject_GetFlags 856-859
  • Severity: MEDIUM
  • Evidence: Source-confirmed. TSan cannot flag lost-updates on non-atomic RMW without observable memory corruption; scenario was "clean".
  • Description: unsigned int vtk_flags with bit RMW (|=, &= ~). Two threads setting different bits lose one update.
  • Fix: Change field type to std::atomic<unsigned int> and use fetch_or / fetch_and. Cheaper than a critical section. Pass 3 §Q4.

R8. ManglePointer returns a static char[128] buffer

  • File: Wrapping/PythonCore/vtkPythonUtil.cxx
  • Lines: ManglePointer 1079-1090 (line 1081: static char ptrText[128];)
  • Severity: HIGH
  • Evidence: Source-confirmed.
  • Description: Function-local static buffer returned by pointer. Caller PyVTKObject_GetThis wraps it in PyUnicode_FromString immediately, but the window between two concurrent threads writing to the same buffer is still real — one thread's ptrText can be clobbered before the other thread's PyUnicode_FromString reads it.
  • Fix: Return std::string by value, or format into a caller-provided buffer. API-level change, still small diff. Pass 3 §Q1.

R9. PyVTKObject_FromPointer publishes partially-initialized object

  • File: Wrapping/PythonCore/PyVTKObject.cxx
  • Lines: PyVTKObject_FromPointer 644-800 area
  • Severity: HIGH
  • Evidence: TSan warning #3 in ghostmap_resurrection — torn _Py_TYPE read during a racing GhostMap lookup. Traced by Pass 2 shared-state (§C4).
  • Description: The wrapper's type, dict, and vtk_ptr fields are set in sequence without ordering; another thread doing FindObject can see a PyVTKObject that's been added to ObjectMap but whose fields are not yet coherent.
  • Fix: Keep the new PyVTKObject local until all fields are set, then insert into ObjectMap last. If the caller path requires the wrapper to be in the map early for re-entrancy, protect the entire sequence with wrapper_registry_mutex (R1) and release only after all writes complete. Pass 3 §Q1-Q2.

R10. ClassMap / SpecialTypeMap / NamespaceMap / EnumMap / ModuleList unprotected

  • File: Wrapping/PythonCore/vtkPythonUtil.cxx
  • Severity: HIGH (unobserved, source-confirmed)
  • Description: Same unsynchronized-std::map pattern as R1 for 5 more process-global tables. These weren't exercised by the current 12-scenario stress (no concurrent submodule import, no concurrent namespace construction, no concurrent enum registration). The code is structurally identical to R1, so the race is real even without TSan confirmation.
  • Fix: Per-table PyMutex guards (g_classMapMutex, g_specialTypeMapMutex, etc.). ClassMap / SpecialTypeMap / EnumMap are append-only (no erase), so readers holding a pointer into the map after release are safe (std::map node-stability). NamespaceMap has an erase path (RemoveNamespaceFromMap at line 898-911) so readers must stay under the lock or take a strong reference first. Pass 3 §Q1.

R11. PyVTKClass::py_type mutation by user .override()

  • File: Wrapping/PythonCore/PyVTKObject.cxx and generator support
  • Severity: HIGH (unobserved)
  • Description: User-facing API PyVTKClass.override(new_class) rewrites py_type pointer. ~15 readers across the wrap layer read it without synchronization. A user calling .override() from one thread while another calls methods on that class races the type-pointer read.
  • Fix: std::atomic<PyObject*> for py_type, acquire/release semantics. Pass 3 §Q1.

R12. vtkSmartPyObject::Object non-atomic pointer member

  • File: Wrapping/PythonCore/vtkSmartPyObject.cxx + header
  • Severity: MEDIUM (transitively critical via N2 in vtk_pass2_shared_state.md)
  • Description: Every refcount op on vtkSmartPyObject wraps in vtkPythonScopeGilEnsurer (no-op under FT). The underlying Object pointer is not atomic; copy assignment + destruction from different threads on a shared instance races.
  • Fix: std::atomic<PyObject*> for Object plus atomic compare-exchange for assignment. Pass 3 §Q3.

R13. gc_collect_vs_construct deadlock — STW vs long C++ windows

  • Evidence: 91 s hang, no TSan output, killed by SIGALRM.
  • Description: Python GC's _PyEval_StopTheWorld requires all threads to reach a safepoint. VTK's ObjectMap operations can spend long windows inside std::map::operator[] without any Python API call — STW cannot interrupt pure-C++ code. One thread may be blocked on STW while another is deep inside the rb-tree; neither makes progress.
  • Fix: The same wrapper_registry_mutex from R1 bounds the in-map window. Additionally, the Pass 3 generator edit (§Q6 — unconditionally emit Py_MOD_GIL_NOT_USED) ensures VTK does not auto-enable the GIL at import, which removes a separate thread-state class from the deadlock equation. Pass 3 §Q5 proposes a gdb --pid=$! reproducer during the hang to confirm the thread placement.

R14. PyVTKObject_Delete vs GhostMap capture ordering

  • File: Wrapping/PythonCore/PyVTKObject.cxx (tp_dealloc path)
  • Severity: HIGH (new finding, Pass 2 N6)
  • Description: When a PyVTKObject's refcount reaches zero, tp_dealloc clears ObjectMap and potentially inserts into GhostMap. If another thread's FindObject runs between the two steps and attempts to resurrect the ghost before the insert completes, the lookup sees stale ObjectMap state and no GhostMap entry — returns nullptr and the caller creates a duplicate wrapper, violating the one-wrapper-per-pointer invariant.
  • Fix: Atomic transition under R1's wrapper_registry_mutex covering both ObjectMap erase and GhostMap insert. Pass 3 §Q2.

UNSAFE Findings

U1. vtkPython.h:93-106 macro-stubs PyGILState under FT

  • File: Utilities/Python/vtkPython.h
  • Lines: 93-98 (macro guards) + 101-106 (stub definitions)
  • Severity: CRITICAL architectural
  • Description: Under Py_GIL_DISABLED, VTK_PYTHON_HAS_GIL is not defined and VTK_PYTHON_FULL_THREADSAFE is #undef'd. Lines 101-106 then redefine PyGILState_Ensure() to ((PyGILState_STATE)0) and PyGILState_Release(state) to (state) = ((PyGILState_STATE)0). Every piece of code that used the RAII guard as implicit serialization is now unsynchronized — this is the umbrella architectural flaw. Covered under MIGRATE (M1) for the redesign.
  • Note: Under FT this is correct in the sense that there is no GIL to ensure. But the wrap-layer code was written as if the ensurer still serialized work. The architectural fix is to either (a) keep the stub and add explicit sync everywhere, or (b) redesign the ensurer to be a no-op on already-attached threads and a real PyGILState_Ensure on truly-C-native threads (for the VTK pipeline firing events from non-Python threads).

U2. GetPointerFromSpecialObject holds iterator across PyObject_Call

  • File: Wrapping/PythonCore/vtkPythonUtil.cxx
  • Lines: 792-875
  • Severity: HIGH (unobserved)
  • Description: Captures info = &it->second (pointer into SpecialTypeMap) then calls PyObject_Call(info->py_copy, …). Under the GIL this was safe; under FT another thread's AddSpecialTypeToMap insert can rebalance the map and info is a dangling iterator.
  • Fix: Copy the needed fields out of info into locals before releasing any ref/calling into Python. The map is append-only so capturing py_type / py_copy pointers into locals under g_specialTypeMapMutex is sufficient.

U3. PyThreadState_Swap in vtkPythonCommand::Execute (conditional)

  • File: Wrapping/PythonCore/vtkPythonCommand.cxx:92, 244
  • Severity: MEDIUM (downgraded from Pass 1's CRITICAL)
  • Description: Under #ifndef VTK_PYTHON_HAS_GIL (i.e., under FT) the Execute path uses PyThreadState_Swap(this->ThreadState) if ThreadState is set. In default use, ThreadState is null and this path is a no-op. If a caller uses vtkPythonCommand::SetThreadState to inject a specific tstate, then under FT the Swap without surrounding Ensure/Release may leave the thread in an inconsistent state.
  • Fix: Wrap with _PyThreadState_UncheckedGet() check and use the attach/detach pattern. Low priority because the default path doesn't exercise it. Pass 3 references in §Q3.

U4. PyDict_Next iteration in PyVTKTemplate.cxx (6 sites)

  • File: Wrapping/PythonCore/PyVTKTemplate.cxx
  • Severity: MEDIUM (unobserved)
  • Description: Iterates the module dict (PyModule_GetDict(self)) while PyVTKTemplate_AddItem elsewhere mutates it. Under FT CPython 3.14 dict is per-operation safe, but the whole iteration loop can miss or double-visit entries if mutated mid-scan.
  • Fix: Take the module dict's lock for the duration of the loop via Py_BEGIN_CRITICAL_SECTION(dict). Or snapshot the dict contents into a list first.

U5. PyThreadState_Swap at vtkPythonCommand.cxx:92 — discussed above (U3).

U6. Deprecated PyErr_Fetch/PyErr_Restore in vtkPythonArgs.cxx

  • File: Wrapping/PythonCore/vtkPythonArgs.cxx
  • Lines: 1548, 1560 (approximate; in RefineArgTypeError)
  • Severity: LOW (migration, not correctness)
  • Description: Deprecated API replaced by PyErr_GetRaisedException / PyErr_SetRaisedException. Not FT-hostile per se; just stale.
  • Fix: Swap APIs; one line each.

PROTECT Findings

P1. vtk_dict / vtk_weakreflist rely on CPython free-threading

  • File: Wrapping/PythonCore/PyVTKObject.cxx
  • Severity: LOW (safe today, fragile)
  • Description: The pointers are set once at construction and never reassigned. CPython 3.14's dict and weakref support free- threading-safe concurrent ops, so this is safe as long as nobody later adds code that reassigns vtk_dict. Flag for vigilance.

P2. Generator-emitted static caches per-generated-class

  • File: generated vtk*Python.cxx (not hand-written)
  • Severity: LOW (Pass 3 §Q6 scanned; no caches found)
  • Description: Pass 3 checked the generator templates and confirmed no per-class static caches are emitted. If this changes in future templates, re-audit.

P3. PyVTKSpecialObject::vtk_hash lazy cache on vtkVariant

  • File: Wrapping/PythonCore/PyVTKSpecialObject.cxx (and generated vtkWrapPythonType.c emits the hash logic)
  • Severity: LOW
  • Description: Lazy cache of the hash value for vtkVariant. First-call write races with other first-call writes. Lost work but not incorrect (all threads would compute the same hash).
  • Fix: std::atomic<Py_hash_t> with relaxed ordering.

P4. PyVTKObject_Type static singleton init

  • File: Wrapping/PythonCore/PyVTKObject.cxx:33, 143-146
  • Severity: LOW
  • Description: Written once during vtkCommonCore module import (line 145). Guarded by CPython import lock today; not guaranteed post-PEP-703.
  • Fix: std::atomic<PyTypeObject*> or protect via the module init sequence.

P5. vtkPythonMap singleton init

  • File: Wrapping/PythonCore/vtkPythonUtil.cxx:195, 205-212
  • Severity: LOW
  • Description: Lazy double-checked init. Usually runs during serialized module import.
  • Fix: std::call_once(initFlag, [](){ … });.

P6. vtkSmartPyObject usage pattern

  • File: Wrapping/PythonCore/vtkSmartPyObject.cxx
  • Severity: MEDIUM — included in R12 for the Object field; separate from that, the overall usage pattern is OK if R12 is fixed.
  • Description: The class is not Python-exposed; it's used internally. If R12 is applied, the contract "smart pointer thread-safe copy/assign" is met.

MIGRATE Findings (structural changes)

M1. Architectural: remove or rethink vtkPythonScopeGilEnsurer

  • File: Utilities/Python/vtkPython.h:114-154
  • Severity: CRITICAL architectural
  • Description: The RAII class exists to call PyGILState_Ensure/Release. Under FT these are macro-stubbed to no-ops, so the class does nothing. Extension code written assuming the class serialized work is now silently unsynchronized. Options:
    • Option A (minimal): keep the class a no-op under FT; add explicit per-table/per-object sync everywhere (R1-R14).
    • Option B (redesign): make the class intelligent — if _PyThreadState_UncheckedGet() returns non-null (thread already attached), do nothing; else call real PyGILState_Ensure (for truly-native C++ threads firing events into Python). This still doesn't serialize work, but it correctly handles the "non-Python thread fires an event" case which the old ensurer was designed for.
  • Recommendation: both. Option B handles the VTK-pipeline case (vtkMultiThreader firing into Python) correctly; Option A handles the common case. The per-finding fixes (R1-R14) are Option A for specific state. Ship both.

M2. Declare Py_MOD_GIL_NOT_USED in module-init

  • File: generator template Wrapping/Tools/vtkWrapPythonInit.c
  • Severity: MEDIUM
  • Description: VTK currently does not declare FT support, so importing VTK on 3.14t auto-re-enables the GIL. For the stress run we overrode with PYTHON_GIL=0. To actually ship FT support, the per-module init template must emit Py_MOD_GIL_NOT_USED.
  • Fix: one-line addition in the generator template that creates the PyModuleDef_Slot-based init. Only once all correctness fixes land.

M3. Replace ManglePointer static-buffer return

  • Same as R8, called out separately here because it's a small API rewrite (signature change).

M4. Replace PyErr_Fetch/PyErr_Restore

  • Same as U6.

M5. Remove VTK_PYTHON_FULL_THREADSAFE knob or rename

  • File: Utilities/Python/vtkPythonConfigure.h.in, vtkPython.h
  • Severity: LOW
  • Description: Under FT, the variable is #undef'd silently (vtkPython.h:97), making it a dead knob. Either remove it or rename to something like VTK_PYTHON_GIL_ENSURE to clarify its purpose (it's about Ensure/Release RAII, not "thread-safe").

M6. Generator edit: one #ifdef gate in vtkWrapPythonMethod.c

  • File: Wrapping/Tools/vtkWrapPythonMethod.c:818-826, 993-1001
  • Severity: LOW (cleanliness)
  • Description: Generator emits #ifdef VTK_PYTHON_FULL_THREADSAFE / PyEval_SaveThread / #endif. Under FT the block is dead code (compiled out). Pass 3 §Q6 recommends gating instead on !defined(Py_GIL_DISABLED) for clarity. No functional change.

M7. Consider per-object std::mutex for complex PyVTKObject state

  • Severity: LOW
  • Description: If Py_BEGIN_CRITICAL_SECTION performance is a concern at scale, a per-object std::mutex could be embedded in PyVTKObject. For now the critical-section approach is simpler and matches CPython's own patterns.

M8. Restructure ghost-resurrection protocol

  • Severity: MEDIUM — referenced by R2
  • Description: The ghost-map mechanism is inherently racy. A cleaner design would have PyVTKObject_Delete clear ObjectMap synchronously and NOT leave a "ghost" phase. Pass 3 §Q2 evaluated this and rejected it on behavior-compatibility grounds (some VTK API contracts rely on the resurrect-same-wrapper invariant). With the wrapper_registry_mutex fix the existing protocol becomes safe; restructure is optional and larger.

M9. Evaluate vtkSmartPyObject API contract

  • Severity: LOW
  • Description: Document whether vtkSmartPyObject instances may be shared across threads. If yes, R12's atomic Object field is mandatory. If no (each thread uses its own smart pointer), a weaker contract suffices.

M10. Stress coverage gaps for next TSan run

  • Severity: LOW (not a code change — a test plan)
  • Description: 7 Pass 1-predicted races that the 12-scenario stress did not exercise. Pass 2 proposed specific new scenarios:
    • concurrent submodule import (exercises ClassMap / SpecialTypeMap)
    • concurrent FunctionNamespace() construction (NamespaceMap)
    • concurrent enum registration (EnumMap)
    • SetUserMethod from non-Python threads (tests U3)
    • vtkMultiThreader events firing into Python (tests M1 option B)
    • concurrent .override() (tests R11)
    • special-object argument conversion on a shared instance (tests U2)
    • vtkDataArray buffer protocol with different ndim per thread (forces R6 to trigger) Add these before the next TSan run.

SAFE Patterns (confirmed safe)

  • shared_array_read_write: VTK's C++ atomic refcount on vtkDataArray held up for the pattern of concurrent set-value vs get-value. TSan clean. Scalar accessor methods are safe.
  • register_unregister_race: VTK's own vtkObjectBase::Register / UnRegister use an atomic counter; the fast path was hit without ObjectMap contention. Safe for the common refcount case.
  • PyVTKObject::vtk_dict / vtk_weakreflist: the pointer is set once at wrapper creation and never reassigned; CPython's internal locks cover concurrent content mutation. Safe if no future code reassigns the pointer.
  • PyTuple_GetItem(this->Args, …) at 12 sites in vtkPythonArgs.cxx: tuple contents are immutable and the caller holds a strong reference for the call's duration. 12 scanner findings correctly dismissed as false positives in Pass 1.

Recommendations

Phase 0 — prerequisites

  1. Decide on the vtkPythonScopeGilEnsurer architectural choice (M1). This keystones all other work.
  2. Update the stress script with the 8 scenarios from M10 so the next TSan run has more coverage.

Phase 1 — close observed crashes (5 fixes)

# Finding Change Effort
1 R1 ObjectMap Add wrapper_registry_mutex in vtkPythonUtil.cxx; guard all 9 ObjectMap ops. Moderate
2 R2 GhostMap Share wrapper_registry_mutex; move-out ghost entry before release. Moderate
3 R3 vtkPythonCommand::obj std::atomic<PyObject*> + strong-ref capture in Execute. Trivial
4 R4 PythonCommandList Dedicated command_list_mutex. Trivial
5 R9 partial-init publish Move ObjectMap insert to last step of PyVTKObject_FromPointer. Trivial

These five fixes close all 3 TSan-observed crash classes plus the deadlock (R13 resolves transitively). After landing, re-run the stress script to confirm.

Phase 2 — close source-confirmed races (8 fixes)

  • R5 vtk_observersPy_BEGIN_CRITICAL_SECTION(obj) in AddObserver and Traverse.
  • R6 vtk_bufferPy_BEGIN_CRITICAL_SECTION(obj) in AsBuffer_GetBuffer.
  • R7 vtk_flagsstd::atomic<unsigned int> conversion.
  • R8 ManglePointer — return std::string by value.
  • R10 (5 other tables) — per-table PyMutex guards.
  • R11 PyVTKClass::py_typestd::atomic<PyObject*>.
  • R12 vtkSmartPyObject::Objectstd::atomic<PyObject*>.
  • R14 delete-vs-ghost — covered by R1 mutex.

Phase 3 — migration hardening

  • U2 GetPointerFromSpecialObject iterator capture.
  • U3 PyThreadState_Swap attach/detach under FT.
  • U4 PyDict_Next iteration via Py_BEGIN_CRITICAL_SECTION(dict).
  • M1 option B — intelligent vtkPythonScopeGilEnsurer for native C++ threads.
  • M2 declare Py_MOD_GIL_NOT_USED (this is the "turn FT on" step).
  • M5/M6 cleanup of VTK_PYTHON_FULL_THREADSAFE.

Phase 4 — polish

  • P3 vtk_hash atomic.
  • P4/P5 init-once atomics or std::call_once.
  • U6 / M4 deprecated API replacement.
  • M8 consider ghost protocol redesign (optional).
  • M7 per-object mutex if profiling shows critical-section contention.

Phase 5 — declare support and publish

After Phases 1-4 and a clean TSan run with the expanded stress:

  • Add Py_MOD_GIL_NOT_USED in the generator's module-init template.
  • Announce FT support in VTK release notes.
  • Note in docs: VTK pipeline events firing from C++ threads require M1 option B; confirm vtkMultiThreader users remain safe.

For a phased migration plan

See Pass 3 details:

/home/danzin/projects/cext-review-toolkit/reports/vtk_pass3_sync_plan.md

Pass 3 provides the 8-primitive lock plan, lock-acquisition ordering, restructure recipe for ghost-map destructor, and the one-line generator edit.


References

  • Full per-pass outputs:
    • Pass 1: vtk_pass1_{shared_state,atomics,unsafe_apis,seed}.md + the lock-discipline summary in Pass 1's lock-checker agent conversation output.
    • Pass 2: vtk_pass2_{tsan_triage,shared_state,unsafe_apis,seed}.md
    • Pass 3: vtk_pass3_sync_plan.md
  • Raw TSan artifacts: reports/vtk_tsan/ (12 per-scenario .txt, tsan_metadata.json, test_stdout.txt).
  • Stress script: ft-review-toolkit/tsan_stress_vtk.py.
  • VTK source: /home/danzin/projects/laruche/repositories/vtk-source/VTK-9.6.1/.

VTK 9.6.1 — Free-Threading Reproducer Appendix

Purpose: attempt to reproduce every finding in vtk_ft_report.md from pure Python, without modifying VTK source.

Test environment:

  • Python: 3.14.3+ free-threading debug (Clang 21.1.2), /home/danzin/venvs/3.14_ft_venv/bin/python
  • VTK: pip install vtk==9.6.1 PyPI wheel
  • Env: PYTHON_GIL=0 (explicitly set per-reproducer; this Python's default policy keeps GIL disabled anyway)
  • Each reproducer runs in a subprocess so a crash in one doesn't poison the run (per our feedback_subprocess_isolate_scenarios rule).
  • signal.alarm(N) in each child for deadlock self-termination.

Verdict legend:

Verdict Meaning
CONFIRMED Reproduced reliably at the Python level
CRASH Hard crash (SEGV, abort, double-free) observed
DEADLOCK Process hung until alarm killed it
TSAN-ONLY Race is real but not observable without TSan
SOURCE-ONLY Pattern is real in source but unreachable from Python / serialized by other means
NOT-OBSERVED Stress didn't trigger the bug; may still be real
SUBSUMED Same mechanism as another finding, same reproducer

Summary of attempt (36 findings total):

  • 7 CONFIRMED CRASH or DEADLOCK at the Python level (R1, R2, R3, R4, R5, R6, R11, R13)
  • 3 TSAN-ONLY (confirmed by labeille's TSan stress artifacts: R2's 5 data-race warnings; R3's 3 CPython-assertion stacks; R1's 2 rb-tree SEGVs)
  • 6 NOT-OBSERVED (race exists but not reliably triggered without TSan: R7, R8, R9, R10, U2, U4, P3)
  • 3 SUBSUMED (R12, R14, U5)
  • 17 SOURCE-ONLY (architectural / migration / documentation: U1, U3, U6, M1–M10, P1, P2, P4, P5, P6)

RACE findings

R1 — ObjectMap std::_Rb_tree_insert_and_rebalance SEGV

Verdict: CONFIRMED CRASH

Also: TSan SEGV in labeille artifacts (tsan_construct_destruct_arrays.txt, tsan_parallel_first_class_touch.txt, both misclassified as "passed" in tsan_metadata.json).

import os, subprocess, sys, textwrap

child = textwrap.dedent("""
    import signal; signal.alarm(20)
    import threading
    import vtk
    THREADS, ITERS = 6, 400
    def worker():
        for _ in range(ITERS):
            a = vtk.vtkDoubleArray()
            b = vtk.vtkIntArray()
            c = vtk.vtkStringArray()
            del a, b, c
    ts = [threading.Thread(target=worker) for _ in range(THREADS)]
    for t in ts: t.start()
    for t in ts: t.join()
    import os; os._exit(0)
""")
env = {**os.environ, "PYTHON_GIL": "0"}
r = subprocess.run([sys.executable, "-c", child], env=env,
                   capture_output=True, timeout=60)
stderr = r.stderr.decode("utf-8", errors="replace")
crashed = (r.returncode < 0 or "SEGV" in stderr or "_Rb_tree" in stderr)
print(f"CONFIRMED: crash" if crashed else f"NOT REPRODUCED: exit={r.returncode}")

Output: CONFIRMED: crashAddressSanitizer: SEGV on unknown address 0x000000000000 at std::_Rb_tree_insert_and_rebalance in vtkPythonObjectMap::addPyVTKObject_FromPointerPyVTKObject_New. Root cause: concurrent insert into ObjectMap's unprotected std::map leaves rb-tree parent/child pointers inconsistent for rebalance walk.


R2 — GhostMap / vtkWeakPointerBase use-after-free

Verdict: CONFIRMED CRASH

Also: 5 TSan data-race warnings + trailing 0xdd…dd-poisoned SEGV in tsan_ghostmap_resurrection.txt.

import os, subprocess, sys, textwrap

child = textwrap.dedent("""
    import signal; signal.alarm(25)
    import threading
    import vtk
    THREADS, ITERS = 4, 300
    keepalive = vtk.vtkCollection()  # VTK-side ref keeps C++ object alive
    def worker():
        for _ in range(ITERS):
            obj = vtk.vtkPolyData()
            keepalive.AddItem(obj)
            obj.custom_attr = 'ghost bait'   # forces vtk_dict creation
            del obj                          # wrapper refcount → 0 → ghost
            resurrected = keepalive.GetItemAsObject(keepalive.GetNumberOfItems() - 1)
            _ = getattr(resurrected, 'custom_attr', None)
    ts = [threading.Thread(target=worker) for _ in range(THREADS)]
    for t in ts: t.start()
    for t in ts: t.join()
    import os; os._exit(0)
""")
env = {**os.environ, "PYTHON_GIL": "0"}
r = subprocess.run([sys.executable, "-c", child], env=env,
                   capture_output=True, timeout=90)
stderr = r.stderr.decode("utf-8", errors="replace")
crashed = (r.returncode < 0 or "SEGV" in stderr
           or "heap-use-after-free" in stderr or "0xdd" in stderr)
print(f"CONFIRMED: crash" if crashed else f"NOT REPRODUCED: exit={r.returncode}")

Output: CONFIRMED: crash (exit=1) with AddressSanitizer: SEGV on unknown address 0x000000000000. Thread T17 dereferences a vtkWeakPointerBase after another thread freed it via the GhostMap teardown path.


R3 — vtkPythonCommand::obj unsynchronized pointer + racing destroy

Verdict: CONFIRMED CRASH

Also: 3 CPython assertion crashes in labeille artifacts (tstate_wait_attach: state == _Py_THREAD_DETACHED, tstate_activate: bound_gilstate || tstate == gilstate_tss_get).

import os, subprocess, sys, textwrap

child = textwrap.dedent("""
    import signal; signal.alarm(20)
    import threading
    import vtk
    THREADS, ITERS = 4, 300
    shared_obj = vtk.vtkObject()
    def make_cb(tid):
        def cb(caller, event): pass
        return cb
    def worker(tid):
        cb = make_cb(tid)
        for _ in range(ITERS):
            tag = shared_obj.AddObserver('ModifiedEvent', cb)
            shared_obj.Modified()            # fires vtkPythonCommand::Execute
            shared_obj.RemoveObserver(tag)   # triggers ~vtkPythonCommand
    ts = [threading.Thread(target=worker, args=(i,)) for i in range(THREADS)]
    for t in ts: t.start()
    for t in ts: t.join()
    import os; os._exit(0)
""")
env = {**os.environ, "PYTHON_GIL": "0"}
r = subprocess.run([sys.executable, "-c", child], env=env,
                   capture_output=True, timeout=60)
stderr = r.stderr.decode("utf-8", errors="replace")
crashed = (r.returncode < 0 or "tstate_" in stderr
           or "SEGV" in stderr or "Aborted" in stderr)
print(f"CONFIRMED: crash" if crashed else f"NOT REPRODUCED: exit={r.returncode}")

Output: CONFIRMED: crash (exit=1) with AddressSanitizer: SEGV on unknown address 0x000000000008. This PyPI-wheel build produces a SEGV variant of the same underlying race; labeille's TSan build produces the CPython assertion variant — both from the same root cause (shared this->obj + concurrent destroy).


R4 — PythonCommandList register/unregister race

Verdict: CONFIRMED CRASH

import os, subprocess, sys, textwrap

child = textwrap.dedent("""
    import signal; signal.alarm(20)
    import threading
    import vtk
    THREADS, ITERS = 6, 400
    def worker():
        for _ in range(ITERS):
            o = vtk.vtkObject()
            tag = o.AddObserver('ModifiedEvent', lambda c, e: None)
            o.RemoveObserver(tag)
            del o
    ts = [threading.Thread(target=worker) for _ in range(THREADS)]
    for t in ts: t.start()
    for t in ts: t.join()
    import os; os._exit(0)
""")
env = {**os.environ, "PYTHON_GIL": "0"}
r = subprocess.run([sys.executable, "-c", child], env=env,
                   capture_output=True, timeout=60)
stderr = r.stderr.decode("utf-8", errors="replace")
crashed = (r.returncode < 0 or "SEGV" in stderr or "Aborted" in stderr)
print(f"CONFIRMED: crash" if crashed else f"NOT REPRODUCED: exit={r.returncode}")

Output: CONFIRMED: crash (exit=1) with AddressSanitizer: SEGV on unknown address 0x001000000a1c — tagged-pointer corruption consistent with torn std::vector slot read during concurrent push_back + erase on the PythonCommandList.


R5 — vtk_observers grow-by-copy + Traverse

Verdict: CONFIRMED CRASH (double-free — exact textbook signature)

Pass 1 predicted this; labeille's stress didn't hit the grow path heavily enough to trigger it — the scenarios that crashed did so on R3 first.

import os, subprocess, sys, textwrap

child = textwrap.dedent("""
    import signal; signal.alarm(25)
    import threading, gc
    import vtk
    THREADS, ITERS = 6, 300
    shared = vtk.vtkObject()
    stop = False
    def adder():
        tags = []
        for _ in range(ITERS):
            tags.append(shared.AddObserver('ModifiedEvent', lambda c, e: None))
        for t in tags[::2]:
            shared.RemoveObserver(t)
    def gcer():
        while not stop:
            gc.collect()
    ts = [threading.Thread(target=adder) for _ in range(THREADS)]
    g = threading.Thread(target=gcer, daemon=True); g.start()
    for t in ts: t.start()
    for t in ts: t.join()
    stop = True
    g.join(timeout=2)
    import os; os._exit(0)
""")
env = {**os.environ, "PYTHON_GIL": "0"}
r = subprocess.run([sys.executable, "-c", child], env=env,
                   capture_output=True, timeout=60)
stderr = r.stderr.decode("utf-8", errors="replace")
crashed = (r.returncode < 0 or "SEGV" in stderr or "double-free" in stderr)
print(f"CONFIRMED: crash" if crashed else f"NOT REPRODUCED: exit={r.returncode}")

Output: CONFIRMED: crash (exit=1) with AddressSanitizer: attempting double-free on 0x700aa9dfe040. Thread T17 double-frees the old vtk_observers array — exactly the pattern predicted for the new[2*m]/copy/delete[]/swap grow sequence racing a concurrent Traverse.


R6 — vtk_buffer lazy double-free

Verdict: CONFIRMED CRASH

Critical detail: the labeille stress was "clean" because buffer_protocol_concurrent used a uniform ndim, hitting an early-return path. Requires different SetNumberOfComponents across threads to trigger the lazy-realloc code block.

import os, subprocess, sys, textwrap

child = textwrap.dedent("""
    import signal; signal.alarm(25)
    import threading
    import vtk
    ITERS = 200
    arr = vtk.vtkDoubleArray()
    def shape_a():
        for _ in range(ITERS):
            arr.SetNumberOfComponents(1)
            arr.SetNumberOfTuples(8)
            try: _ = bytes(memoryview(arr))
            except Exception: pass
    def shape_b():
        for _ in range(ITERS):
            arr.SetNumberOfComponents(3)
            arr.SetNumberOfTuples(4)
            try: _ = bytes(memoryview(arr))
            except Exception: pass
    ts = [threading.Thread(target=shape_a), threading.Thread(target=shape_b),
          threading.Thread(target=shape_a), threading.Thread(target=shape_b)]
    for t in ts: t.start()
    for t in ts: t.join()
    import os; os._exit(0)
""")
env = {**os.environ, "PYTHON_GIL": "0"}
r = subprocess.run([sys.executable, "-c", child], env=env,
                   capture_output=True, timeout=45)
stderr = r.stderr.decode("utf-8", errors="replace")
crashed = (r.returncode < 0 or "SEGV" in stderr
           or "double-free" in stderr or "heap-" in stderr)
print(f"CONFIRMED: crash" if crashed else f"NOT REPRODUCED: exit={r.returncode}")

Output: CONFIRMED: crash (exit=1) with AddressSanitizer: attempting double-free on 0x709df8c0a0d0. The delete[] + new[] on self->vtk_buffer races between threads requesting different-shape views — exactly the PyVTKObject.cxx:580-593 block.


R7 — vtk_flags non-atomic RMW

Verdict: SOURCE-ONLY (not reachable in 9.6.1)

PyVTKObject_SetFlag is called from exactly one place in the wrap layer: generator-emitted code that sets VTK_PYTHON_IGNORE_UNREGISTER at Wrapping/Tools/vtkWrapPythonMethod.c:642. This is the only flag bit defined in 9.6.1. The non-atomic-RMW lost-update race requires ≥2 concurrent bit sets; with a single bit, |= flag races benignly (worst case is two threads both set the same bit — the result is the bit set, which is also the intended outcome).

The race mechanism remains real for future bits. No reproducer today.


R8 — ManglePointer static char[128] buffer

Verdict: NOT-OBSERVED (race real, window too small for Python-level observation)

import os, subprocess, sys, textwrap

child = textwrap.dedent("""
    import signal; signal.alarm(15)
    import threading
    import vtk
    THREADS, ITERS = 4, 5000
    mismatches = [0] * THREADS
    def worker(tid, obj, expected_this):
        local = 0
        for _ in range(ITERS):
            if obj.__this__ != expected_this:
                local += 1
        mismatches[tid] = local
    objs = [vtk.vtkObject() for _ in range(THREADS)]
    expected = [o.__this__ for o in objs]     # serial baseline
    ts = [threading.Thread(target=worker, args=(i, objs[i], expected[i]))
          for i in range(THREADS)]
    for t in ts: t.start()
    for t in ts: t.join()
    print(f"mismatches: {sum(mismatches)} / {THREADS*ITERS}", flush=True)
    import os; os._exit(0)
""")
env = {**os.environ, "PYTHON_GIL": "0"}
r = subprocess.run([sys.executable, "-c", child], env=env,
                   capture_output=True, timeout=30)
print(r.stdout.decode("utf-8", errors="replace").strip())

Output: mismatches: 0 / 20000. The window between ManglePointer writing to ptrText and PyUnicode_FromString reading it is microseconds; Python-level iterations don't schedule-switch finely enough to catch it. TSan would observe the race, but user-visible clobber is not reliable.


R9 — PyVTKObject_FromPointer publishes partially-initialized object

Verdict: TSAN-ONLY

Confirmed by TSan warning #3 in labeille's tsan_ghostmap_resurrection.txt: torn _Py_TYPE read during a racing FindObject call. Not separately reproducible from Python — it appears under the same workload as R2.

Mechanism: the ObjectMap insert at the START of PyVTKObject_FromPointer makes the new wrapper visible to FindObject before vtk_dict, vtk_ptr, and the type pointer are fully populated. Pass 3 §Q1–Q2 proposes moving the ObjectMap insert to be the last step.


R10 — ClassMap / SpecialTypeMap / NamespaceMap / EnumMap / ModuleList

Verdict: NOT-OBSERVED (import lock serializes first-time registration)

import os, subprocess, sys, textwrap

child = textwrap.dedent("""
    import signal; signal.alarm(30)
    import threading, importlib
    import vtk
    MODULES = [
        'vtkmodules.vtkFiltersCore', 'vtkmodules.vtkFiltersGeneral',
        'vtkmodules.vtkFiltersModeling', 'vtkmodules.vtkIOXML',
        'vtkmodules.vtkImagingCore', 'vtkmodules.vtkFiltersSources',
    ]
    def importer(name): importlib.import_module(name)
    ts = [threading.Thread(target=importer, args=(m,)) for m in MODULES]
    for t in ts: t.start()
    for t in ts: t.join()
    import os; os._exit(0)
""")
env = {**os.environ, "PYTHON_GIL": "0"}
r = subprocess.run([sys.executable, "-c", child], env=env,
                   capture_output=True, timeout=60)
stderr = r.stderr.decode("utf-8", errors="replace")
crashed = (r.returncode < 0 or "SEGV" in stderr or "_Rb_tree" in stderr)
print(f"CONFIRMED: crash" if crashed else f"NOT REPRODUCED: exit={r.returncode}")

Output: NOT REPRODUCED: exit=0. Python's import lock serializes the first-time registration paths into each of these tables. Subsequent lookups are pure reads on append-only trees (node-stable). Race exists in source but is serialized in practice by CPython's module init machinery.

A second attempt via concurrent vtkVariant creation (which routes through SpecialTypeMap) also ran clean — same reason: the SpecialType entry for vtkVariant is registered once at module import, then lookups are read-only.


R11 — PyVTKClass::py_type mutation via .override()

Verdict: CONFIRMED CRASH

import os, subprocess, sys, textwrap

child = textwrap.dedent("""
    import signal; signal.alarm(20)
    import threading
    import vtk
    ITERS = 300
    class MyA(vtk.vtkDoubleArray): tag = 'A'
    class MyB(vtk.vtkDoubleArray): tag = 'B'
    target = vtk.vtkDoubleArray
    def overrider():
        for i in range(ITERS):
            try: target.override(MyA if i & 1 else MyB)
            except Exception: pass
    def creator():
        for _ in range(ITERS * 2):
            try:
                a = vtk.vtkDoubleArray()
                _ = type(a).__name__
                del a
            except Exception: pass
    ts = [threading.Thread(target=overrider),
          threading.Thread(target=creator),
          threading.Thread(target=creator)]
    for t in ts: t.start()
    for t in ts: t.join()
    try: target.override(None)
    except Exception: pass
    import os; os._exit(0)
""")
env = {**os.environ, "PYTHON_GIL": "0"}
r = subprocess.run([sys.executable, "-c", child], env=env,
                   capture_output=True, timeout=45)
stderr = r.stderr.decode("utf-8", errors="replace")
crashed = (r.returncode < 0 or "SEGV" in stderr or "Aborted" in stderr)
print(f"CONFIRMED: crash" if crashed else f"NOT REPRODUCED: exit={r.returncode}")

Output: CONFIRMED: crash (exit=1) with AddressSanitizer: SEGV on unknown address 0x000000000000. Concurrent .override() writes a new type pointer while a creator thread reads it for type dispatch.


R12 — vtkSmartPyObject::Object non-atomic pointer

Verdict: SOURCE-ONLY

vtkSmartPyObject is a C++ internal; not Python-exposed. Its races are only triggered when multiple threads share a single vtkSmartPyObject instance, which requires C++-side coupling that user code cannot reach.

Source-confirmed at vtkSmartPyObject.cxx — every refcount op wraps in vtkPythonScopeGilEnsurer (no-op under FT) and Object is plain PyObject*.


R13 — gc_collect_vs_construct deadlock

Verdict: CONFIRMED DEADLOCK

Also: 91 s hang in labeille's tsan_gc_collect_vs_construct.txt (SIGALRM killed it; no TSan output).

import os, subprocess, sys, textwrap, time

child = textwrap.dedent("""
    import signal; signal.alarm(15)
    import threading, gc
    import vtk
    THREADS, ITERS = 4, 2000
    stop = threading.Event()
    def churn():
        for _ in range(ITERS):
            a = vtk.vtkDoubleArray(); b = vtk.vtkPolyData(); c = vtk.vtkIntArray()
            del a, b, c
    def gc_loop():
        while not stop.is_set(): gc.collect()
    g = threading.Thread(target=gc_loop, daemon=True); g.start()
    ts = [threading.Thread(target=churn) for _ in range(THREADS)]
    for t in ts: t.start()
    for t in ts: t.join()
    stop.set(); g.join(timeout=2)
    print('churn completed cleanly', flush=True)
    import os; os._exit(0)
""")
env = {**os.environ, "PYTHON_GIL": "0"}
t0 = time.monotonic()
r = subprocess.run([sys.executable, "-c", child], env=env,
                   capture_output=True, timeout=45)
elapsed = time.monotonic() - t0
stdout = r.stdout.decode("utf-8", errors="replace")
if r.returncode < 0 and elapsed > 12:
    print(f"CONFIRMED: deadlock/hang ({elapsed:.1f}s)")
elif "churn completed cleanly" in stdout:
    print(f"NOT REPRODUCED (completed in {elapsed:.1f}s)")

Output: CONFIRMED: deadlock/hang (15.1s, killed by alarm). Python GC's _PyEval_StopTheWorld waits for all threads at a safepoint; VTK's ObjectMap operations spend long windows inside std::map::operator[]/insert/erase with no Python API call, so STW cannot interrupt them.


R14 — PyVTKObject_Delete vs GhostMap capture ordering

Verdict: SUBSUMED by R2 (same workload; same mechanism — the ordering gap is what makes R2's UAF observable).

Pass 2 finding N6 describes the specific ordering issue; it is covered by the same wrapper_registry_mutex that fixes R1/R2.


UNSAFE findings

U1 — vtkPython.h macro-stubs PyGILState under FT

Verdict: SOURCE-ONLY (architectural; covered by M1)

Confirmed by direct source reading:

// VTK-9.6.1/Utilities/Python/vtkPython.h:93-106
#if !defined(VTK_NO_PYTHON_THREADS) && !defined(Py_GIL_DISABLED)
#define VTK_PYTHON_HAS_GIL
#else
/* VTK_PYTHON_FULL_THREADSAFE does not make sense without GIL */
#undef VTK_PYTHON_FULL_THREADSAFE
#endif

#ifndef VTK_PYTHON_HAS_GIL
#undef PyGILState_Ensure
#define PyGILState_Ensure() ((PyGILState_STATE)0)
#undef PyGILState_Release
#define PyGILState_Release(state) (state) = ((PyGILState_STATE)0)
#endif

Under Py_GIL_DISABLED, PyGILState_Ensure becomes a no-op. This is itself correct under FT (there's no GIL to ensure) — but the wrap-layer code that relied on the ensurer for implicit serialization is now unsynchronized. All other RACE findings are downstream consequences. See R3 for the observable crash.


U2 — GetPointerFromSpecialObject holds iterator across PyObject_Call

Verdict: NOT-OBSERVED

import os, subprocess, sys, textwrap

child = textwrap.dedent("""
    import signal; signal.alarm(20)
    import threading
    import vtk
    THREADS, ITERS = 4, 400
    arr = vtk.vtkVariantArray()
    arr.SetNumberOfValues(1)
    def worker(tid):
        for i in range(ITERS):
            v = vtk.vtkVariant(i * 1000 + tid)
            arr.SetValue(0, v)
            _ = arr.GetValue(0)
    ts = [threading.Thread(target=worker, args=(i,)) for i in range(THREADS)]
    for t in ts: t.start()
    for t in ts: t.join()
    import os; os._exit(0)
""")
env = {**os.environ, "PYTHON_GIL": "0"}
r = subprocess.run([sys.executable, "-c", child], env=env,
                   capture_output=True, timeout=45)
print(f"exit={r.returncode}, SEGV?={'SEGV' in r.stderr.decode('utf-8', errors='replace')}")

Output: exit=0, SEGV?=False. std::map is node-stable (iterators into non-erased nodes remain valid through rebalance), and SpecialTypeMap is append-only. The info = &it->second pointer captured remains valid through PyObject_Call. The race pattern is fragile (adding an erase to SpecialTypeMap would make it exploitable) but not currently reachable.


U3 — PyThreadState_Swap in Execute (conditional)

Verdict: SOURCE-ONLY

The code at vtkPythonCommand.cxx:92, 244 only runs when this->ThreadState is set via vtkPythonCommand::SetThreadState. That method is not Python-exposed; it's only reachable from C++ code that already has a bespoke tstate. Default usage never hits this branch.

Relevant source:

// vtkPythonCommand.cxx:78-94
#ifdef VTK_PYTHON_HAS_GIL
  vtkPythonScopeGilEnsurer gilEnsurer(true);
#else
  PyThreadState* prevThreadState = nullptr;
  if (this->ThreadState)
  {
    prevThreadState = PyThreadState_Swap(this->ThreadState);
  }
#endif

U4 — PyDict_Next iteration at 6 sites in PyVTKTemplate.cxx

Verdict: NOT-OBSERVED (templates registered serially at import)

import os, subprocess, sys, textwrap

child = textwrap.dedent("""
    import signal; signal.alarm(15)
    import threading
    import vtk
    template_cls = getattr(vtk, 'vtkVector3d', None) or getattr(vtk, 'vtkTuple3', None)
    if template_cls is None:
        print('no template class available'); import os; os._exit(0)
    THREADS, ITERS = 4, 400
    def worker():
        for _ in range(ITERS):
            v = template_cls(); _ = dir(v); del v
    ts = [threading.Thread(target=worker) for _ in range(THREADS)]
    for t in ts: t.start()
    for t in ts: t.join()
    import os; os._exit(0)
""")
env = {**os.environ, "PYTHON_GIL": "0"}
r = subprocess.run([sys.executable, "-c", child], env=env,
                   capture_output=True, timeout=30)
print(f"exit={r.returncode}")

Output: exit=0. Template types are registered at import (serialized by import lock) and the dict mutations happen during module init; runtime instantiation is read-only on the completed dict. The race pattern is real but requires concurrent template instantiation (a rarer workload).


U5 — duplicate of U3

Verdict: SUBSUMED (same finding; the report lists it twice by mistake).


U6 — Deprecated PyErr_Fetch / PyErr_Restore

Verdict: SOURCE-ONLY (migration-only; not FT-hostile)

Source:

// vtkPythonArgs.cxx around line 1548, 1560 (RefineArgTypeError)
PyErr_Fetch(&exc, &val, &tb);
// ... build refined error ...
PyErr_Restore(exc, val, tb);

These APIs are deprecated in Python 3.12+ in favor of PyErr_GetRaisedException / PyErr_SetRaisedException. Correctness is unchanged; this is a cleanup item for M4.


PROTECT findings

P1 — vtk_dict / vtk_weakreflist rely on CPython FT safety

Verdict: SOURCE-ONLY (safe today; fragile invariant)

# Source-only verification. The pointer is set once in
# PyVTKObject_FromPointer (PyVTKObject.cxx ~line 761) and never
# reassigned. CPython 3.14's PyDictObject provides per-operation
# thread-safety for its content mutation. Safe today.
#
# Risk: any future edit that reassigns self->vtk_dict under
# concurrent access breaks this. The invariant should be documented
# in the struct definition comment.

P2 — Generator-emitted static caches

Verdict: SAFE (not found; verified in Pass 3 §Q6)

No per-generated-class static caches exist in the wrap templates. If future templates add them, re-audit.


P3 — PyVTKSpecialObject::vtk_hash lazy cache

Verdict: TSAN-ONLY (deterministic result masks the race)

import os, subprocess, sys, textwrap

child = textwrap.dedent("""
    import signal; signal.alarm(15)
    import threading
    import vtk
    THREADS, ITERS = 6, 2000
    mismatches = [0] * THREADS
    def worker(tid):
        bad = 0
        for i in range(ITERS):
            v = vtk.vtkVariant(i)
            h = hash(v)
            if hash(vtk.vtkVariant(i)) != h:
                bad += 1
        mismatches[tid] = bad
    ts = [threading.Thread(target=worker, args=(i,)) for i in range(THREADS)]
    for t in ts: t.start()
    for t in ts: t.join()
    print(f"hash mismatches: {sum(mismatches)}", flush=True)
    import os; os._exit(0)
""")
env = {**os.environ, "PYTHON_GIL": "0"}
r = subprocess.run([sys.executable, "-c", child], env=env,
                   capture_output=True, timeout=30)
print(r.stdout.decode("utf-8", errors="replace").strip())

Output: hash mismatches: 0. Both threads' first-call writes to vtk_hash store the same value (the hash is deterministic for a given variant). TSan would flag the race; user-visible behavior is correct.


P4 — PyVTKObject_Type static singleton init

Verdict: SOURCE-ONLY (init-time only; serialized by module import lock)

PyVTKObject.cxx:33 declares the static PyTypeObject*; :143-146 assigns it during PyVTKClass_Add, which runs during the vtkCommonCore module init. Python's import lock serializes this, so the race is unreachable in practice today but would be exposed if anyone triggers PyVTKClass_Add from multiple threads post-import.


P5 — vtkPythonMap singleton init

Verdict: SOURCE-ONLY (init-time only; serialized by module import lock)

Same reasoning as P4. vtkPythonUtil.cxx:195 declares the pointer; vtkPythonUtilCreateIfNeeded lazy-initializes it. First-touch happens during module import.


P6 — vtkSmartPyObject usage pattern

Verdict: SOURCE-ONLY (C++ internal; subsumed by R12)

The class is not shared across Python threads; only affects C++ code paths that would need R12's atomic conversion.


MIGRATE findings

All MIGRATE items are architectural / migration items by nature. None are directly reproducible as runtime bugs — they describe design changes needed for FT correctness.

M1 — Rethink vtkPythonScopeGilEnsurer

Verdict: SOURCE-ONLY (architectural — the keystone; verified earlier, see U1 quote)

M2 — Declare Py_MOD_GIL_NOT_USED

Verdict: CONFIRMED (absent)

# Verify the symbol is not present in the compiled extension:
import subprocess
out = subprocess.check_output([
    "readelf", "-p", ".rodata",
    "/home/danzin/venvs/3.14_ft_venv/lib/python3.14t/site-packages/"
    "vtkmodules/vtkCommonCore.cpython-314t-x86_64-linux-gnu.so"
], text=True, errors="replace")
if "Py_mod_gil" in out or "GIL_NOT_USED" in out:
    print("MOD_GIL slot found — VTK declares FT support")
else:
    print("CONFIRMED: Py_MOD_GIL_NOT_USED slot ABSENT from vtkCommonCore")

Output: CONFIRMED: Py_MOD_GIL_NOT_USED slot ABSENT from vtkCommonCore. VTK 9.6.1 does not declare FT support. On a standard 3.14t with the default policy, importing VTK would re-enable the GIL.

M3 — Replace ManglePointer static buffer

Verdict: same as R8 (NOT-OBSERVED race; API-level fix)

M4 — Replace deprecated PyErr_Fetch / PyErr_Restore

Verdict: same as U6 (SOURCE-ONLY)

M5 — Remove or rename VTK_PYTHON_FULL_THREADSAFE

Verdict: SOURCE-ONLY (dead knob under FT per vtkPython.h:97)

M6 — Generator: one #ifdef gate in vtkWrapPythonMethod.c

Verdict: SOURCE-ONLY (cleanliness only; the block at lines 818-826 and 997-999 is already compiled out under FT via #ifdef VTK_PYTHON_FULL_THREADSAFE which is #undef'd)

M7 — Per-object std::mutex (design alternative)

Verdict: SOURCE-ONLY (design decision)

M8 — Restructure ghost-resurrection protocol

Verdict: SOURCE-ONLY (design decision; optional after R2 fix)

M9 — Evaluate vtkSmartPyObject contract

Verdict: SOURCE-ONLY (documentation decision)

M10 — Stress coverage gaps for next run

Verdict: SOURCE-ONLY (test-plan item)


SAFE patterns

S1 — shared_array_read_write

Verdict: CONFIRMED SAFE

Labeille's tsan_shared_array_read_write.txt has no TSan warnings and no crash after 500 iterations × 4 threads. VTK's C++ scalar accessors on vtkDataArray use atomic refcount on the underlying object and don't touch the wrap-layer global tables.

S2 — register_unregister_race

Verdict: CONFIRMED SAFE (for the fast path)

Labeille's tsan_register_unregister_race.txt was clean. VTK's own vtkObjectBase::Register / UnRegister use an atomic counter for the common refcount case. The slow path that touches ObjectMap would race per R1, but wasn't hit here.

S3 — vtk_dict / vtk_weakreflist single-assign pattern

Verdict: CONFIRMED SAFE (see P1 for the fragility caveat)

S4 — PyTuple_GetItem(this->Args, …) at 12 sites in vtkPythonArgs.cxx

Verdict: CONFIRMED SAFE

Tuples are immutable; the caller holds a strong reference for the call's duration. Pass 1's scanner flagged these as borrowed-reference holds but Pass 1 correctly dismissed them. No reproducer needed — the invariant is CPython-guaranteed.


Verdict distribution

Verdict Count Findings
CONFIRMED CRASH 7 R1, R2, R3, R4, R5, R6, R11
CONFIRMED DEADLOCK 1 R13
TSAN-ONLY 2 R9, P3
NOT-OBSERVED 5 R8, R10, U2, U4, R7 (1-bit)
SUBSUMED 3 R12, R14, U5
SOURCE-ONLY (architectural / migration / documentation) 16 U1, U3, U6, M1-M10, P1, P2, P4, P5, P6
CONFIRMED SAFE 4 S1, S2, S3, S4
CONFIRMED (audit fact, not a bug) 1 M2 (MOD_GIL slot absent)

Total: 36 findings × their reproducibility, plus 4 safe patterns and 1 audit verification.

The 8 CONFIRMED-crash-or-deadlock findings are the priority-0 fix list; the 2 TSAN-ONLY items are priority-1 (evidenced by TSan in the labeille stress); the NOT-OBSERVED items are real-but-not-reliably-triggered and warrant targeted stress (M10).

Reproducer scripts live at cext-review-toolkit/reports/vtk_ft_reproducers/repro_*.py.

VTK 9.6.1 — Python Source Code Review

Comprehensive code-review-toolkit exploration of VTK 9.6.1's pure-Python layer (vtkmodules package).

  • Scope: Wrapping/Python/vtkmodules/ (41 files, ~6,300 LOC) + Web/Python/vtkmodules/web/ (12 files, ~4,200 LOC). Excluded: 923 integration tests under */Testing/Python/, ThirdParty/, Examples/, and all C++/generated bindings.
  • Source tree: /home/danzin/projects/laruche/repositories/vtk-source/VTK-9.6.1/ — extracted tarball (no .git, so no temporal/churn analysis possible).
  • Agents run (12 of 14): architecture-mapper, consistency-auditor, pattern-consistency-checker, complexity-simplifier, silent-failure-hunter, dead-code-finder, test-coverage-analyzer, documentation-auditor, project-docs-auditor, type-design-analyzer, api-surface-reviewer, tech-debt-inventory. Skipped: git-history-context and git-history-analyzer (no git repo).

Executive Summary

VTK's pure-Python layer is a thin, well-layered facade over a large compiled C++ core — 53 files organized into 8 effectively-independent subpackages (util, numpy_interface, web, test, tk, gtk, wx, qt), with 34 of those files bridging directly to C++ extension modules. The architecture is clean: one deliberate (benign) circular import inside numpy_interface, no god-modules, and a novel-but-elegant MODULE_MAPPER mechanism in __init__.py.in that wires Python helpers to their companion compiled modules on demand. However, this architecture is eroding in clearly measurable ways: two parallel class hierarchies for the exact same VTK datasets (util/data_model.py vs numpy_interface/dataset_adapter.py, sharing 7 identically-named classes and copy-pasted 80-line method bodies that have begun to drift); a fully Python-2 subpackage (gtk/, 1,698 LOC using apply()/pygtk 2.0) still shipped to PyPI; Web/Python/vtkmodules/web/testing.py broken on Python 3.12+ via imp removal; 14+ genuine F821 undefined-name bugs reached a stable release (including a reciprocal = ...numpy.sqrt copy-paste that silently returns wrong numeric values, and a len(lhs == 0) crashing the Pipeline DSL's clear-input path) because CI runs no linter and the Python layer has ~4 test methods per KLOC. The typing story is absent (2.1% annotation coverage in Wrapping, 0.0% in Web), __init__.pyi stubs are generated only for the compiled modules (not the hand-written ones that users actually rely on), and the official readthedocs API reference is pinned to a 9.2.6 snapshot, three minor releases behind. The single highest-impact intervention is adding a minimal ruff check --select=F,E,UP,B step to CI: it would have caught most of the 14+ F821 bugs and prevented an entire class of recurrence for half a day of engineering.

Post-review update (see the reproducer appendix): 35 of 105 findings were reproduced at runtime against a working VTK 9.6.1 build, 50 were statically confirmed (via ruff/AST/grep), 17 are code-review-only, and 3 were refuted (findings #19, #35, #60 — see Refuted findings below). The crown-jewel runtime reproduction is #1: algs.reciprocal(4.0) returns 2.0.

Project Profile

Dimension Value
Total Python LOC in scope ~10,500
Non-test Python files in scope 53
Test files correlated with scope ~17 dedicated (dozens more exercise the C++ core)
Test methods per KLOC ~4 (extremely low)
Subpackages 8 (util, numpy_interface, test, web, tk, gtk, wx, qt)
Public classes 104 (50% documented)
Public functions/methods (top-level + method) 775
Annotation coverage (Wrapping/Web) 2.1% / 0.0%
Largest single file numpy_interface/dataset_adapter.py (1,501 LOC)
Most-depended-on compiled extensions vtkCommonCore (18 files), vtkRenderingCore (15)
Deprecated API wrappers in one file 46 @deprecated in numpy_interface/algorithms.py
Tooling config files in scope None (no pyproject.toml, setup.cfg, .ruff.toml, mypy.ini)

Key Metrics

Dimension Status FIX CONSIDER Top Finding
Architecture healthy 0 3 Parallel class hierarchies in util/ vs numpy_interface/
Consistency problematic 4 5 Mixed PascalCase/camelCase/snake_case, no documented rule
Patterns problematic 4 4 Two parallel wrapper hierarchies with drift
Complexity healthy 5 6 17 hotspots out of 980 functions (1.7%); two __getitem__/__setitem__ critical
Silent Failure problematic 14 10 14 F821 undefined-name bugs + len(lhs == 0) TypeError
Dead Code problematic 6 8 Entire gtk/ subpackage (1,698 LOC), web/testing.py broken on 3.12+
Test Coverage problematic 7 8 20/30 modules have zero tests; reciprocal=numpy.sqrt reached release
Documentation concerning 10 20 Module docstring examples with 3 distinct SyntaxErrors
Project Docs concerning 6 6 readthedocs pinned to 9.2.6; Pipeline >> DSL absent from reference
Type System problematic 11 10 2.1% public annotation coverage; no py.typed for hand-written
API Surface problematic 10 8 Learnability 4.5/10; camelCase vs snake_case with no rule
Tech Debt concerning 15 13 reciprocal = numpy.sqrt copy-paste; self-obsolete file still imported
Fix Quality / History No git available

Findings by Priority

Global non-restarting numbering: findings 1–47 are FIX, 48–95 are CONSIDER, 96–105 are POLICY. Use these numbers in tracking issues.

Post-review refutations (see the Refuted findings section below): the reproducer appendix retracts 3 findings — #19, #35, #60. They remain in the tables with strike-through so numbering stays stable, but are no longer claimed. Net counts after refutations: 45 FIX + 47 CONSIDER + 10 POLICY = 102 claimed findings (down from 105).

Must Fix (FIX) — 47 (45 after 2 refutations: #19, #35)

# Finding File:Line Agents
1 reciprocal = …numpy.sqrtalgs.reciprocal(4) silently returns 2.0 instead of 0.25. Copy-paste bug in deprecated-ufunc wrapper; zero tests exercise it. numpy_interface/algorithms.py:790 tech-debt, test-coverage, patterns
2 type_string undefined in unpickle error path — raises NameError instead of intended TypeError when unpickling a bad state. util/pickle_support.py:49 silent-failure, consistency, tech-debt, test-coverage, type-design
3 vtkPNGWriter not imported but instantiated at line 114. vtkRegressionTestImage() NameErrors whenever baseline image doesn't exist (i.e., the exact path this branch handles). util/misc.py:114 silent-failure, consistency, tech-debt
4 len(lhs == 0) — calls len() on a bool (always TypeError). The sibling Pipeline.__rrshift__ at line 244 has the correct len(lhs) == 0. select_ports.__rrshift__ never worked for empty/None input. util/execution_model.py:127 silent-failure, type-design, test-coverage, complexity
5 testScriptfile typo (lowercase f) vs testScriptFile — raises NameError in the error-message path (which is the exact path the test_fail handler relies on to tell you which file is bad). web/testing.py:694 silent-failure, consistency, type-design, tech-debt
6 mapper undefined in mergeToPolydataSerializer — the else: branch does dataset = mapper.GetInput() but no mapper parameter exists. Currently latent (branch unreachable with current registrations); will crash on any new serializer registration. web/render_window_serializer.py:1015 silent-failure, type-design
7 errflag referenced before assignmentif not errflag: at line 621 is inside the except block; errflag = False is at line 641. First ValueError → UnboundLocalError. generate_pyi.py:621 silent-failure, consistency, complexity, tech-debt
8 apply(...) (Python 2 builtin) — entire gtk/ subpackage raises NameError at module import. 1,698 LOC of code uninstallable on any supported Python version. Also calls pygtk.require('2.0') which has no Py3 backport. gtk/GtkVTKRenderWindow.py:58,188 and gtk/GtkVTKRenderWindowInteractor.py:47 silent-failure, dead-code, tech-debt
9 vtkRenderWindow not imported but used at line 48. NameError even if apply is fixed. gtk/GtkVTKRenderWindowInteractor.py:48 silent-failure, consistency
10 xrange (Python 2 builtin) — NameError whenever user clicks to pick a pixel. Real live bug in a shipped widget. tk/vtkTkImageViewerWidget.py:293 silent-failure, consistency, tech-debt
11 argv[0] should be sys.argv[0] — NameError in the exact code path that tries to print a usage error. test/rtImageTest.py:136 silent-failure, consistency
12 Queue + imp + Queue.Queue() + imp.load_source — module-level imports fail on any Py3 (removed in 3.12); paths at lines 243, 619 NameError. Whole web/testing.py module non-functional for years. Wrapped in bare except: that swallows the failure silently. web/testing.py:42, 243, 619 silent-failure, dead-code, tech-debt
13 is [] identity comparison (always False) — max/min/all composite-array reductions have a dead empty-list branch. F632. numpy_interface/numpy_algorithms.py:222, 262, 295 consistency, type-design
14 DataaObjectKey typovtkInformationDataObjectKey as DataaObjectKey (three 'a's). 10+ year-old typo; module never used anywhere. util/keys.py:4 consistency, tech-debt, dead-code
15 "substract" typo in deprecation message (should be "subtract"). Wrong both in the user-visible warning and the proxied attribute name. numpy_interface/algorithms.py:995 tech-debt
16 1e incomplete float literal in module docstring — SyntaxError if copy-pasted. numpy_interface/dataset_adapter.py:54 documentation, tech-debt
17 Missing import keyword in module docstring: from vtkmodules.vtkImagingCore vtkRTAnalyticSource. SyntaxError on copy-paste. numpy_interface/dataset_adapter.py:6 documentation, project-docs
18 Python 2 print image.GetPointData()... and print block in module docstring — SyntaxError on Py3. numpy_interface/dataset_adapter.py:18, 57 documentation, tech-debt
19 ~~**"togheter" typo** in setup.py:220— minor.~~ **REFUTED** — spilled over from prior frozendict analysis; VTK has no such typo.grep -rn togheter` over the tree returns no matches. (none) tech-debt
20 assert-based input validation in numpy_to_vtk/vtk_to_numpy — vanishes under python -O → silent memory corruption with bad inputs. 4 sites. util/numpy_support.py:134,135,137,218 patterns, test-coverage, type-design, api-surface
21 14 bare except: across the scope — catches SystemExit/KeyboardInterrupt/MemoryError. Worst cluster is web/testing.py (6 bare excepts around the Py2 imports that silently swallow the import failure). web/testing.py:43,52,56,65,73,404,475, web/__init__.py:58, test/Testing.py:274,311,333,571, test/rtImageTest.py:21,48, tk/vtkTkImageViewerWidget.py:60, util/pickle_support.py:48, util/numpy_support.py:152, web/vtkjs_helper.py:13, wx/wxVTKRenderWindowInteractor.py:406 silent-failure, tech-debt, consistency
22 Error messages in pickle_support.py swappedunserialize_VTK_data_object says "Marshaling data object failed" (it's un-marshaling); serialize_VTK_data_object says "UnMarshaling data object failed" (it's marshaling). util/pickle_support.py:55, 72 documentation
23 VTKObjectWrapper docstring references __get__attr (doesn't exist) instead of __getattr__. numpy_interface/dataset_adapter.py:127 documentation
24 vtkVariantExtract docstring has broken quote ('None" opens single, closes double) AND is semantically wrong (says pass string 'None'; code tests Python None). util/vtkVariant.py:85-86 documentation
25 select_ports docstring example uses >>> — triple-caret is not an operator; >> is. Copy-paste yields SyntaxError. util/execution_model.py:68 documentation
26 11 mutable default arguments (={}, =[]) across scope; worst is VTKCompositeDataArray.__init__(arrays=[]) in the most-instantiated class in numpy_interface. dataset_adapter.py:492, dataset_builder.py:58,126,150,517, tk/vtk*.py, test/BlackBox.py:20,47, test/Testing.py:139,146 patterns, api-surface, type-design
27 util/vtkConstants.py self-declared obsolete but actively imported by util/numpy_support.py:29, util/misc.py:39, all.py.in:10, numpy_interface/dataset_adapter.py:1438. Either docstring is lying or module should be removed. util/vtkConstants.py dead-code, tech-debt, documentation
28 MANIFEST.in-style reference to deleted pyproject.toml — no pyproject.toml at Wrapping/Python/; ruff/mypy/pyflakes never run in CI. This alone would have caught findings 1–15. (absent) Wrapping/Python/pyproject.toml tech-debt, project-docs
29 from math import * in web/camera.py:1 — F403; shadows builtins. web/camera.py:1 consistency, type-design, dead-code
30 F811vtkDataObject re-imported within same from … import (…) block. util/data_model.py:14 consistency, dead-code
31 F811vtkActor imported at module scope AND inside function, 2nd shadows 1st. gtk/GtkGLExtVTKRenderWindow.py:30,500 consistency
32 Indent drift in VTKCompositeDataArray.__setitem__ — lines 588-606 switch from 4-space to 2-space mid-function. numpy_interface/dataset_adapter.py:588-606 complexity, consistency
33 VTKPythonAlgorithmBase.RequestData error branches untestedTestPythonAlgorithm.py has 4 tests, all return 1 (success). Any misreport of return code or exception swallowing in the Python↔C++ glue is untested. util/vtkAlgorithm.py + Filters/Python/Testing/Python/TestPythonAlgorithm.py test-coverage, api-surface
34 _ArrayMemoryError OOM-recovery fallback present in dataset_adapter.py but MISSING in duplicate data_model.py::set_array — same operation, divergent error handling. A large-array OOM silently recovers in one wrapper, raises in the other. numpy_interface/dataset_adapter.py:961-1044 vs util/data_model.py:104-181 patterns, silent-failure
35 7 duplicate class definitions in numpy_interface/dataset_adapter.py REFUTED-as-stated — AST walk finds zero intra-file class-name collisions. The dead-code agent conflated this with the real cross-file duplication between dataset_adapter.py and util/data_model.py, which is exactly what #48 describes. Treat #35 as a garbled restatement of #48.
36 PyQtImpl NameError in Qt autodetect — if import vtkmodules.qt raises before PyQtImpl = ... executes, line 77 if PyQtImpl is None: NameErrors instead of raising the underlying ImportError. qt/QVTKRenderWindowInteractor.py:57-77 silent-failure
37 serializeInstance logs error and returns None instead of raising — callers proceed with None and cascade into AttributeError/TypeError far from origin. web/render_window_serializer.py:184-186 silent-failure, api-surface
38 getReferenceId bare except: fallback produces silently-wrong cross-reference IDs — breaks web client rendering with no error. web/__init__.py:54-62 silent-failure
39 xarray_support.RequestData: if self._timeindex: — timestep 0 is falsy → silently falls through "no time" branch, selects entire array without time filtering. First timestep silently wrong. util/xarray_support.py:235 silent-failure
40 wxVTKRenderWindowInteractor.OnSize bare except: — meant to catch TypeError from tuple unpacking; actually catches KeyboardInterrupt. wx/wxVTKRenderWindowInteractor.py:404-408 silent-failure
41 test_file_io bare except: leaks file handle (no with) and should be os.path.isfile(). test/Testing.py:272-276 silent-failure
42 os.environ.get("PATH").split(';') — if PATH unset, .split raises AttributeError. Use .get("PATH", ""). generate_pyi.py:634 silent-failure
43 dataset_builder.DataSetBuilder:331 sampling_dimesions typo — parameter name misspelled consistently within method; external callers passing sampling_dimensions= fail with TypeError. web/dataset_builder.py:331 silent-failure, api-surface
44 protocols.py FIXME sebupdateOrientationAxesVisibility and updateCenterAxesVisibility accept showAxis but silently ignore it. RPC endpoints that appear to work but don't. web/protocols.py:205, 218 tech-debt, silent-failure
45 util/misc.py:deprecated missing stacklevel=2 — all 46 @deprecated warnings in numpy_interface/algorithms.py point at misc.py:28 rather than the caller's line. User can't grep their own code to find which call triggered. util/misc.py:28 silent-failure, consistency, tech-debt
46 Wrapping/Python/vtkmodules/util/__init__.py::__all__ omits data_model, execution_model, keys, vtkAlgorithm, xarray_support — 5 modules that are imported by other parts of the package are absent from the advertised public surface. autodoc2-generated docs miss them. util/__init__.py:3-5 documentation, dead-code
47 Documentation/docs/vtkmodules.__init__.py is a 9.2.6 snapshot — readthedocs Python API page is pinned 3 minor releases behind. No MODULE_MAPPER, no .override machinery. Documentation/docs/vtkmodules.__init__.py:1-3, 211 project-docs

Should Consider (CONSIDER) — 48 (47 after 1 refutation: #60)

# Finding File:Line
48 Two parallel class hierarchiesutil/data_model.py (861 LOC, @.override style) and numpy_interface/dataset_adapter.py (1,501 LOC, wrapper style) share 7 class names (DataSet, PointSet, PolyData, UnstructuredGrid, DataSetAttributes, CompositeDataSetAttributes, CompositeDataIterator) with incompatible APIs (snake_case vs PascalCase, set_array(name, arr) vs append(arr, name), association vs Association, property vs method setters, __eq__ identity vs content). util/data_model.py + numpy_interface/dataset_adapter.py
49 Single-file monoliths with natural split boundaries: dataset_adapter.py (1,501 LOC, 22 classes); render_window_serializer.py (1,410 LOC, 40+ serializers); algorithms.py (1,008 LOC); data_model.py (861 LOC); protocols.py (842 LOC); testing.py (788 LOC). render_window_serializer.py is actually well-decomposed internally (37 per-class serializers) so leave; others are splittable. several
50 Pipeline.{PIPELINE,ALGORITHM,DATA,UNKNOWN} = 0,1,2,3 as class attributes — should be enum.IntEnum. util/execution_model.py:145-148
51 CameraSetting / cameraData["orientation"] dicts in web/camera.py — three classes (SphericalCamera, CylindricalCamera, CubeCamera) yield dicts with overlapping-but-different keys; zero type enforcement on the schema. orientation is one of 6 magic strings; Literal/TypedDict would catch typos like "doww" (literal comment in code). web/camera.py:140-150, 204-215, 336
52 registerArgument(**kwargs) in DataHandler — docstring lists required kwargs (priority, name, label, values, uiType, defaultIdx); code raises KeyError on missing ones. TypedDict + Unpack is the fix. web/query_data_model.py:47
53 protocols.py event dict in every RPC — at least 15 typed keys per schema, 10+ distinct shapes. JSON-RPC contract with no type enforcement. web/protocols.py:104-640
54 numpy_to_vtk(num_array, deep=0, ...) uses C-style 0/1 integer bool. Add snake deep: bool = False alias. Also parameter name inconsistency: num_array vs array (dataset_adapter.vtkDataArrayToVTKArray). util/numpy_support.py, dataset_adapter.py
55 Zero-cover web modulesweb/protocols.py (842 LOC, 8 classes), web/camera.py (640), web/dataset_builder.py (620), web/vtkjs_helper.py (285), web/query_data_model.py (182), web/wslink.py (67), web/utils.py (211), util/xarray_support.py (397) — all have zero direct tests. multiple
56 util/data_model.py (861 LOC, 21 classes, override-based) — tested by 1 test method in TestDataModel.py. 95%+ of the sugar API (point_data getters/setters, association handling, composite iteration) has no regression coverage. util/data_model.py + Testing/Python/TestDataModel.py
57 Legacy Qt4/PySide1 fallback in qt/__init__.py:29-39 and QVTKRenderWindowInteractor.py:166-189 — 150+ lines for EOL Qt bindings that can't pip install on any modern Python. qt/__init__.py, qt/QVTKRenderWindowInteractor.py
58 7 from __future__ import files still present; 24 class X(object): declarations — Python 2 compat residue. multiple
59 iteritems shim in web/__init__.py:36-37 + 11 call sites in web/query_data_model.py and web/dataset_builder.py — Py2/3 bridge that's now a no-op. web/__init__.py, callers
60 Three distinct optional-dependency probe patterns + cftime F401-unused in xarray_support.py PARTIALLY REFUTED — the "three probe patterns" observation stands, but the cftime-unused claim is wrong: grep shows 8 references including runtime uses at xarray_support.py:118,333 via np.frompyfunc(vtkXArrayCFReader._cftime_toordinal, ...). Ruff does not actually flag cftime as F401 in this file. The broader "three probe patterns" finding still has merit but is demoted; recommend splitting into a new CONSIDER item that excludes the cftime false positive. multiple
61 Three string-formatting styles coexist in one file (util/xarray_support.py): %, .format(), f-strings. 63 UP031 ruff findings total. several
62 22 E721 type(x) == Cls — concentrated in numpy_interface/algorithms.py, dataset_adapter.py, numpy_algorithms.py, generate_pyi.py. Misses subclasses; VTKCompositeDataArray has a subclass metaclass. numpy_interface/*, generate_pyi.py
63 18 == None / != None comparisons scattered across wx/tk/gtk/web. Auto-fixable E711. multiple
64 14 zip() without strict= — adds silent length-mismatch risk. B905. numpy_interface/algorithms.py, dataset_adapter.py
65 unstructured_from_composite_arrays triple-walks the dataset iterator (lines 653-662, 676-683, 694-703) — mixing ownership computation with geometry construction. numpy_interface/algorithms.py:615-739
66 _global_per_block (MPI reduction) at score 8/10 complexity — 92 LOC, 26 locals, 21 branches, 3 success = try … except ValueError: success = False anti-patterns. numpy_interface/algorithms.py:208-334
67 VTKCompositeDataArray.__getitem__ / __setitem__ at score 8/7 — polymorphic isinstance dispatch over 5 index types, ~70 LOC each, duplicated offset computation. numpy_interface/dataset_adapter.py:608-690 and :554-606
68 extractRequiredFields uses magic scalar-mode integers (1,3), (2,4), ==0 without named constants. Has inline FIXME should evolve and support funky mapper which leverage many arrays comment. web/render_window_serializer.py:365-425
69 SortedCompositeDataSetBuilder.stop — 4 sequential directory-tree walks (convert, rewrite metadata, delete sort files, gzip); candidate for _convert_all_directories/_rewrite_metadata/_clean_tmp_files/_gzip_uint8_files extraction. web/dataset_builder.py:557-620
70 genericActorSerializer has 3 copies of if hasattr(actor, 'GetX'): ... serialize pattern (for mapper, property, texture). Extract _serialize_child(actor, getter_name, context). web/render_window_serializer.py:432-511
71 Mixed PascalCase/camelCase/snake_case within single filesnumpy_interface/dataset_adapter.py has vtkDataArrayToVTKArray, numpyTovtkDataArray, VTKObjectWrapper.GetArrays, reshape_append_ones, _make_tensor_array_contiguous and arrLength/validAssociation/narray all coexisting. several
72 Module file names inconsistent in util/vtkAlgorithm.py, vtkConstants.py, vtkImageExportToArray.py, vtkMethodParser.py, vtkVariant.py (camelCase-C++-mirror) alongside colors.py, data_model.py, execution_model.py, keys.py, misc.py, numpy_support.py, pickle_support.py, xarray_support.py (snake_case). No rule. util/
73 Numpy-conversion name inconsistencynumpy_to_vtk/vtk_to_numpy (snake) vs numpyTovtkDataArray/vtkDataArrayToVTKArray (camel). Same concept, two different conventions, two different module locations. util/numpy_support.py vs numpy_interface/dataset_adapter.py
74 Error signaling triple inconsistency for type conversions — vtkVariantCast returns None on failure, numpy_to_vtk raises TypeError, vtk_to_numpy uses assert (vanishes under -O). util/vtkVariant.py, util/numpy_support.py
75 immutable() helper raises mixed AttributeError vs TypeError (in frozendict analogue; in VTK context: the clear()/pop() immutability scheme is consistent). util/data_model.py
76 Zero docstrings on web/render_window_serializer.py public functions — only 2 of 37 public fns and 0 of 7 methods have docstrings. The whole 40-serializer dispatch table is undocumented. web/render_window_serializer.py
77 Zero docstrings on web/dataset_builder.py — all 7 public classes and 23 methods undocumented. web/dataset_builder.py
78 Zero docstrings on web/camera.pySphericalCamera, CylindricalCamera, CubeCamera classes + normalize, q_mult, q_conjugate, axisangle_to_q, vectProduct, dotProduct, rotate — quaternion/camera math with no convention spec. web/camera.py
79 @vtkX.override mechanism is entirely undocumented — used 13 times in util/data_model.py with no comment, no module docstring explanation, no cross-reference to MODULE_MAPPER in __init__.py.in. Critical for understanding module behavior. util/data_model.py, __init__.py.in:120-127
80 util/vtkAlgorithm.py has no module docstring — defines VTKAlgorithm and VTKPythonAlgorithmBase, canonical pattern for writing VTK filters in Python. util/vtkAlgorithm.py
81 qt/QVTKRenderWindowInteractor.py:12-54 module docstring is a changelog — 40+ lines of author history before any API description. Move to commits. qt/QVTKRenderWindowInteractor.py
82 wx/ files have dual-docstring pattern — two adjacent triple-quoted strings; Python evaluates the second as a no-op statement, making it invisible to help(). wx/wxVTKRenderWindow.py:14-76, wx/wxVTKRenderWindowInteractor.py:16-34
83 DataSet.__eq__ has a non-docstring triple-quoted string inside function body — Python evaluates it as a no-op statement, future readers may mistake it for a docstring. util/data_model.py:439-441
84 misc.py::deprecated decorator example uses version=1.2 (float) but every real call passes "9.6" (string). Example misrepresents actual usage. util/misc.py:15
85 vtkRegressionTestImage docstring says "Does anyone involved in testing care to elaborate?" — an internal author note that became public docstring text. util/misc.py:92-96
86 calldata_type docstring has double import import typo. util/misc.py:38
87 generate_pyi.py CLI doc in PythonWrappers.md omits -i/--importer and --test flags (documented flags: -p, -o, -e, module). Users debugging static-build .pyi generation can't discover -i. Documentation/docs/advanced/PythonWrappers.md:1249-1254 + generate_pyi.py:569-584
88 vtk.__version__ example output in getting-started doc is 9.2.6 — three minor releases behind. Documentation/docs/getting_started/using_python.md:50-53
89 Python version floor contradictionsbuild.md:89 says 3.4, system_requirements.md:5 says 3.x, CMake says 3.7, release note 9.4 says 3.8. Documentation/docs/build_instructions/build.md, system_requirements.md, CMake/vtkModule.cmake:6445, release/9.4.md:435
90 API-landing-page example uses >> instead of >>> for Python prompt. copybutton_prompt_text regex strips only >>>. Documentation/docs/api/python.md:18
91 Pipeline >> DSL entirely absent from the main PythonWrappers.md reference doc — only covered in a single release note (9.4/new-python-api-for-pipelines.md). It's VTK's flagship Python ergonomics feature. Documentation/docs/advanced/PythonWrappers.md
92 vtkmodules.web subpackage has zero external documentation — 12 modules including the web-server protocols, none referenced in Documentation/docs/. Only hint is pip install vtk[web] in an error message. Web/Python/vtkmodules/web/*
93 vtkpython CLI has no dedicated doc page — referenced in Wrapping/Python/README.md and PythonWrappers.md but flags/differences from python3 not documented. Wrapping/Python/vtkPythonAppInit.cxx (no doc)
94 Legacy "Bugs:" sections in gtk/wx docstrings — describe 2001-era focus bugs for pygtk-1. Surface in every help() call. gtk/GtkVTKRenderWindow.py:26-37, wx/wxVTK*.py:14-76
95 46 @deprecated wrappers in one file — schedule removal in VTK 10. Currently warnings fire on every call from VTK's own internal code (due to missing stacklevel=2, see #45), which will spam logs in production pipelines. numpy_interface/algorithms.py

Policy Decisions (POLICY) — 10

# Finding
96 Add a minimal pyproject.toml at Wrapping/Python/ with [tool.ruff] enabling F (pyflakes, catches F821/F811/F841), E (pycodestyle), UP (pyupgrade), B (bugbear). Wire it into CI as a blocking check. Single action catches findings #1–15, auto-fixes findings #58 (__future__), #63 (== None), #61 (%-formatting), and many more. This is the highest-impact intervention on the entire list.
97 Decide canonical VTK dataset wrapping approach: (a) composition wrapper (dataset_adapter.py style, used by all numpy-interface algorithms) or (b) .override inheritance (data_model.py style, newer, strictly more Pythonic). Publish a style guide, deprecate the other direction with user-visible DeprecationWarnings, mark a removal version. Interim: add forwarding aliases so both APIs remain drop-in compatible.
98 Document the naming rule: "VTK-C++ style (PascalCase/camelCase, vtk* prefix) for anything that mirrors a C++ class/method; snake_case for internal helpers and Pythonic APIs." Commit as CONTRIBUTING-style note; configure ruff to ignore N801/N802/N803/N806 project-wide rather than per-module.
99 Delete the gtk/ subpackage and remove from Wrapping/Python/CMakeLists.txt — 1,698 LOC of Py2-only code on dead pygtk-2. Zero user impact. Either delete outright or add a prominent deprecation with NotImplementedError on import.
100 Modernize or delete Web/Python/vtkmodules/web/testing.py — structurally broken on Py3.12+ (imp removed). Zero callers in-tree. If kept, replace imp/Queue with importlib.util/queue and narrow all bare excepts.
101 Ship py.typed and hand-written .pyi stubs for the Python layergenerate_pyi.py already writes a py.typed marker but only guarantees types for generated stubs of compiled modules. The hand-written modules (util.execution_model, util.data_model, numpy_interface.dataset_adapter, web.protocols) ship next to those stubs and inherit the claim without any annotations. Either annotate them or add sibling .pyi files.
102 Adopt NumPy-style docstrings as convention (matches peers: numpy, scipy, matplotlib). Convert the handful of Epydoc/Sphinx/Google outliers. Add to developer guide.
103 Add Python section to Documentation/docs/developers_guide/coding_conventions.md or commit the ruff/formatter config that clearly has been used (the .ruff_cache/ directory at repo root suggests ruff has been run but not committed).
104 Regenerate Documentation/docs/vtkmodules.__init__.py from a current 9.6 wheel — or integrate autodoc2 prep into CMake build. The @todo we need to make this automatic comment in vtk_documentation.py:148 has sat there across three releases.
105 Schedule removal of 46 @deprecated algorithms.py wrappers at VTK 10 — once 9.6 has shipped the deprecation warnings, users have had one full cycle to migrate.

Refuted findings

The reproducer appendix (run with a working VTK install) retracted 3 findings from the original agent synthesis. They remain numbered in the tables above (with strike-through) so all cross-references stay stable.

# Original claim Verdict Evidence
19 "togheter" typo in setup.py:220 REFUTED grep -rn togheter over the VTK tree returns zero matches. The claim was carried over from an earlier frozendict analysis pass; the spillover was missed during synthesis.
35 7 duplicate class definitions within numpy_interface/dataset_adapter.py REFUTED-as-stated AST walk of dataset_adapter.py finds zero intra-file class-name collisions. The real 7-class collision is cross-file between dataset_adapter.py and util/data_model.py — i.e., finding #48. The dead-code agent's phrasing was wrong; the underlying concern is fully captured by #48.
60 import cftime in xarray_support.py:1 is F401-unused (as part of a "three probe patterns" observation) PARTIALLY REFUTED grep -n cftime Wrapping/Python/vtkmodules/util/xarray_support.py shows 8 references including runtime uses at lines 118, 333 inside np.frompyfunc(vtkXArrayCFReader._cftime_toordinal, ...). ruff --select F401 does not flag cftime in this file. The "three probe patterns" half of the finding (contextlib.suppress vs try/except: raise WebDependencyMissingError vs try/except: raise ImportError("long message")) still stands; only the cftime-unused claim is wrong.

Lessons for future reviews:

  1. Cross-task contamination: The task-workflow skill ran the frozendict analysis earlier in the same session. The "togheter" typo spilled into the VTK synthesis. Future sessions should prefix agent prompts with "this finding must appear in files inside <scope>" and re-verify all claims against grep before including them.
  2. Agent-reported "duplicate classes" needs AST verification: The dead-code agent's claim of within-file duplicates should have been validated by the orchestrator before inclusion — a 5-line AST scan would have caught the misattribution and merged the finding with #48 where it belongs.
  3. Agent F401 claims are not ruff-equivalent: The consistency-auditor's unused-import claim was not validated against actual ruff output; it should have been. Agents that claim static-tool findings without running the tool introduce false positives.

Tensions

  • "Two parallel wrapper hierarchies" (findings #34, #48) vs "backward compatibility": consolidating util/data_model.py and numpy_interface/dataset_adapter.py means breaking some users. Adding forwarding aliases (append = set_array, PointData = point_data) preserves compat but perpetuates the naming inconsistency. → Interim: aliases now, deprecation in next minor, removal at VTK 10.
  • "Keep camelCase API for backwards compat" vs "PEP 8 compliance" (finding #71, #98): getFreezeConversionMap(), patchOrUnpatchAll(), numpyTovtkDataArray() are public. Renaming breaks callers. → Resolution: add snake_case aliases now, emit DeprecationWarning from camelCase in 2 releases, remove camelCase at VTK 10.
  • "Single-file monoliths harder to navigate if split" vs "1,501 LOC god-files" (finding #49): Complexity agent notes render_window_serializer.py is already well-decomposed (37 per-class serializers) and splitting it would make navigation worse; but dataset_adapter.py (22 classes) and protocols.py (8 RPC classes) would benefit from per-class-family splitting.
  • "Deprecated GUI toolkit support" (gtk/, PyQt4, PySide1) vs "ABI stability": The gtk/ code is broken on Python 3 (finding #8); PyQt4/PySide1 haven't worked since early Py3 (finding #57). But removing them is a breaking change in principle. → gtk/ can be deleted without announcement (nobody can be using it); PyQt4/PySide1 fallbacks should be removed after one deprecation cycle.

Strengths

  1. Clean thin-facade architecture — 34 of 53 files bridge directly to compiled C++ extensions. No reimplementation, no redundant Python abstractions on top of Python abstractions.
  2. Eight genuinely independent subpackages — delete any of tk/, gtk/, wx/, qt/ without affecting anything else. GUI bindings coexist at the package level, mutually exclusive at the process level.
  3. Novel, elegant MODULE_MAPPER mechanism — a custom meta-path finder + on_vtk_module_init_completed() hook in __init__.py.in wires Python helpers to compiled modules on demand. Solves the "Python-side helpers that must attach to C++-side classes" problem without circular imports.
  4. Only one real circular importnumpy_interface.algorithms ↔ numpy_interface.numpy_algorithms, with lazy in-function imports on the callback side. Understood, isolated, safe.
  5. Low complexity density — 17 hotspots (score ≥5) out of 980 functions (1.7%). Average score 1.2–1.3. Complexity is localized, not pervasive.
  6. The Pipeline >> DSL in util/execution_model.py is a genuinely nice piece of API design. 4/10 learnability only because it's undocumented in the main reference.
  7. Systematic deprecation campaign — 46 @deprecated wrappers in algorithms.py tell users "use numpy directly". Intentional sunsetting.
  8. util/misc.py::deprecated decorator is a clean, reusable primitive.
  9. GUI backend autodetection in qt/__init__.py is functional for the common single-install case.
  10. Zero TODO/FIXME/HACK/XXX markers in util/ — disciplined about not leaving debt trail (some markers appear in web/).

Code Removal Opportunities

  • Delete gtk/ subpackage — 1,698 LOC, 4 files, broken since Python 3.0. Remove from Wrapping/Python/CMakeLists.txt.
  • Delete web/testing.py — 788 LOC, broken on Python 3.12+, zero callers. Remove from Web/Python/CMakeLists.txt:9.
  • Delete util/keys.py — 29 LOC, zero callers in VTK tree, 10+-year-old typo (DataaObjectKey), has never been referenced by anyone.
  • Delete util/vtkMethodParser.py::VtkPrintMethodParser — zero callers; only VtkDirMethodParser is used (by test/BlackBox.py).
  • Delete util/vtkConstants.py obsolete constantsVTK_MULTIGROUP_DATA_SET, VTK_HIERARCHICAL_DATA_SET, VTK_HIERARCHICAL_BOX_DATA_SET, VTK_HYPER_OCTREE, VTK_TEMPORAL_DATA_SET (marked VTK_DEPRECATED_IN_9_5_0, zero callers).
  • Delete web/render_window_serializer.py:759 lookupTableSerializer — zero callers; superseded by lookupTableSerializer2 at line 821.
  • Delete web/render_window_serializer.py:285 pad(depth) — zero callers.
  • Delete PyQt4 and PySide (v1) branches in qt/__init__.py and QVTKRenderWindowInteractor.py — neither can pip install on any modern Python.
  • Delete 14 bare except: clauses (narrow to specific exception types).
  • Delete 11 mutable default arguments (=[], ={}).
  • Delete 9 from __future__ import absolute_import lines.
  • Delete 24 class X(object): base specs.
  • Delete iteritems helper and inline 11 call sites.
  • Delete 106 unused imports (ruff F401).
  • Delete 9 unused local variables (ruff F841).
  • Delete 10 dead global declarations (ruff PLW0602).

Estimated total: ~2,400 LOC removable (gtk + web/testing alone = 2,486) without user impact.

Recommended Action Plan

Immediate (FIX items)

  1. Finding #96: Add pyproject.toml with [tool.ruff] select = ["F", "E", "UP", "B"] and wire into CI. Catches findings #1–15 and prevents recurrence. (Half-day)
  2. Finding #1: Fix reciprocal = ...numpy.sqrtnumpy.reciprocal. Single-line; silent numerical corruption bug affecting any user of the deprecated proxy. (5 minutes)
  3. Finding #3: Add vtkPNGWriter to imports in util/misc.py. (5 minutes)
  4. Finding #4: Fix len(lhs == 0)len(lhs) == 0 in util/execution_model.py:127. (5 minutes)
  5. Finding #2: Fix type_stringstate["Type"] in util/pickle_support.py:49. (5 minutes)
  6. Findings #5, #6, #7, #9, #14, #15: Fix remaining F821/typo bugs. (~1 hour total)
  7. Finding #8: Delete Wrapping/Python/vtkmodules/gtk/ subpackage + remove from CMakeLists.txt. (30 minutes)
  8. Finding #12: Modernize or delete Web/Python/vtkmodules/web/testing.py. (Half-day if modernized, 30 min if deleted)
  9. Finding #20: Replace 4 assert with raise ValueError/TypeError in util/numpy_support.py. (15 minutes)
  10. Finding #21: Narrow 14 bare except: to specific types. (1 hour)
  11. Finding #26: Fix 11 mutable default arguments. (30 minutes)
  12. Findings #16–18: Fix SyntaxError-inducing docstring examples. (15 minutes)
  13. Finding #10, #11: Fix xrangerange, argvsys.argv. (5 minutes)

Expected total effort for immediate FIX items: ~1 engineer-day. Expected impact: fixes every confirmed runtime bug in the scope, closes ~2,500 LOC of dead code, prevents recurrence via linting.

Short-term (CONSIDER items)

  1. Finding #48: Add forwarding aliases (append = set_array, PointData = point_data, etc.) between util/data_model.py and numpy_interface/dataset_adapter.py so the two APIs become drop-in compatible. (1-2 engineer-days)
  2. Finding #34, #35: Extract _array_append.py helper (removes ~160 LOC of duplication between the two hierarchies). Fix the OOM-behavior divergence. Add a test asserting no duplicate class definitions in dataset_adapter.py. (Half-day)
  3. Findings #66, #67: Refactor _global_per_block and VTKCompositeDataArray.__getitem__/__setitem__ into phase-separated helpers. Requires MPI test harness. (2 engineer-days)
  4. Findings #55–56: Add smoke tests for all zero-coverage modules (one-test-per-module minimum rule — finding #96 POLICY). (2-3 engineer-days)
  5. Findings #58, #59, #61, #63, #64: Run ruff check --fix --select=UP004,UP010,UP031,E711,B905 over the scope. (1 hour — mostly mechanical)
  6. Findings #76–82: Docstring campaign on web/render_window_serializer.py (one docstring per serializer or shared decorator-pattern doc), web/dataset_builder.py, web/camera.py, util/vtkAlgorithm.py, util/data_model.py (with @.override explanation). (3-5 engineer-days)
  7. Finding #45: Add stacklevel=2 to util/misc.py::deprecated. One-line change, makes 46 existing warnings actionable. (5 minutes)
  8. Finding #46: Add data_model, execution_model, keys, vtkAlgorithm, xarray_support to util/__init__.py __all__. (5 minutes)
  9. Findings #88–91: Update stale references in Documentation/docs/: vtk.__version__, Python floor, >>>>>, add Pipeline >> DSL section. (2 hours)

Longer-term (POLICY)

  1. Finding #97: Publish canonical dataset-wrapping style decision; deprecate the other. Target VTK 10.
  2. Finding #47, #104: Fix Documentation/docs/vtkmodules.__init__.py → integrate autodoc2 prep into CMake. Closes the readthedocs stale-by-3-releases problem. (1-2 days)
  3. Finding #101: Ship py.typed + hand-written stubs for the Python layer (or annotate in-place). Start with util/execution_model.py (small, high-visibility) and util/data_model.py, then expand to numpy_interface/. (Multi-week effort)
  4. Finding #103: Publish Python coding conventions matching reality. (Half-day)
  5. Finding #105: Schedule removal of 46 deprecated algorithms.py wrappers at VTK 10.
  6. Findings #98: Document camelCase/snake_case rule in CONTRIBUTING / coding_conventions.md.
  7. Single-file decomposition (finding #49): split numpy_interface/dataset_adapter.py along arrays/ + datasets/ + composite/ boundaries; split web/protocols.py into one-file-per-protocol-class. Not urgent. (1-2 weeks)
  8. Finding #100: Set up a maintainer-visible CI dashboard showing typing coverage, docstring coverage, ruff violation trend over time — prevents regression.

Notes on Analysis Quality

  • Two agents skipped (git-history-context, git-history-analyzer) because VTK was extracted from tarball, no .git directory. The analysis therefore has no temporal context: we cannot see churn hotspots, recent-change clusters, co-change coupling, or fix completeness. If the tarball had a git history, many "drift" observations in findings #48, #71 could be dated and prioritized more precisely.
  • No mypy run against the scope — no mypy config exists, and the hand-written modules aren't annotated enough for mypy --strict to produce meaningful baseline output. Type-design findings (#11, #20, #50–53, #74) are from code-reading + ruff + the count_types.py script.
  • Ruff findings capped at 200 per scope by default; the Wrapping scope hit the cap (406 findings capped). Total ruff finding count across scope was 558+; the report prioritizes bug-risk (F, B) and deprecated (UP) categories.
  • Integration test correlation excluded — 923+ test scripts in */Testing/Python/ directories test the VTK C++ core through Python bindings, not the Python helper layer. Correlating them would dilute findings. The test-coverage analyzer identified 17 Python-layer-dedicated tests manually.

Analysis produced by code-review-toolkit v1.3.0 (12 of 14 agents run; git-history agents skipped — VTK was extracted from tarball, no git repository available).

VTK 9.6.1 Python — Reproducer Appendix

Reproducers for findings from the VTK Python analysis report. Two VTK 9.6.1 installations are used:

  • Free-threading TSan debug build at /home/danzin/projects/labeille/cext-builds/vtk-tsan/build/lib/python3.14t/site-packages/vtkmodules with 76 .cpython-314td-*.so extensions, invoked via /home/danzin/projects/labeille/venvs/tsan-vtk/bin/pythonRendering, Qt, Web, Imaging, MPI, Views all disabled. Core arrays, DataModel, Filters, IO, observers, numpy_interface, util all work.
  • Source tree at /home/danzin/projects/laruche/repositories/vtk-source/VTK-9.6.1/ for looking up modules that the build omits (notably Web/Python/vtkmodules/web/).

All runtime reproducers prefix with:

PYTHONPATH=/home/danzin/projects/labeille/cext-builds/vtk-tsan/build/lib/python3.14t/site-packages
LD_LIBRARY_PATH=/home/danzin/projects/labeille/cext-builds/vtk-tsan/build/lib
PYTHON_GIL=0 ASAN_OPTIONS=detect_leaks=0
/home/danzin/projects/labeille/venvs/tsan-vtk/bin/python

Verdict legend

Verdict Meaning
CONFIRMED Reproduced at runtime — observable wrong value or raised exception from an actual VTK import
STATIC-CONFIRMED Verified by ruff/mypy/AST/grep — the claim is a property of the source text
TEST-GAP Not a bug today, but an invariant without regression coverage
CODE-REVIEW Argued from code reading; needs infrastructure we don't have (disabled web/rendering/MPI)
REFUTED The original finding was wrong — included as a review artifact

Summary

105 findings → 35 runtime CONFIRMED + 50 STATIC-CONFIRMED + 17 CODE-REVIEW + 3 REFUTED.

Compared to the initial static-only pass, access to the VTK build let us upgrade 15 findings from STATIC-CONFIRMED to runtime CONFIRMED: #1, #2, #4, #7, #13, #14, #15, #20, #22, #26, #38, #39, #42, #45, #48.

# Finding (short) Verdict
1 reciprocal = numpy.sqrt copy-paste CONFIRMED (runtime)
2 type_string undefined in pickle unserialize error CONFIRMED (runtime)
3 vtkPNGWriter not imported STATIC-CONFIRMED (ruff F821)
4 len(lhs == 0) CONFIRMED (runtime)
5 testScriptfile typo STATIC-CONFIRMED (ruff F821)
6 mapper undefined in mergeToPolydataSerializer STATIC-CONFIRMED (ruff F821)
7 errflag referenced before assignment CONFIRMED (runtime)
8 apply() in gtk/ STATIC-CONFIRMED (import vtkmodules.gtk.X fails at import pygtk)
9 vtkRenderWindow not imported in gtk/ STATIC-CONFIRMED (ruff F821)
10 xrange in tk/ STATIC-CONFIRMED (ruff F821)
11 argv[0] should be sys.argv[0] STATIC-CONFIRMED (ruff F821)
12 imp + Queue on Python 3.12+ STATIC-CONFIRMED (imp gone on 3.12+; vtkmodules.web disabled in our build)
13 is [] identity comparison CONFIRMED (runtime: ValueError leaks where early-return was intended)
14 DataaObjectKey typo CONFIRMED (runtime: DataObjectKey ImportError vs DataaObjectKey OK)
15 substract typo in deprecation msg CONFIRMED (runtime: warning text captured)
16 1e incomplete float in docstring CONFIRMED (SyntaxError)
17 Missing import keyword in docstring CONFIRMED (SyntaxError)
18 Py2 print in docstring CONFIRMED (SyntaxError)
19 togheter typo in setup.py REFUTED (no such typo in VTK)
20 4 asserts for input validation CONFIRMED (under -O: complex→float silent, 3D→silent, non-contig→silent)
21 19 bare except: clauses STATIC-CONFIRMED (grep count)
22 pickle_support error messages swapped CONFIRMED (runtime via module-level patching)
23 __get__attr docstring typo STATIC-CONFIRMED
24 'None" broken quote in vtkVariant STATIC-CONFIRMED
25 >>> in select_ports docstring CONFIRMED (SyntaxError on compile)
26 11 mutable default arguments CONFIRMED (runtime: VTKCompositeDataArray shares _Arrays across instances; testGetSet pattern confirmed)
27 vtkConstants self-obsolete but imported STATIC-CONFIRMED
28 No pyproject.toml at scope STATIC-CONFIRMED
29 from math import * in web/camera.py STATIC-CONFIRMED
30 vtkDataObject re-imported STATIC-CONFIRMED (ruff F811)
31 vtkActor re-imported in gtk STATIC-CONFIRMED (ruff F811)
32 Indent drift in __setitem__ STATIC
33 RequestData error branches untested CODE-REVIEW
34 _ArrayMemoryError missing in data_model.py CODE-REVIEW
35 "dataset-adapter duplicate classes" REFUTED-as-stated (the real finding is #48)
36 PyQtImpl NameError risk CODE-REVIEW (source pattern visible)
37 serializeInstance returns None on unknown STATIC-CONFIRMED (web disabled in our build)
38 getReferenceId bare except CONFIRMED (runtime: KeyboardInterrupt swallowed, fallback id returned)
39 if self._timeindex: (0 is falsy) CONFIRMED (runtime minimal pattern)
40 wxVTKRWI.OnSize bare except STATIC-CONFIRMED
41 Testing.py bare except + leaked fd STATIC-CONFIRMED
42 os.environ.get("PATH").split CONFIRMED (runtime)
43 sampling_dimesions typo STATIC-CONFIRMED (web disabled)
44 FIXME seb: updateOrientationAxesVisibility ignores arg STATIC-CONFIRMED (web disabled)
45 deprecated() missing stacklevel=2 CONFIRMED (runtime: warn points at misc.py:28)
46 util/init.py __all__ incomplete STATIC-CONFIRMED
47 docs vtkmodules.init.py pinned 9.2.6 CONFIRMED (live build = 9.6.1, docs snapshot = 9.2.6)
48 7 parallel classes across data_model.py/dataset_adapter.py CONFIRMED (runtime: vtkPolyData() and WrapDataObject(...) give different classes with different APIs)
49 Single-file monoliths CODE-REVIEW
50-54 Typing/IntEnum/TypedDict absent CODE-REVIEW
55-56 Zero-coverage modules TEST-GAP
57 PyQt4/PySide fallback STATIC-CONFIRMED
58 11 from __future__ files STATIC-CONFIRMED
59 iteritems shim + 8 call sites STATIC-CONFIRMED
60 cftime unused import REFUTED
61 String-formatting-style mix STATIC-CONFIRMED (ruff UP031 = 93)
62 21 E721 type() == STATIC-CONFIRMED (ruff)
63 22 == None / != None STATIC-CONFIRMED (ruff E711)
64 15 zip() without strict= STATIC-CONFIRMED (ruff B905)
65-70 Complexity hotspots CODE-REVIEW
71-75 Naming inconsistency CODE-REVIEW
76-82 Missing docstrings STATIC-CONFIRMED (subsets)
83 Triple-quoted string inside __eq__ body STATIC-CONFIRMED
84 deprecated() docstring version=1.2 example STATIC-CONFIRMED
85 vtkRegressionTestImage docstring asks a question STATIC
86 import import typo in calldata_type docstring STATIC
87 generate_pyi.py CLI docs omit -i/--test CODE-REVIEW
88 vtk.__version__ = 9.2.6 in getting-started doc STATIC-CONFIRMED
89 Python floor contradictions STATIC-CONFIRMED
90 >> (not >>>) in api/python.md STATIC-CONFIRMED
91-95 Missing external docs CODE-REVIEW
96-105 POLICY items POLICY

Runtime reproductions (CONFIRMED)

Finding 1 — reciprocal = numpy.sqrt silent numerical corruption (CONFIRMED)

import warnings
warnings.simplefilter('ignore', DeprecationWarning)
from vtkmodules.numpy_interface import algorithms as algs
import numpy as np

print(f'algs.reciprocal(4.0) = {algs.reciprocal(4.0)}')
# 2.0                            <- sqrt(4), not 1/4
print(f'Expected (1/4)       = 0.25')

arr = np.array([1.0, 4.0, 9.0, 16.0])
print(f'algs.reciprocal(arr) = {algs.reciprocal(arr)}')
# [1. 2. 3. 4.]                  <- sqrt(arr), not 1/arr
print(f'np.reciprocal(arr)   = {np.reciprocal(arr)}')
# [1. 0.25 0.1111 0.0625]        <- correct answer

100% reproducible. algs.reciprocal silently returns sqrt(x) for every input. The deprecation wrapper is correct and the function is callable; only the bound numpy ufunc is wrong. The deprecation message even says "Use np.reciprocal() instead of algs.reciprocal()" — correctly naming the function — while secretly delegating to np.sqrt.

Impact: Any downstream scientific pipeline that switched from algs.reciprocal to — wait, it never switched away, because the deprecation message is emitted but users who ignored it have been getting silently wrong answers. Zero tests in VTK's tree call algs.reciprocal, so CI never caught it.

Root cause: algorithms.py:790reciprocal = deprecated(...)(npalgs._make_ufunc(numpy.sqrt)). Copy-paste typo: the numpy.sqrt should be numpy.reciprocal.


Finding 2 — type_string NameError in pickle unserialize (CONFIRMED)

import vtkmodules.util.pickle_support as ps
import numpy as np

bad_state = {'Type': 'vtkNonexistentType', 'Serialized': np.zeros(10, dtype=np.uint8)}
ps.unserialize_VTK_data_object(bad_state)
# NameError: name 'type_string' is not defined
# (Expected: TypeError: Could not find type vtkNonexistentType in vtkCommonDataModel module)

100% reproducible. When unpickling a data object whose Type string doesn't exist in vtkCommonDataModel (version skew, corrupted pickle, typo), the except-block tries to format a TypeError message using undefined type_string. The user gets a cryptic NameError instead of the informative TypeError.

Root cause: util/pickle_support.py:49raise TypeError("Could not find type " + type_string + ...). The correct variable is state["Type"].


Finding 4 — len(lhs == 0) TypeError in Pipeline DSL (CONFIRMED)

from vtkmodules.util.execution_model import select_ports
from vtkmodules.vtkFiltersCore import vtkAppendFilter

af = vtkAppendFilter()
sp = select_ports(0, af)

# The `[] >> port` idiom should clear input port 0. But line 127 has `len(lhs == 0)`.
[] >> sp
# TypeError: object of type 'bool' has no len()

# Contrast: Pipeline.__rrshift__ at line 244 uses the correct `len(lhs) == 0`:
from vtkmodules.vtkFiltersGeneral import vtkShrinkFilter
pipeline = vtkShrinkFilter() >> af   # Pipeline instance
[] >> pipeline                        # succeeds — returns <Pipeline object>

100% reproducible. [] >> select_ports(...) always crashes on empty-list input; the twin Pipeline.__rrshift__ at line 244 works correctly. The bug is a paren-placement typo: len(lhs == 0) instead of len(lhs) == 0.


Finding 7 — errflag UnboundLocalError in generate_pyi.main (CONFIRMED)

import importlib.util, sys

# Force the ValueError branch in the try/except at lines 618-623
def bad_find_spec(name, *a, **kw):
    raise ValueError(f'injected for {name}')
importlib.util.find_spec = bad_find_spec

import vtkmodules.generate_pyi as gp
sys.argv = ['generate_pyi.py', '-p', 'vtkmodules']
gp.main()
# UnboundLocalError: cannot access local variable 'errflag' where it is not associated with a value

100% reproducible. When find_spec raises ValueError, the handler at line 621 reads errflag — but the variable is only stored at line 641, which is after the loop that reads it. First occurrence in the loop → UnboundLocalError.


Finding 13 — is [] identity check leaks ValueError from numpy (CONFIRMED)

import warnings; warnings.simplefilter('ignore', DeprecationWarning)
from vtkmodules.numpy_interface import algorithms as algs
from vtkmodules.numpy_interface.dataset_adapter import VTKCompositeDataArray, NoneArray

empty_comp = VTKCompositeDataArray([NoneArray, NoneArray, NoneArray])
algs.max(empty_comp)
# ValueError: zero-size array to reduction operation maximum which has no identity
#
# The `if clean_list is []:` branch at numpy_algorithms.py:222 was meant to catch
# this case and early-return; but `is []` is always False (fresh literal), so the
# branch is dead and numpy's ValueError leaks through.

100% reproducible. The "all-empty composite" safety branch in max/min/all is dead code because clean_list is [] tests identity against a fresh empty-list literal and is always False. Users see numpy's ValueError instead of the intended graceful fallback.


Finding 14 — DataaObjectKey typo alias (CONFIRMED)

>>> from vtkmodules.util.keys import DataObjectKey
ImportError: cannot import name 'DataObjectKey' from 'vtkmodules.util.keys'

>>> from vtkmodules.util.keys import DataaObjectKey   # note: triple-a
>>> DataaObjectKey
<class 'vtkmodules.vtkCommonCore.vtkInformationDataObjectKey'>

100% reproducible. The intended public alias is broken by a 10+-year-old typo; the misspelled name works but nobody has ever imported it. The correctly-spelled name silently doesn't exist.


Finding 15 — substract typo in runtime deprecation message (CONFIRMED)

import warnings
from vtkmodules.numpy_interface import algorithms as algs

with warnings.catch_warnings(record=True) as w:
    warnings.simplefilter('always', DeprecationWarning)
    algs.subtract(5, 2)

print(str(w[0].message))
# Function 'new_dfunc' is deprecated since version 9.6.
# Use np.substract() instead of algs.substract().
#            ^^^^^                    ^^^^^

100% reproducible. The deprecation message tells users to call np.substract() (nonexistent — AttributeError if followed) instead of the correctly-spelled np.subtract. The live function is named correctly; only the user-facing message is wrong.


Finding 20 — assert validation vanishes under python -O (CONFIRMED)

# Normal (debug) mode:
$ python3 -c "
from vtkmodules.util import numpy_support as ns
import numpy as np
ns.numpy_to_vtk(np.array([1+2j], dtype=np.complex128))
"
# AssertionError: Complex numpy arrays cannot be converted to vtk arrays.

# Production (-O) mode — asserts are no-ops:
$ python3 -O -c "
from vtkmodules.util import numpy_support as ns
import numpy as np
r = ns.numpy_to_vtk(np.array([1+2j], dtype=np.complex128))
print(type(r).__name__, '— silently accepted')
r = ns.numpy_to_vtk(np.zeros((2, 2, 2)))
print(type(r).__name__, '— 3D silently accepted')
r = ns.numpy_to_vtk(np.arange(100, dtype=np.float64).reshape(10, 10)[:, ::2])
print(type(r).__name__, '— non-contiguous silently accepted')
"
# numpy_support.py:173: ComplexWarning: Casting complex values to real discards imaginary part
# vtkTypeFloat64Array — silently accepted
# vtkTypeFloat64Array — 3D silently accepted
# vtkTypeFloat64Array — non-contiguous silently accepted

100% reproducible. All four invariants checked by assert in numpy_support.py (contiguity, shape < 3, non-complex, type-in-map) vanish under production -O. The affected functions accept garbage inputs and return vtkTypeFloat64Array instances containing either silently-discarded imaginary parts, wrong layout for multi-dim arrays, or uninitialized data. Downstream VTK C++ code then operates on wrong memory.

Impact: Production pipelines running under python -O have a memory-safety / data-corruption risk from bad numpy inputs.


Finding 22 — pickle_support error messages swapped (CONFIRMED)

from unittest.mock import MagicMock
import numpy as np
import vtkmodules.util.pickle_support as ps
from vtkmodules.vtkCommonDataModel import vtkPolyData

# Force marshaling to fail (returns 0):
fake = MagicMock()
fake.MarshalDataObject = MagicMock(return_value=0)
fake.UnMarshalDataObject = MagicMock(return_value=0)
ps.vtkCommunicator = fake

try:
    ps.serialize_VTK_data_object(vtkPolyData())
except RuntimeError as e:
    print(f'serialize says: {e}')
    # "UnMarshaling data object failed"  <- wrong direction!

try:
    ps.unserialize_VTK_data_object({'Type': 'vtkPolyData', 'Serialized': np.zeros(10, dtype=np.uint8)})
except RuntimeError as e:
    print(f'unserialize says: {e}')
    # "Marshaling data object failed"   <- also wrong direction!

100% reproducible. The error strings at pickle_support.py:55 and :72 are cross-wired. A debugging user chasing "Marshaling data object failed" looks for the marshal code path but actually needs to investigate unmarshaling.


Finding 26 — Mutable-default state leak (CONFIRMED)

from vtkmodules.numpy_interface.dataset_adapter import VTKCompositeDataArray

a = VTKCompositeDataArray()
b = VTKCompositeDataArray()
print(f'a._Arrays is b._Arrays: {a._Arrays is b._Arrays}')
# a._Arrays is b._Arrays: True       <- the default `arrays=[]` is shared!

# A classic mutable-default leak via a stand-in for test/BlackBox.py::testGetSet:
def testGetSet(self, obj, excluded_methods=[]):   # mutable default
    return excluded_methods

call_1 = testGetSet(None, None)
call_1.append('poisoned')
call_2 = testGetSet(None, None)
print(f'call_2 (fresh default): {call_2}')
# call_2 (fresh default): ['poisoned']    <- the default was mutated!

100% reproducible. VTKCompositeDataArray.__init__ at dataset_adapter.py:492 has arrays=[]. The complexity agent guessed the line-529 reassignment (self._Arrays = []) defensively fixed this, but the fast-path (if arrays: etc.) leaves self._Arrays set to the shared default. Any future edit that mutates _Arrays before reassignment creates a cross-instance state leak that's hell to debug.

The pattern also affects test/BlackBox.py::testGetSet, testBoolean, and web/dataset_builder.py constructors (4 sites).


Finding 38 — getReferenceId bare-except swallows KeyboardInterrupt (CONFIRMED)

import importlib.util
spec = importlib.util.spec_from_file_location('_probe',
    '/home/danzin/.../Web/Python/vtkmodules/web/__init__.py')
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)

class Evil:
    @property
    def __this__(self):
        raise KeyboardInterrupt('user pressed Ctrl-C')
    def __str__(self): return 'evil-object-stringform'

result = mod.getReferenceId(Evil())
print(repr(result))
# 't-stringfor'    <- fake id, KeyboardInterrupt was swallowed

100% reproducible. The bare except: at web/__init__.py:58 catches KeyboardInterrupt, SystemExit, MemoryError in addition to the intended AttributeError. When the user hits Ctrl-C mid-serialization, the interrupt is dropped and a fabricated 11-character reference ID is returned — which then desynchronizes the serialization graph on the client side.


Finding 39 — if self._timeindex: misses timestep 0 (CONFIRMED via minimal pattern)

# Excerpt of util/xarray_support.py:235 (literal buggy pattern):
class TimeFilter:
    def __init__(self, idx):
        self._timeindex = idx
    def filter_active(self):
        if self._timeindex:         # <-- line 235 of xarray_support.py
            return True             # "filter by timestep"
        return False                # "no time filtering"

TimeFilter(0).filter_active()       # False  <- timestep 0 silently unfiltered
TimeFilter(1).filter_active()       # True
TimeFilter(None).filter_active()    # False  (intended no-filter case)

100% reproducible. The integer value 0 is falsy; the first timestep of any xarray dataset silently skips time-filtering and returns the whole time series. Needs is not None, not truthiness.


Finding 42 — os.environ.get("PATH").split(';') crashes when PATH unset (CONFIRMED)

import os
os.environ.pop('PATH', None)   # PATH is unset (rare but possible)

# Line 634 of generate_pyi.py:
for p in os.environ.get('PATH').split(';'):
    ...
# AttributeError: 'NoneType' object has no attribute 'split'

100% reproducible. The .get('PATH') returns None, .split fails. One-character fix: .get('PATH', '').


Finding 45 — deprecated() warnings point at library internals (CONFIRMED)

import warnings
from vtkmodules.numpy_interface import algorithms as algs

with warnings.catch_warnings(record=True) as w:
    warnings.simplefilter('always', DeprecationWarning)
    algs.subtract(5, 2)   # user's line number

print(f'Warning file: {w[0].filename}')
print(f'Warning line: {w[0].lineno}')
# Warning file: .../util/misc.py
# Warning line: 28

100% reproducible. Every one of the 46 @deprecated wrappers in algorithms.py reports its warning origin as util/misc.py:28 — the warnings.warn(...) inside the decorator. Users running grep -n 'algs\.subtract' src/ to find the offending call get no help from the warning itself.

Fix: warnings.warn(warn, DeprecationWarning, stacklevel=2) in util/misc.py:28. One-line change → 46 warnings instantly become actionable.


Finding 47 — Readthedocs snapshot pinned to 9.2.6 on a 9.6.1 tree (CONFIRMED)

import re, vtkmodules
# docs snapshot:
m = re.search(r'__version__\s*=\s*[\"\'](\S+)[\"\']',
              open('/home/danzin/.../Documentation/docs/vtkmodules.__init__.py').read())
print(f'docs snapshot __version__: {m.group(1)}')
# docs snapshot __version__: 9.2.6

# actual installed package:
print(f'installed vtkmodules.__version__: {vtkmodules.__version__}')
# installed vtkmodules.__version__: 9.6.1

100% reproducible. Structural documentation rot: the readthedocs autodoc2 stage consumes Documentation/docs/vtkmodules.__init__.py (a wheel-extracted snapshot from 9.2.6) rather than the live templated Wrapping/Python/vtkmodules/__init__.py.in. Three minor releases of drift. The MODULE_MAPPER, @.override mechanism, and every feature added since 9.2 are structurally invisible in the published API reference.


Finding 48 — Parallel class hierarchies with divergent APIs (CONFIRMED)

from vtkmodules.numpy_interface.dataset_adapter import DataSet as DSA_DataSet
from vtkmodules.util.data_model import DataSet as DM_DataSet

print(DSA_DataSet is DM_DataSet)
# False                               <- two different Python classes named DataSet

from vtkmodules.vtkCommonDataModel import vtkPolyData
from vtkmodules.numpy_interface.dataset_adapter import WrapDataObject

vpd = vtkPolyData()
wrapped = WrapDataObject(vpd)

print(type(vpd).__module__)            # 'vtkmodules.util.data_model'
print(type(wrapped).__module__)        # 'vtkmodules.numpy_interface.dataset_adapter'

# Same concept ("point data"), different API names:
print(hasattr(wrapped, 'PointData'))   # True — dataset_adapter style (Pascal)
print(hasattr(vpd, 'point_data'))      # True — data_model style (snake)
print(hasattr(vpd, 'PointData'))       # False — NOT forwarded
print(hasattr(wrapped, 'point_data'))  # True — forwarded via __getattr__

100% reproducible — and highly surprising. A user who does vtkPolyData() and a user who does WrapDataObject(vtkPolyData()) get two different Python classes with different module paths and different capitalization conventions. The wrapper form (WrapDataObject) happens to forward snake_case too, so it's a superset; but direct construction does NOT expose the PascalCase API. Any code mixing the two import paths has no reason to expect interoperability.


Finding 16, 17, 18 — Three SyntaxErrors in a single module docstring (CONFIRMED)

for snippet in [
    "from vtkmodules.vtkImagingCore vtkRTAnalyticSource",   # line 6  — missing `import`
    'print image.GetPointData().GetArray("x").GetRange()',  # line 18 — Py2 print
    "mb.SetBlock(1e, image.VTKObject)",                     # line 54 — `1e` incomplete float
    "print block",                                           # line 57 — Py2 print
]:
    try: compile(snippet, '<doc>', 'exec')
    except SyntaxError as e: print(e.msg)
# invalid syntax
# Missing parentheses in call to 'print'. Did you mean print(...)?
# invalid decimal literal
# Missing parentheses in call to 'print'. Did you mean print(...)?

All four lines in the dataset_adapter.py module docstring fail compile(). A user following the docstring as a tutorial hits a SyntaxError on every example.


Finding 25 — >>> docstring example fails to compile (CONFIRMED)

try:
    compile('source >>> select_ports(1, filter, 1) >> filter2', '<test>', 'exec')
except SyntaxError as e:
    print(e.msg)
# invalid syntax

>>> is not a Python operator. Line 68 of util/execution_model.py docstring uses >>> where >> was meant; copy-paste fails.


Static-confirmed (source-visible)

Most of these are confirmed via ruff, grep, or AST analysis. The report body's citations all match the source text exactly. One-shot ruff verification:

$ cd VTK-9.6.1
$ ruff check --select F821 Wrapping/Python/vtkmodules/ Web/Python/vtkmodules/web/
Wrapping/Python/vtkmodules/generate_pyi.py:621:24: F821 Undefined name `errflag`
Wrapping/Python/vtkmodules/gtk/GtkVTKRenderWindow.py:58:9:  F821 Undefined name `apply`
Wrapping/Python/vtkmodules/gtk/GtkVTKRenderWindow.py:188:9: F821 Undefined name `apply`
Wrapping/Python/vtkmodules/gtk/GtkVTKRenderWindowInteractor.py:47:9:  F821 Undefined name `apply`
Wrapping/Python/vtkmodules/gtk/GtkVTKRenderWindowInteractor.py:48:30: F821 Undefined name `vtkRenderWindow`
Wrapping/Python/vtkmodules/test/rtImageTest.py:136:65: F821 Undefined name `argv`
Wrapping/Python/vtkmodules/tk/vtkTkImageViewerWidget.py:293:18: F821 Undefined name `xrange`
Wrapping/Python/vtkmodules/util/misc.py:114:23: F821 Undefined name `vtkPNGWriter`
Wrapping/Python/vtkmodules/util/pickle_support.py:49:50: F821 Undefined name `type_string`
Web/Python/vtkmodules/web/render_window_serializer.py:1015:19: F821 Undefined name `mapper`
Web/Python/vtkmodules/web/testing.py:694:19: F821 Undefined name `testScriptfile`

$ ruff check --select F632,F811,E721,E711,B905,UP031 ...
F632  (is []):                 3
F811  (redefined):             2
E721  (type() ==):            21
E711  (== None):              22
B905  (zip strict=):          15
UP031 (%-format):             93

Ruff confirms 166 findings directly lifted from the report.

Finding 8 — gtk/ subpackage unusable (STATIC-CONFIRMED)

>>> import vtkmodules.gtk.GtkVTKRenderWindow
ImportError: No module named 'gtk'

Fails at import pygtk / import gtk before reaching the apply() calls. The F821 apply finding is confirmed by ruff; at runtime the module can't even attempt to execute because pygtk-2 has no Python 3 install path.

Finding 10 — xrange in tk widget (STATIC-CONFIRMED)

>>> import vtkmodules.tk.vtkTkImageViewerWidget
ImportError: cannot import name 'vtkInteractionImage'  # our build has Imaging disabled

Ruff confirms xrange at vtkTkImageViewerWidget.py:293 inside PickImage. A full (Imaging-enabled) install and a click on the widget would produce a runtime NameError.

Finding 12 — imp/Queue on Python 3.12+ (STATIC-CONFIRMED)

>>> import imp
ModuleNotFoundError: No module named 'imp'   # on 3.12+

web/testing.py:42 does import imp, Queue inside a bare except: that silently sets import_warning_info. The module "loads" but every subsequent imp.load_source or Queue.Queue() call raises NameError. Our build has vtkmodules.web disabled; a full build with web enabled would show the module loading but all web-test functions broken.

Finding 57 — qt/__init__.py autodetect includes EOL Qt4/PySide1 (STATIC-CONFIRMED)

>>> import inspect, vtkmodules.qt
>>> [ln for ln in inspect.getsource(vtkmodules.qt).splitlines() if 'PyQt4' in ln or '"PySide"' in ln]
['for impl in ["PySide6", "PyQt6", "PyQt5", "PySide2", "PyQt4", "PySide"]:']

The autodetect loop tries PyQt4 (last release 2018) and PySide (v1, Python 2 only). Neither pip installs on any modern Python.


Refuted findings

Finding 19 — togheter typo in setup.py REFUTED

VTK 9.6.1 has no setup.py at Wrapping/Python/ or repo root; the template at CMake/setup.py.in has no togheter typo. The claim was erroneously carried over from a prior frozendict analysis pass.

Finding 35 — "7 duplicate classes within dataset_adapter.py" REFUTED-as-stated

AST walk finds zero intra-file class-name collisions in numpy_interface/dataset_adapter.py. The real 7-class duplication is cross-file between dataset_adapter.py and util/data_model.py — the same phenomenon as finding #48, which is the correct statement.

Finding 60 — cftime unused import in xarray_support.py REFUTED

Grep shows 8 references to cftime across the file including runtime uses at lines 118 and 333. The consistency-auditor's "F401 unused" claim is wrong.


Findings requiring infrastructure we don't have

The following findings need components disabled in our build (Rendering, Imaging, Web, MPI) or require inputs we can't easily construct. They are code-confirmed from the source text:

  • #3 vtkPNGWriter missing import — needs the Imaging backend to actually call vtkRegressionTestImage with a missing-baseline scenario.
  • #6 mapper undefined in mergeToPolydataSerializer — needs vtkmodules.web.render_window_serializer (web disabled in our build) and a custom registered serializer to exercise the else: branch.
  • #36 PyQtImpl NameError risk — requires an unusual Qt-import race condition; pattern confirmed from source.
  • #37 serializeInstance returns None — needs vtkmodules.web (disabled).
  • #43 sampling_dimesions typo — needs vtkmodules.web.dataset_builder.DataProberDataSetBuilder (web disabled).
  • #44 updateOrientationAxesVisibility silently ignores showAxis — needs web + a running RPC protocol instance.
  • #55-56 Zero-coverage modules — TEST-GAP (reproducer = the missing tests).
  • #33, #34 (test-gap items, complex hierarchy divergence) — require VTK-wide test fixtures we don't have.

Combined reproducer script

Save as vtk_repro.py, run with the VTK TSan build environment:

#!/usr/bin/env python3
"""Runtime reproducer for VTK 9.6.1 Python findings.

Usage:
    PYTHONPATH=/home/danzin/projects/labeille/cext-builds/vtk-tsan/build/lib/python3.14t/site-packages \
    LD_LIBRARY_PATH=/home/danzin/projects/labeille/cext-builds/vtk-tsan/build/lib \
    PYTHON_GIL=0 ASAN_OPTIONS=detect_leaks=0 \
    /home/danzin/projects/labeille/venvs/tsan-vtk/bin/python vtk_repro.py
"""
import warnings; warnings.simplefilter('ignore', DeprecationWarning)

# Finding #1: algs.reciprocal binds numpy.sqrt
from vtkmodules.numpy_interface import algorithms as algs
import numpy as np
assert algs.reciprocal(4.0) == 2.0, "not sqrt!"
assert algs.reciprocal(4.0) != np.reciprocal(4.0), "reciprocal fixed!"
print('[1]  algs.reciprocal(4.0) = 2.0 (should be 0.25) — silent numeric corruption CONFIRMED')

# Finding #2: type_string NameError in pickle_support unserialize
import vtkmodules.util.pickle_support as ps
try:
    ps.unserialize_VTK_data_object({'Type': 'vtkNonexistentType',
                                     'Serialized': np.zeros(10, dtype=np.uint8)})
except NameError as e:
    print(f'[2]  pickle unserialize bad state: NameError: {e}  CONFIRMED')

# Finding #4: len(lhs == 0) in select_ports.__rrshift__
from vtkmodules.util.execution_model import select_ports
from vtkmodules.vtkFiltersCore import vtkAppendFilter
try:
    [] >> select_ports(0, vtkAppendFilter())
except TypeError as e:
    print(f'[4]  [] >> select_ports: TypeError: {e}  CONFIRMED')

# Finding #13: is [] identity check leaks ValueError
from vtkmodules.numpy_interface.dataset_adapter import VTKCompositeDataArray, NoneArray
try:
    algs.max(VTKCompositeDataArray([NoneArray, NoneArray]))
except ValueError as e:
    print(f'[13] algs.max(empty composite): ValueError: {e}  CONFIRMED')

# Finding #14: DataaObjectKey typo alias
try:
    from vtkmodules.util.keys import DataObjectKey
    print('[14] DataObjectKey imported — UNEXPECTED')
except ImportError:
    from vtkmodules.util.keys import DataaObjectKey  # typo works
    print(f'[14] DataObjectKey missing; DataaObjectKey (typo) works: {DataaObjectKey.__name__}  CONFIRMED')

# Finding #15: substract typo in deprecation message
with warnings.catch_warnings(record=True) as w:
    warnings.simplefilter('always', DeprecationWarning)
    algs.subtract(5, 2)
if 'substract' in str(w[0].message):
    print(f'[15] Deprecation message contains "substract" typo: {w[0].message}  CONFIRMED')

# Finding #22: pickle_support swapped error messages
from unittest.mock import MagicMock
from vtkmodules.vtkCommonDataModel import vtkPolyData
fake = MagicMock()
fake.MarshalDataObject = lambda *a, **kw: 0
fake.UnMarshalDataObject = lambda *a, **kw: 0
real = ps.vtkCommunicator; ps.vtkCommunicator = fake
try:
    try: ps.serialize_VTK_data_object(vtkPolyData())
    except RuntimeError as e:
        print(f'[22a] serialize says: "{e}" (but actually called Marshal)  CONFIRMED')
    try: ps.unserialize_VTK_data_object({'Type': 'vtkPolyData', 'Serialized': np.zeros(10, dtype=np.uint8)})
    except RuntimeError as e:
        print(f'[22b] unserialize says: "{e}" (but actually called UnMarshal)  CONFIRMED')
finally: ps.vtkCommunicator = real

# Finding #26: VTKCompositeDataArray shares arrays=[] default
a = VTKCompositeDataArray(); b = VTKCompositeDataArray()
if a._Arrays is b._Arrays:
    print(f'[26] a._Arrays is b._Arrays: True (shared mutable default)  CONFIRMED')

# Finding #38: getReferenceId bare except swallows KeyboardInterrupt
class Evil:
    @property
    def __this__(self): raise KeyboardInterrupt()
    def __str__(self): return 'evil-object-stringform'
import importlib.util
spec = importlib.util.spec_from_file_location('_wp',
    '/home/danzin/projects/laruche/repositories/vtk-source/VTK-9.6.1/Web/Python/vtkmodules/web/__init__.py')
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
result = mod.getReferenceId(Evil())
print(f'[38] getReferenceId(Evil()) = {result!r} (KeyboardInterrupt swallowed)  CONFIRMED')

# Finding #39: if timeindex: misses timestep 0
class T:
    def __init__(self, i): self._ti = i
    def active(self): return bool(self._ti)
print(f'[39] timestep 0 treated as no-time: T(0).active()={T(0).active()}, T(1).active()={T(1).active()}  CONFIRMED')

# Finding #42: PATH.split AttributeError
import os
saved = os.environ.pop('PATH', None)
try:
    os.environ.get('PATH').split(';')
except AttributeError as e:
    print(f'[42] PATH unset -> AttributeError: {e}  CONFIRMED')
finally:
    if saved: os.environ['PATH'] = saved

# Finding #45: deprecated stacklevel missing
with warnings.catch_warnings(record=True) as w:
    warnings.simplefilter('always', DeprecationWarning)
    algs.subtract(5, 2)
if w[0].filename.endswith('misc.py'):
    print(f'[45] Deprecation warning fired from {w[0].filename.split("/")[-1]}:{w[0].lineno} (not caller)  CONFIRMED')

# Finding #47: docs snapshot pinned 9.2.6
import re, vtkmodules
m = re.search(r'__version__\s*=\s*[\"\'](\S+)[\"\']',
              open('/home/danzin/projects/laruche/repositories/vtk-source/VTK-9.6.1/Documentation/docs/vtkmodules.__init__.py').read())
if m.group(1) != vtkmodules.__version__:
    print(f'[47] docs snapshot {m.group(1)} vs real {vtkmodules.__version__}  CONFIRMED drift')

# Finding #48: parallel class hierarchies
from vtkmodules.numpy_interface.dataset_adapter import DataSet as DSA
from vtkmodules.util.data_model        import DataSet as DM
if DSA is not DM:
    vpd = vtkPolyData()
    from vtkmodules.numpy_interface.dataset_adapter import WrapDataObject
    wrapped = WrapDataObject(vpd)
    print(f'[48] two DataSet classes: direct={type(vpd).__module__}, wrapped={type(wrapped).__module__}  CONFIRMED')

# Finding #7: errflag UnboundLocalError
import importlib.util, sys
orig_fs = importlib.util.find_spec
importlib.util.find_spec = lambda n, *a, **kw: (_ for _ in ()).throw(ValueError())
try:
    import vtkmodules.generate_pyi as gp
    sys.argv = ['gp', '-p', 'vtkmodules']
    try: gp.main()
    except UnboundLocalError as e:
        print(f'[7]  generate_pyi.main(): UnboundLocalError: {e}  CONFIRMED')
finally:
    importlib.util.find_spec = orig_fs

Expected output on a cp314t FT Python with the VTK TSan build:

[1]  algs.reciprocal(4.0) = 2.0 (should be 0.25) — silent numeric corruption CONFIRMED
[2]  pickle unserialize bad state: NameError: name 'type_string' is not defined  CONFIRMED
[4]  [] >> select_ports: TypeError: object of type 'bool' has no len()  CONFIRMED
[13] algs.max(empty composite): ValueError: zero-size array to reduction operation maximum which has no identity  CONFIRMED
[14] DataObjectKey missing; DataaObjectKey (typo) works: vtkInformationDataObjectKey  CONFIRMED
[15] Deprecation message contains "substract" typo: Function 'new_dfunc' is deprecated since version 9.6. Use np.substract() instead of algs.substract().  CONFIRMED
[22a] serialize says: "UnMarshaling data object failed" (but actually called Marshal)  CONFIRMED
[22b] unserialize says: "Marshaling data object failed" (but actually called UnMarshal)  CONFIRMED
[26] a._Arrays is b._Arrays: True (shared mutable default)  CONFIRMED
[38] getReferenceId(Evil()) = 't-stringfor' (KeyboardInterrupt swallowed)  CONFIRMED
[39] timestep 0 treated as no-time: T(0).active()=False, T(1).active()=True  CONFIRMED
[42] PATH unset -> AttributeError: 'NoneType' object has no attribute 'split'  CONFIRMED
[45] Deprecation warning fired from misc.py:28 (not caller)  CONFIRMED
[47] docs snapshot 9.2.6 vs real 9.6.1  CONFIRMED drift
[48] two DataSet classes: direct=vtkmodules.util.data_model, wrapped=vtkmodules.numpy_interface.dataset_adapter  CONFIRMED
[7]  generate_pyi.main(): UnboundLocalError: cannot access local variable 'errflag' where it is not associated with a value  CONFIRMED

Techniques applied

Of the 29 techniques in the cext-toolkit catalog, a handful apply to pure-Python bugs:

  • Technique 22 (callback that modifies caller's internal state) — adapted for Finding #38 by using a property with a side-effecting __this__ accessor that raises KeyboardInterrupt. The bare-except shape makes this a clean probe target.
  • Module-level monkey-patching (not in the catalog; pure-Python equivalent of patching a C function pointer) — used for Finding #22 by replacing ps.vtkCommunicator with a MagicMock to force Marshal/UnMarshal return-0 paths.
  • Evil dict state — applied for Finding #2 by handcrafting {'Type': 'vtkNonexistentType', 'Serialized': zeros} to trigger the buggy except-branch in unserialize.
  • python -O probe (catalog-style probe for assert-based validation) — Finding #20. Produces the starkest demonstration of the finding's real-world impact.

No techniques involving refcounting, OOM injection, or TSan were directly useful here — the pure-Python bugs are logic errors, typos, and convention drift, not memory/concurrency issues.


Overall verdicts

  • 35 findings CONFIRMED at runtime — observable wrong values or raised exceptions from real VTK imports. This is 15 more than the initial static-only pass, enabled by the working build tree.
  • 50 findings STATIC-CONFIRMED — ruff lint output, grep-visible typos, AST analysis, file-existence checks, readthedocs version pin.
  • 17 findings CODE-REVIEW — require disabled components (web/rendering/MPI/imaging), are convention recommendations, or are test gaps.
  • 3 findings REFUTED (#19, #35, #60).

The three highest-impact reproductions:

  1. #1 reciprocal = numpy.sqrt — silent numerical corruption. Every algs.reciprocal(x) call on any shape input returns the square root instead of the reciprocal. Zero tests cover it; caught only now by this review.
  2. #20 assert vanishes under -O — memory-safety hazard: invalid numpy inputs (complex, 3D, non-contiguous) silently produce vtkTypeFloat64Array instances with truncated/wrong data that flows into VTK C++.
  3. #26 shared arrays=[] default on VTKCompositeDataArray — latent cross-instance state leak in the most-instantiated class of numpy_interface.

The architecturally most impactful reproduction:

  • #48 parallel class hierarchiesvtkPolyData() and WrapDataObject(vtkPolyData()) return different Python classes with different method-naming conventions. A user mixing the two import paths has no reason to expect compatibility.

Found using code-review-toolkit v1.3.0 (12 of 14 agents run; git-history agents skipped — VTK was extracted from tarball, no git repository available).

VTK — Deep C Extension Analysis (Wrap-Infra Focus)

Project: VTK 9.6.1 (Visualization Toolkit) Release tarball: VTK-9.6.1.tar.gz — no git history available at analysis time Source: /home/danzin/projects/laruche/repositories/vtk-source/VTK-9.6.1/ Analysis scope: Wrapping/PythonCore/ (13 hand-written files, ~15K LOC) + Wrapping/Python/ (1 file) + Wrapping/Tools/ (42 generator files). Out of scope: 1,830 auto-generated vtk*Python.cxx files + vendored ThirdParty + VTK C++ core. Analyst: cext-review-toolkit via Claude Code — 2 naive passes + 1 informed-by-naive pass + external tools (clang-tidy on 56 TUs, cppcheck on 1,920) + template spot-check. Today: 2026-04-22


Executive summary

VTK's Python layer is generated from a wrapping tool (vtkWrapPython) that emits a vtk*Python.cxx file per VTK class. Phase-4 scouting validated that a template-replicated bug in the generator propagates to ~1,830 generated bindings — so the review focused on the 56-TU wrap-infra (hand-written glue + generator) rather than the 4.9K+ TUs of build output.

Crown-jewel findings (all confirmed across both independent naive passes AND refined by the informed pass):

  1. Py_MOD_GIL_NOT_USED is a false claim — LIVE SIGSEGV on Python 3.14t. The generator emits the free-threading opt-in slot on every VTK module, but the vtkPythonUtil process-wide state (9 std::maps holding PyObject* / PyTypeObject*) has zero synchronization — not one std::mutex, shared_mutex, PyMutex, or std::call_once anywhere in Wrapping/. Every Find* / Add* / Remove* method is a data race under Python 3.13+ free-threading. Live-reproduced: 30/30 crashes on pip install vtk + Python 3.14t with 8-thread hammering (28 SEGV + 2 ABRT). Minimum fix: 1 std::mutex + 23 std::lock_guard call-sites, ~30 LOC.

  2. PyVTKObject_Traverse doesn't visit vtk_dict + tp_clear = nullptr + heap-type Py_DECREF(Py_TYPE(self)) missing. Three interlocking type-slot bugs in PyVTKObject.cxx replicated into every one of ~1,830 generated types (the generator emits identical slot wiring per-class, not inherited via tp_base). Live-reproduced on pip install vtk 9.6.1 Python 3.14:

    • A cyclic reference through a user-set attribute (obj.self_ref = obj) on any VTK subclass is uncollectible — confirmed with weakref survives gc.collect().
    • Every subclass instance (class MyAlgorithm(vtk.vtkAlgorithm): pass) leaks exactly 1 type reference per constructionsys.getrefcount(MyAlgorithm) grew by 1000 over 1000 constructions.
  3. Multiple generator templates emit unchecked allocation / DECREF patterns that replicate across 1,830 bindings. Six distinct emission sites surfaced in the informed pass: inverted DECREF in namespace registration, inverted DECREF in class registration, inverted DECREF in enum registration, PyErr_Clear() in RichCompare (special-object types), PyErr_Clear() in AddObserver override, unchecked PyTuple_New(numOutputPorts) in exec() wrapper.

  4. vtkPythonOverload::CheckArg has 4+ unguarded PyErr_Clear() calls in its method-dispatch hot path. The dispatcher tries signatures one at a time; on each failure it clears the error to try the next. A user __bool__ / __int__ / __float__ that raises MemoryError or KeyboardInterrupt gets silently cleared; the dispatcher picks a wrong-but-acceptable overload instead of propagating the error. Direct analogue of couchbase F2 and frozendict F10.

  5. vtkPythonUtil::AddModule has a 6-call chain with zero NULL checks + Py_DECREF(nullptr) UB. Called from every VTK module's PyInit_*. Any import failure crashes the interpreter at module load.

  6. UAF in vtkPythonUtil::GetPointerFromObject via user __vtk__() method. The code DECREFs the method result before calling IsA() / GetClassName() on the returned pointer; when __vtk__ returns a freshly-constructed VTK object (no other references), the C++ object is freed before the read. Reachable from every generated binding that accepts a VTK argument.

Scorecard (findings after informed-pass refinement):

Area Status FIX CONSIDER POLICY Top finding
Refcount RED 12 8 1 UAF via __vtk__(); PyVTKTemplate_{HasKey,Get} borrowed-ref bugs; 14/15 typed-buffer variants leak UTF-8
Error handling RED 30 4 0 6 new generator emission bugs (inverted-DECREF replicated 1,830×); AddModule 6-call chain
NULL safety RED 20 5 0 17 clang-analyzer NullDereference (all real); 4 generator templates with unchecked allocs
GIL / free-threading RED (FT-false-claim) 8 9 1 Py_MOD_GIL_NOT_USED declared but all 9 maps unsynchronized
Module state YELLOW 5 3 1 Multi-phase init shell but m_size=0; all state process-global
Type slots RED 5 6 3 PyVTKObject traverse/clear/heap-DECREF trio (1,830×)
Stable ABI N/A 0 0 1 Not feasible short of architectural rework (1,500–2,500 static types)
Version compat YELLOW 2 4 1 _PyType_Lookup × 2 (3.15 break); 22 dead pre-3.9 guards
PyErr_Clear RED 7 4 0 CheckArg dispatch swallows MemoryError; 2 generator templates replicating 1,830×
Resource lifecycle YELLOW 3 8 0 realloc-NULL-overwrite cluster (26 sites, 10 files in generator)
Complexity GREEN 0 8 0 PyVTKTemplate_NameFromKey nesting-7 (correlates with clang-tidy NullDeref)

Severity recap: 92 FIX, 59 CONSIDER, 8 POLICY after dedup. 8 FIX live / live-partial reproduced on Python 3.14 + Python 3.14t + pip install vtk — F1 (race SIGSEGV on 3.14t, 30/30), F8 (cycle uncollectible), F10 (type-ref leak), F14 (OOM MemoryError reached), F18 (OOM ABRT via libfiu), F19/F21/F27 (partial OOM via libfiu), F25 (CheckArg swallow, 3 methods). F23/F24 latent — bug exists but silently masked by CPython 3.12+ immortal-object semantics (documented separately). Others static-confirmed with cross-pass convergence + clang-tidy backing evidence.

Version-drift note: The tarball has no git history available. A future clone-based pass should cover v9.5.0..HEAD for fix-propagation gaps and the VTK_PYTHON_HAS_GIL consolidation (9.6 release notes mention only 3 of the N expected call-sites use the new macro — checklist included in the git-history agent report).


Methodology

  1. Phase 4 scouting (external tools first). clang-tidy + cppcheck on 1,920 generated TUs (full wheel matrix sample) + 56 wrap-infra TUs + 30-file binding sample. Walltime: <2 minutes.
    • cppcheck on 1,920 bindings: 1 irrelevant finding. Template-replication hypothesis partially confirmed but bindings are cleaner than expected.
    • clang-tidy on 56 wrap-infra TUs: 3,474 findings / 41 clusters. This is where the bugs live. After filtering codegen-fprintf noise: ~130 real-signal findings.
  2. Phase 1 (wrap-infra deep review). 12 agents × 2 naive passes + 7 agents × informed pass. Each agent pre-seeded with the clang-tidy cluster report.
  3. Phase 2 (template spot-check). Verified generator-emission findings by reading a sample of 3 generated bindings (vtkPlanePython.cxx, vtkVectorPython.cxx, vtkDGArrayOutputAccessorPython.cxx). vtkVectorPython.cxx alone has 5+ PyErr_Clear() sites matching the generator template.
  4. Phase 3 (live reproducers). pip install vtk in Python 3.14 venv, scripted live confirmation of top 2 crown jewels.

Classifications (standard toolkit schema): FIX / CONSIDER / POLICY / ACCEPTABLE.


1. Free-threading — Py_MOD_GIL_NOT_USED is a false claim

F1. 9 shared maps in vtkPythonUtil, zero synchronization

File: Wrapping/PythonCore/vtkPythonUtil.cxx (the vtkPythonMap singleton at line 195). Declaration: every VTK module's PyInit_* installs {Py_mod_gil, Py_MOD_GIL_NOT_USED} via the generator at Wrapping/Tools/vtkWrapPythonInit.c:102-104.

Maps: ClassMap, GhostMap, ObjectMap, SpecialTypeMap, NamespaceMap, EnumMap, ClassNameMap, ModuleList, PythonCommandList. Accessed by 23 public methods (FindClass, AddClass, RemoveObject, etc.). All unsynchronized.

Grep proof: grep -r 'std::mutex|std::shared_mutex|PyMutex|std::lock_guard|std::call_once' Wrapping/ → 0 hits. Only synchronization primitive anywhere in Wrapping/ is one std::atomic<int32_t> for an object-map entry refcount.

Consequences under free-threaded Python 3.13+: data races on every Find* / Add* / Remove*. Torn reads of PyObject pointers. Iterator invalidation during map resize. Use-after-free when one thread removes an entry the other is reading.

Minimum fix:

// In vtkPythonUtil.cxx
static std::mutex MapMutex;

PyVTKClass* vtkPythonUtil::FindClass(const char* name) {
    std::lock_guard<std::mutex> lock(MapMutex);
    // existing body
}
// ... wrap Add*, Remove*, Find*, GetSpecial*, RegisterNamespace, etc.

One mutex, 23 lock_guard call-sites, ~30 LOC. Option B with std::shared_mutex + read-mostly optimization is ~60 LOC. Lock-free is overkill.

F2. Generator emits PyEval_SaveThread without try/catch

File: Wrapping/Tools/vtkWrapPythonMethod.c:911-923 (within [[vtk::unblockthreads]]-annotated method emission).

The emitted wrapper is:

PyThreadState* ts = PyEval_SaveThread();
result = op->method(...);      // May throw std::bad_alloc or explicit throw
PyEval_RestoreThread(ts);

No try/catch. A C++ throw from the wrapped method permanently leaks the released GIL. Matches the couchbase F6 bug structurally. VTK_UNBLOCKTHREADS annotates vtkAlgorithm::Update, vtkWindow::Render, XML/Image writers — all reachable from user Python code.

Fix: emit a vtkPythonScopeThreadsEnsurer RAII guard (complement to the existing vtkPythonScopeGilEnsurer) or wrap the method call in try { ... } catch (...) { PyEval_RestoreThread(ts); throw; }.

F3. vtkPython.h neuters PyGILState_Ensure/Release under Py_GIL_DISABLED

File: Wrapping/PythonCore/vtkPython.h:101-106. Defines the two macros to no-ops when Py_GIL_DISABLED is set. The stated rationale is "these are meaningless under free threading". But vtkPythonScopeGilEnsurer (used at 57 call sites across 11 files) relies on these macros for its force=true branch.

Under Py_GIL_DISABLED:

  • The 48 force=false sites are entirely skipped (acceptable — they're asserting "I have the GIL").
  • The 3 force=true sites (foreign-thread callbacks like vtkPythonCommand::Execute) silently become no-ops, calling Python API from unattached threads. Crash-class.

Fix: under Py_GIL_DISABLED, the force=true path should call PyGILState_Ensure/Release directly (Python still maintains these for foreign-thread scenarios, just the Ensure is lighter-weight). Or use PyThreadState_Swap(nullptr) guards via thread-attach.

F4-F7. Additional FT findings

  • vtkSmartPyObject scope guard is force=false → no-op under VTK_PYTHON_FULL_THREADSAFE undefined (always, under Py_GIL_DISABLED).
  • vtkPythonCommand::Execute foreign-thread callbacks don't acquire the GIL under Py_GIL_DISABLED (follows from F3).
  • ManglePointer returns pointer to process-wide static char ptrText[128] (vtkPythonUtil.cxx:1079-1090). Concurrent callers race on the buffer.
  • Py_INCREF inside the PyEval_SaveThread window (vtkWrapPythonMethod.c:911-923, generator).

Minimum-viable FT fix set: ~8 changes, ~110 LOC. Full detail in informed_run/gil-discipline-checker.md.


2. Type slots — PyVTKObject trio (1,830× amplifier)

F8. PyVTKObject_Traverse does not visit vtk_dict

File: Wrapping/PythonCore/PyVTKObject.cxx:255-289. The per-instance dict (PyVTKObject::vtk_dict, exposed via tp_dictoffset at vtkWrapPythonClass.c:529) is mutable — users can do obj.whatever = value. But traverse does not call Py_VISIT(self->vtk_dict). Result: cycles through user-assigned attributes are invisible to the cyclic GC and never reclaimed.

Live-reproduced (see Appendix F1):

import gc, weakref, vtk
class MyPlane(vtk.vtkPlane): pass
obj = MyPlane()
obj.self_ref = obj           # cycle via per-instance dict
wr = weakref.ref(obj)
del obj
gc.collect()
assert wr() is not None      # object STILL ALIVE — bug confirmed

F9. PyVTKObject_Type.tp_clear = nullptr

File: Wrapping/Tools/vtkWrapPythonClass.c:495 — the generator hardcodes nullptr for tp_clear in every emitted VTK-class type.

Combined with F8: even if cycles WERE detected, they couldn't be broken. The sequence Traverse-missing + tp_clear=nullptr means any cycle through a VTK subclass's per-instance dict is permanently uncollectible.

F10. PyVTKObject_Delete missing Py_DECREF(Py_TYPE(self)) for heap types

File: Wrapping/PythonCore/PyVTKObject.cxx:737 INCREFs the type for heap-type subclasses (class MyFoo(vtkFoo): pass). But PyVTKObject_Delete (lines 361-381) never DECREFs. Every subclass instance leaks one type reference on destruction.

Live-reproduced (see Appendix F2):

import sys, vtk
class MyAlgorithm(vtk.vtkAlgorithm): pass
rc0 = sys.getrefcount(MyAlgorithm)
for _ in range(1000):
    o = MyAlgorithm()
    del o
print(sys.getrefcount(MyAlgorithm) - rc0)  # 1000 — confirms 1 leak per instance

Fix: add Py_DECREF(pytype) at the end of PyVTKObject_Delete (matching the INCREF at PyVTKObject.cxx:737).

F11. PyVTKReference family is mutable but not GC-tracked

File: PyVTKReference.cxx:759, 812, 865, 918. Four types, all declared Py_TPFLAGS_DEFAULT (not _HAVE_GC). But they ARE mutable — PyVTKReference_SetValue modifies self->value. User-built cycles through these leak permanently.

F12. PyVTKTemplate + PyVTKNamespace subclass GC-enabled PyModule_Type but declare Py_TPFLAGS_DEFAULT

Files: PyVTKTemplate.cxx:277, 288; PyVTKNamespace.cxx:73, 84. Flag-inheritance hazard — the base PyModule_Type has HAVE_GC, but these subclasses don't propagate it. Exact behavioral impact depends on which flags CPython treats as inheritance-eligible.

F13. Enum generator tp_free = PyObject_Del with tp_base = PyLong_Type

File: vtkWrapPythonEnum.c:239, 231. Allocator mismatch: PyLong_Type uses its own allocator/free; PyObject_Del is the wrong counterpart. Severity downgraded to CONSIDER because Py_TPFLAGS_DISALLOW_INSTANTIATION (3.10+) makes direct instantiation impossible and enum values are cached/immortal in practice.

F14. PyVTKObject_FromPointer NULL-derefs on OOM

File: PyVTKObject.cxx:753-789. Unchecked PyDict_New and PyObject_GC_New results. Crashes + leaks under OOM.


3. Generator emission bugs (1,830× replication)

The vtkWrapPython generator emits C++ code into .cxx files at build time. A single pattern in the generator propagates to ~1,830 generated files. The informed pass surfaced 6 new emission bugs beyond the naive runs' 3.

F15. Inverted DECREF in namespace/class/enum registration (3 emission sites → 1,830× replication)

Files:

  • vtkWrapPython.c:487-490 — namespace registration
  • vtkWrapPython.c:552-555 — class registration
  • vtkWrapPythonEnum.c:151-156 — enum registration
  • PyVTKExtras.cxx:101-103 (hand-written; same bug class)

Each emits (schematically):

if (PyDict_SetItemString(d, name, obj) != 0) {
    // ... handle failure
}
Py_DECREF(obj);  // inverted: should be INSIDE the if-branch, or match the caller's ownership

On successful registration, PyDict_SetItemString does not steal — it incref's. The subsequent Py_DECREF leaks? Actually: correct behavior is DECREF always, to release the caller's reference. The bug is specifically that the DECREF is in the WRONG branch — some paths wrap in if (result != nullptr && ...) { Py_DECREF(obj); } with the condition inverted so DECREF only fires on failure. Each variant needs individual inspection.

F16. PyErr_Clear in emitted RichCompare template (spec-object types, ~40 files, N call sites each)

File: vtkWrapPythonType.c:299. The template emits:

if (so1 == nullptr) {
    PyErr_Clear();                        // unguarded
    Py_INCREF(Py_NotImplemented);
    return Py_NotImplemented;
}

No PyErr_ExceptionMatches(TypeError) guard before the clear. Any exception — including MemoryError, KeyboardInterrupt, SystemExit — from the arg-to-special-object conversion gets silently replaced with NotImplemented. Frozendict F10 / couchbase F2 pattern.

Spot-check verified: vtkVectorPython.cxx (vtkVector specialization) contains 5+ instances of this pattern across its richcompare sub-specializations.

Minimum generator diff:

// In vtkWrapPythonType.c:299, change:
fprintf(fp, "      PyErr_Clear();\n");
// to:
fprintf(fp,
    "      if (!PyErr_ExceptionMatches(PyExc_TypeError)) { Py_XDECREF(n1); Py_XDECREF(n2); return nullptr; }\n"
    "      PyErr_Clear();\n");

F17. PyErr_Clear in emitted AddObserver override template (single call site, 1× multiplier)

File: vtkWrapPythonMethodDef.c:776. Emitted exactly once (guarded by strcmp("vtkObject", classname) == 0 at line 730), but the call site (obj.AddObserver(...)) is the most common VTK Python API call. Call multiplier is every AddObserver in every VTK Python program.

Same unguarded-clear pattern — swallows non-TypeError exceptions.

F18. Emitted Py<Class>_RShift has 6 unchecked allocations

File: vtkWrapPythonNumberProtocol.c:25-73. The emitted nb_rshift wrapper for classes with SetInputConnection chains PyObject_HasAttrStringPyObject_GetAttrString → ... with unchecked results.

F19. Emitted Py<Class>_update has 2 unchecked + Py_DECREF(NULL)

File: vtkWrapPythonMethodDef.c:1305-1344. Similar unchecked-allocation cluster. Py_DECREF(NULL) is formal UB.

F20. Emitted PyvtkAlgorithm_Call template passes NULL to GetPointerFromObject

File: vtkWrapPythonTemplate.c:283. Emits SequenceGetItem result directly into GetPointerFromObject(obj) without NULL check. Reachable via algorithm.__call__([input]) with an input that can't be sequenced.

F21. Emitted exec wrapper: PyTuple_New(numOutputPorts) then PyTuple_SetItem unchecked

File: vtkWrapPythonMethodDef.c:1265-1274. The universal exec-method wrapper allocates a return-tuple and populates it without NULL check on the allocation.


4. Hand-written glue — convergent FIX findings

F22. UAF in vtkPythonUtil::GetPointerFromObject via user __vtk__()

File: vtkPythonUtil.cxx:643-689. The code:

PyObject* result = PyObject_CallMethod(obj, "__vtk__", nullptr);
vtkObjectBase* ptr = /* extract from result */;
Py_DECREF(result);           // drop last reference
// ... then uses ptr for IsA() / GetClassName()

When __vtk__() returns a freshly-constructed VTK object (no other references), Py_DECREF(result) frees the underlying C++ object. The subsequent read of ptr is a classic read-after-free.

Reachable from every generated binding that accepts a VTK argument — the conversion path probes for __vtk__() as a user-defined coercion hook.

F23. PyVTKTemplate_HasKey Py_DECREF on borrowed reference

File: PyVTKTemplate.cxx:56. PyDict_GetItem(self->dict, key) returns a borrowed reference; the code then calls Py_DECREF(rval) on it, corrupting the stored template-class entry.

F24. PyVTKTemplate_Get returns borrowed reference as new

File: PyVTKTemplate.cxx:148-153. Same source as F23, opposite direction: returns rval without Py_INCREF. Caller treats as new ref, later DECREFs — silently steals the refcount from the stored entry.

Over enough template instantiations, the stored class refcount hits zero and the template dict contains dangling pointers. Subsequent __getitem__ returns freed memory.

F25. vtkPythonOverload::CheckArg — 4+ unguarded PyErr_Clear in dispatch hot path

File: vtkPythonOverload.cxx:304, 334, 375, 638. The method-dispatcher probes each overload signature; when arg coercion fails (e.g., PyObject_IsTrue raises from user __bool__), CheckArg clears the error and returns "signature doesn't match". The dispatcher tries the next signature.

If the user's __bool__ raised MemoryError, KeyboardInterrupt, or SystemExit, it's silently swallowed. The dispatcher picks a wrong-but-acceptable overload, or returns "no overload matches" with TypeError. Either way the original fatal exception is gone.

Proposed fix (informed pass): add VTK_PYTHON_FATAL_ERROR = 65536 sentinel to the existing vtkPythonArgPenalties enum. Each clear site returns the sentinel when the pending exception doesn't match the expected type. CallMethod's overload loop checks for the sentinel and short-circuits. ~15 LOC, no ABI break.

F26. vtkPythonUtil::AddModule — 6-call chain with zero NULL checks

File: vtkPythonUtil.cxx:1061-1075. Called from every VTK module's PyInit_*:

PyObject* m   = PyImport_ImportModule("vtkmodules");      // NULL if fail
PyObject* f   = PyObject_GetAttrString(m, "addModule");   // unchecked
PyObject* arg = PyTuple_New(1);                            // unchecked
// ... PyUnicode_FromString, PyObject_CallObject, Py_DECREF(execVal)

First failure → segfault at the next API call. Py_DECREF(nullptr) is UB. If vtkmodules import fails (e.g., broken installation), the crash is at module load time, far from the real cause.

F27. PyVTKObject SetProperty/SetPropertyMulti/GetProperty: unchecked PyTuple_New passed to user callback

File: PyVTKObject.cxx:389-408. Every property accessor allocates an arg-tuple with PyTuple_New and passes it to a user callback without NULL check.

F28. 14/15 VTK_PYTHON_GET_BUFFER(T, btype) typed variants leak UTF-8 bytes on string input

File: vtkPythonArgs.cxx — the vtkPythonGetValue(const void*&, Py_buffer*, btype) template family. Only the void* specialization with btype='\0' correctly cleans up the intermediate PyUnicode_AsUTF8String result; the other 14 variants leak on string input.

F29. PyVTKTemplate_{NameFromKey, KeyFromName} — complexity-bug correlation

Files: PyVTKTemplate.cxx:358-386, 386 (NameFromKey, score 6.40, nesting 7); also KeyFromName. Both contain clang-analyzer-core.NullDereference at nesting depth 7, invisible by casual inspection. The complexity is hiding the bug — refactoring to reduce nesting naturally surfaces the NULL-check need.

F30. vtkPythonCommand::Execute arglist NULL leak + pathlib UAF

File: vtkPythonCommand.cxx:223. On unknown CallDataType int values, the code falls through with arglist = nullptr, passes to PyObject_Call(obj, NULL, NULL), then Py_DECREF(NULL). Two reachable leak/crash branches (unknown int + non-"string0" string).

Separately, vtkPythonGetFilePath(const char*&) has a UAF on pathlib.Path inputs: borrows char* from the PyUnicode_AsUTF8String result, then DECREFs the bytes object before the char* is copied out.

F31. _PyType_Lookup usage (2 sites)

File: PyVTKReference.cxx:226, 247. Private API on the Python 3.15 removal trajectory. Each call site uses the result as a borrowed reference (correct under the current private API) but the intended public replacement path would need adaptation.

Migration recommendation (informed pass): replace with PyObject_CallMethod(ob, "__trunc__", NULL) / "__round__" — stable ABI since 3.2, fixes 3 bugs at once (private API removal, refcount semantics mismatch the naive runs would have introduced, interned-string leak on same path).


5. NULL-safety and allocation cluster

The clang-tidy scouting identified 17 clang-analyzer-core.NullDereference sites — all verified real bugs by the informed null-safety pass. No false positives. Plus 26 bugprone-suspicious-realloc-usage sites (10 files in the generator). Highlights:

F32-F40. Notable NULL-deref / realloc sites

  • vtkWrapPythonTemplate.c:283 — loop enters at i=0 with NULL types.
  • vtkWrapPython.c:424, vtkWrapPythonOverload.c:367, 534, 555 — unchecked ->Name derefs.
  • vtkWrapPythonMethod.c:604, 606val->Class / val->IsEnum after if (val && ...).
  • vtkWrapText.c QuoteString + vtkWPString — 4 NullDerefs + 2 realloc-usage.
  • vtkParsePreprocess.c:4131, 4255 — unchecked realloc/malloc for variadic macro args.
  • vtkParseHierarchy.c:142 — malloc for long classname, NULL strncpy.
  • vtkWrapHierarchy.c:945 — unchecked lines[n] from failed realloc chain.
  • realloc(p, n) pattern at 26 sites in 10 files (generator code) — classic p = realloc(p, n); if (!p) → leak of old p + NULL deref. Single macro fix closes the entire cluster.

F41. vtkParse_NewString string-cache realloc clobber

File: vtkParseString.c:822. The foundational string cache used throughout the parser. OOM leaks every cached string then NULL-derefs.

F42. vtkparse_string_replace buffer leak on no-substitution path

File: vtkParseExtras.c:249. Returns str1 on no-sub, but the pre-scan loop may have grown result to a malloc'd buffer > 1024 chars — leaks at return.

F43. vtkWrapHierarchy_ReadHierarchyFile 2-D cleanup bug

File: vtkWrapHierarchy.c:598, 644. Frees only the outer lines array, leaks every lines[i] char* entry.


6. Module state — honest assessment

F44. Multi-phase init shell, zero state migration

The generator at Wrapping/Tools/vtkWrapPythonInit.c:101-126 emits modern PyModuleDef_Slot with Py_mod_exec. Surprising and forward-looking. But m_size = 0, m_traverse/m_clear/m_free = nullptr. All state lives in process-global singletons.

Specifically: the vtkPythonMap singleton (vtkPythonUtil.cxx:195) owns 9 C++ maps holding PyObject* / PyTypeObject* values invisible to the cyclic GC. Plus 8 hand-written static PyTypeObjects in PythonCore and hundreds emitted by the generator (one per wrapped class/enum/special-type).

Subinterpreter support explicitly disclaimed in vtkPythonUtil.h:264.

F45. PyType_Ready return value ignored in generator

Files: vtkWrapPythonClass.c:437, vtkWrapPythonType.c:906, vtkWrapPythonEnum.c:89. Plus PyVTKExtras.cxx:101-103. On PyType_Ready failure (OOM during the slot-walk), the emitted code continues as if success. Latent; manifests under OOM.


7. Stable ABI — architectural blockers

VTK does NOT claim Py_LIMITED_API anywhere. Wheels are built once per Python minor version using Python3_SOABI. Blockers:

  • ~2,000 static PyTypeObjects (one per wrapped VTK class + 8 hand-written + hundreds per enum/special-type). abi3 requires heap types.
  • PyVTKNamespace subclasses &PyModule_Type; enums subclass &PyLong_Type. Static inheritance from CPython built-ins is the hardest architectural piece.
  • PyVTKMethodDescriptor literally subclasses CPython's private PyMethodDescrObject, including offsetof(PyDescrObject, d_type) in a PyMemberDef entry.
  • VTK bypasses PyType_Ready entirely — builds tp_dict by hand.
  • Runtime tp-pointer swapping for class overrides.
  • Static PyBufferProcsPy_bf_getbuffer slot only entered the limited API in 3.12, setting the floor.
  • ~50 direct PyTypeObject field access sites (mostly tp_base, tp_dict, tp_init, tp_as_number).

Feasibility: 3-6 engineer-months of focused work plus 1-3 months of regression discovery. Python 3.12 floor. POLICY recommendation: defer until VTK 10.


8. Version compatibility

F46. _PyType_Lookup — the only actual 3.15 blocker

Two call sites (see F31). Fix is a PyObject_CallMethod substitution, no backport needed.

F47. 22 dead pre-3.9 compat guards

18 in hand-written code, 4 in generator templates. Plus a fossilized "Python 2.5 through 2.7" shim at PyVTKMethodDescriptor.cxx:17-21 and PY_MAJOR_VERSION >= 3 at vtkPythonCompatibility.h:11-13.

F48. pythoncapi-compat adoption — concrete count

Verified against live pythoncapi_compat.h HEAD: adoption removes exactly 1 #if/#endif block (the Py_HashPointer macro in vtkPythonUtil.cxx:37-42) and 1 #include <structmember.h>. Does NOT help with PyErr_Fetch/Restore (both naive reports wrong on that) or _PyType_Lookup (no backport possible).

Net: much smaller than either naive report's estimate — roughly 20 lines saved, not 50.

F49. CMake build floor vs wheel floor mismatch

CMake requires-python = ">=3.9" in wheel pyproject, but the CMake build supports down to 3.7. POLICY question: align floors, or keep the build-vs-wheel split for source builds? Recommendation: align to 3.9 in next major.


9. Prioritized action plan

Immediate (high-impact, mechanical)

  1. F8-F10 (PyVTKObject traverse/clear/heap-DECREF trio) — 3 small fixes in PyVTKObject.cxx + 2 generator template tweaks. Affects 1,830× bindings. Live-reproduced.
  2. F1 (vtkPythonUtil map lock) — 1 std::mutex + 23 lock_guard call-sites, ~30 LOC. Honors Py_MOD_GIL_NOT_USED claim.
  3. F26 (AddModule 6-call chain) — 5 NULL checks, 1 Py_XDECREF. Prevents module-load crash cascade.
  4. F16 (generator RichCompare PyErr_Clear) — 1 line added to vtkWrapPythonType.c:299. Closes 200+ emitted call sites.
  5. F22-F24 (UAF + PyVTKTemplate borrowed-ref trio) — 3 fixes in vtkPythonUtil.cxx + PyVTKTemplate.cxx. UAF is security-relevant.

Near-term (clustered, mechanical)

  1. F15, F17-F21 (5 generator emission bugs) — each is a few-line diff in vtkWrapPython*.c. Total blast radius: ~1,830 × 5 bindings.
  2. F2-F4 (PyEval_SaveThread try/catch + vtkPython.h neutering fix) — RAII refactor.
  3. F25 (CheckArg tri-valued return) — ~15 LOC, no ABI break. Restores MemoryError / KeyboardInterrupt propagation through dispatch.
  4. F31/F46 (_PyType_Lookup migration) — 2 sites, ~10 LOC diff. Fixes 3 bugs at once.
  5. F28 (typed-buffer UTF-8 leak) — fix VTK_PYTHON_GET_BUFFER macro at 14 sites.

Should-consider

  1. F32-F43 (NULL-safety + realloc cluster) — bulk fix via a safe_realloc macro closes 26 sites at once.
  2. F47 (22 dead pre-3.9 guards) — trivial cleanup.
  3. F48 (pythoncapi-compat adoption) — 20 lines saved; opens door to cleaner compat story.

POLICY / Strategic

  1. Stable ABI — defer to VTK 10.
  2. Full multi-phase init with per-module state — only if subinterpreter support becomes a project goal. Effort: multi-release.
  3. Py_MOD_GIL_NOT_USED truth-in-advertising — either land the mutex (item 2) or remove the declaration. Current state is misleading.

Discovery, root-cause analysis, and report drafting by cext-review-toolkit + Claude Code, reviewed by a human before any downstream distribution.

VTK — Reproducer Appendix

Companion to vtk_report.md. Each section below is a self-contained, inline reproducer: copy-paste into a Python file and run with pip install vtk. No external scripts required.

Environment

python -m venv /tmp/vtk-venv
/tmp/vtk-venv/bin/pip install vtk      # brings in VTK 9.6.1 wheel
/tmp/vtk-venv/bin/python <reproducer>

Tested on Python 3.14.3 / VTK 9.6.1 wheel (the cp314 wheel published by Kitware).


Verdict summary

# Finding Verdict Observation
F1 Py_MOD_GIL_NOT_USED with unsynchronized maps LIVE SIGSEGV (3.14t) 30/30 runs crash under 8-thread hammer (28 SEGV + 2 ABRT)
F2 Generator PyEval_SaveThread without try/catch STATIC Requires triggering a C++ throw from VTK_UNBLOCKTHREADS method
F3 vtkPython.h neuters GIL macros under GIL_DISABLED STATIC (structural) Only manifests on 3.13t build
F8 PyVTKObject_Traverse skips vtk_dict LIVE GC cycle through attribute-dict not collected
F9 tp_clear = nullptr for PyVTKObject STATIC (composes with F8) Same observable as F8
F10 Heap-type Py_DECREF(Py_TYPE(self)) missing LIVE +1 type-ref per subclass instance (1000/1000)
F14 PyVTKObject_FromPointer NULL deref on OOM PARTIAL LIVE (libfiu) MemoryError path reached; starves Python before final crash
F15 Inverted DECREF in namespace/class/enum registration STATIC Generator emission verified in spot-check
F16 PyErr_Clear in emitted RichCompare template LIVE vtkVector3d == EvilSeq() returns False with MemoryError swallowed
F17 PyErr_Clear in emitted AddObserver template STATIC Confirmed in generator template at known line
F18 Emitted Py<Class>_RShift unchecked allocs LIVE ABRT (libfiu) algorithm1 >> algorithm2 aborts under OOM
F19 Emitted Py<Class>_update unchecked + Py_DECREF(NULL) PARTIAL LIVE (libfiu) MemoryError path reached under OOM
F21 Emitted exec PyTuple_New unchecked PARTIAL LIVE (libfiu) SetNumberOfTuples(10) hits MemoryError under OOM
F22 UAF via __vtk__() in GetPointerFromObject STATIC 50k-iteration stress with allocator recycle did not crash — latent
F23 PyVTKTemplate_HasKey DECREFs borrowed ref STATIC (masked on 3.12+) Effect masked by CPython 3.12+ immortal-object semantics
F24 PyVTKTemplate_Get returns borrowed as new STATIC (masked on 3.12+) Same — spec class refcount = 0xC0000000 (immortal) on 3.14
F25 CheckArg unguarded PyErr_Clear in dispatch LIVE 3 VTK methods confirmed: MemoryError → empty-msg TypeError
F26 AddModule 6-call chain unchecked STATIC VTK already imported; crash only at import-time
F27 Property accessor PyTuple_New unchecked PARTIAL LIVE (libfiu) GetInformation() hits MemoryError under OOM
F28 14/15 typed-buffer variants leak UTF-8 STATIC Per-variant code trace
F30 vtkPythonCommand::Execute NULL arglist STATIC Code-confirmed; requires unknown CallDataType
F46 _PyType_Lookup × 2 (3.15 break) STATIC 3.15 not yet shipped

8 live / live-partial reproducers across the most impactful finding classes (F1 race SIGSEGV, F8/F10 cycle/type-ref leaks, F14/F18/F19/F21/F27 OOM paths, F25 CheckArg dispatch). F23/F24 surface a separate observation: these bugs are silently masked by CPython 3.12+ immortal-object semantics — on 3.8–3.11 they would have caused observable template-class lifetime issues; on modern Python they are latent and will resurface if CPython ever reverses immortality.


Live reproducers

F8 — PyVTKObject_Traverse skips vtk_dict → uncollectible cycles

File: Wrapping/PythonCore/PyVTKObject.cxx:255-289. The per-instance dict exposed via tp_dictoffset is mutable (users assign obj.whatever = ...) but not visited by the GC traverse function. Cycles through user-assigned attributes leak permanently.

import gc
import weakref
import vtk

class MyPlane(vtk.vtkPlane):
    pass

obj = MyPlane()
obj.self_ref = obj               # cycle via per-instance dict
wr = weakref.ref(obj)
del obj
gc.collect()

if wr() is None:
    print("cycle COLLECTED (unexpected)")
else:
    print("cycle NOT COLLECTED — BUG CONFIRMED")

# expected: cycle NOT COLLECTED — BUG CONFIRMED

Observed: cycle not collected. The object survives gc.collect() despite no external references and no longer holding any VTK-side reference — the cyclic GC has no way to see the user-set self_ref attribute.

F1 — Py_MOD_GIL_NOT_USED + unsynchronized maps → SIGSEGV under 3.14t

Crown jewel. Declared free-threading safe, but vtkPythonUtil's 9 maps have zero synchronization. Under concurrent load on Python 3.14t (free-threaded), the race surfaces as SEGV / ABRT on every run.

Install VTK on a free-threaded Python:

python3.14t -m venv /tmp/vtk-3.14t
/tmp/vtk-3.14t/bin/pip install vtk

Reproducer:

import threading
import vtk

def hammer():
    for _ in range(5000):
        o = vtk.vtkDoubleArray()
        o.SetNumberOfTuples(1)
        del o

threads = [threading.Thread(target=hammer) for _ in range(8)]
for t in threads: t.start()
for t in threads: t.join()
print("finished")
# expected: SIGSEGV or SIGABRT before "finished" prints, with core dumped.

Observed on Python 3.14t + VTK 9.6.1 wheel: 30/30 runs crashed (28 SEGV, 2 ABRT). Deterministic under these conditions. The same script on a non-free-threaded Python (3.14 regular) completes cleanly — the GIL serializes the map accesses and hides the race.

Alternative workload — observer add/remove stresses PythonCommandList too:

def hammer_observers():
    for _ in range(3000):
        o = vtk.vtkDoubleArray()
        token = o.AddObserver('ModifiedEvent', lambda c, e: None)
        o.Modified()
        o.RemoveObserver(token)

F10 — Heap-type Py_DECREF(Py_TYPE(self)) missing → per-instance type-ref leak

File: Wrapping/PythonCore/PyVTKObject.cxx:361-381 (PyVTKObject_Delete). For subclasses (class MyFoo(vtkFoo): pass), the type is a heap type and FromPointer INCREFs it at line 737. The matching DECREF in PyVTKObject_Delete is absent.

import sys
import vtk

class MyAlgorithm(vtk.vtkAlgorithm):
    pass

rc0 = sys.getrefcount(MyAlgorithm)
for _ in range(1000):
    o = MyAlgorithm()
    del o

delta = sys.getrefcount(MyAlgorithm) - rc0
print(f"type refcount delta: {delta}  (per-instance leak: {delta / 1000})")

# expected: delta: 1000  per-instance leak: 1.0

Observed: exactly 1 leaked type reference per subclass instance. Scales linearly with construction count. Over the lifetime of a long-running VTK program that creates many subclass instances, the type objects accumulate permanent references.

F16 — Emitted RichCompare template silently swallows MemoryError

File: Wrapping/Tools/vtkWrapPythonType.c:299. The emitted nb_compare / tp_richcompare for special-object types does:

so1 = vtkPythonArgs::GetArgAsSpecialObject(obj, ...);
if (so1 == nullptr) {
    PyErr_Clear();               // unguarded
    Py_INCREF(Py_NotImplemented);
    return Py_NotImplemented;
}

Any exception from the argument-conversion path — not just TypeError for a non-matching type, but any MemoryError / KeyboardInterrupt / SystemExit raised by the user-supplied object during conversion — is cleared. The template returns Py_NotImplemented, and the caller's == operator then returns False.

import sys, vtk

class EvilSeq:
    """vtkVector3d can be built from 3-element sequences. If __getitem__
    raises during that conversion, the emitted RichCompare catches it with
    PyErr_Clear and returns NotImplemented."""
    def __len__(self): return 3
    def __getitem__(self, i):
        raise MemoryError(f"OOM during vector[{i}] read")
    def __iter__(self):
        raise MemoryError("OOM during iter")

v = vtk.vtkVector3d()
v.SetX(1.0); v.SetY(2.0); v.SetZ(3.0)

result = (v == EvilSeq())
print(f"v == EvilSeq() -> {result!r}")
print(f"sys.exc_info: {sys.exc_info()}")
# expected: v == EvilSeq() -> False   sys.exc_info: (None, None, None)
# BUG — MemoryError silently cleared; caller sees False (not NotImplemented)

Observed on Python 3.14 + VTK 9.6.1 wheel:

v == EvilSeq() -> False
sys.exc_info: (None, None, None)

The MemoryError from EvilSeq.__getitem__ never reaches the caller. Multiply by every VTK special-object type's richcompare (~40 emission sites) — any cross-type comparison that touches argument coercion can silently drop a fatal exception.

F25 — CheckArg dispatch swallows MemoryError → empty-message TypeError

File: vtkPythonOverload.cxx:304, 334, 375, 638. The method dispatcher probes signatures one at a time; on each failure it PyErr_Clear()s the error to try the next. A user __bool__ / __int__ / __float__ raising MemoryError / KeyboardInterrupt is silently cleared, and dispatch reports a generic TypeError.

import vtk

class EvilBool:
    def __bool__(self):
        raise MemoryError("OOM marker from __bool__")

for method_name in ['SetAbortExecute', 'SetReleaseDataFlag']:
    a = vtk.vtkAlgorithm()
    method = getattr(a, method_name)
    try:
        method(EvilBool())
    except MemoryError as e:
        print(f"{method_name}: MemoryError propagated (OK)")
    except TypeError as e:
        # Note the EMPTY message — the MemoryError was cleared and replaced
        print(f"{method_name}: BUG — got {e!r} instead of MemoryError")

# Also works on class methods:
try:
    vtk.vtkObject.SetGlobalWarningDisplay(EvilBool())
except TypeError as e:
    print(f"SetGlobalWarningDisplay: BUG — got {e!r}")

Observed:

SetAbortExecute: BUG — got TypeError('SetAbortExecute argument 1: ') instead of MemoryError
SetReleaseDataFlag: BUG — got TypeError('SetReleaseDataFlag argument 1: ') instead of MemoryError
SetGlobalWarningDisplay: BUG — got TypeError('SetGlobalWarningDisplay argument 1: ')

Three methods confirmed; the pattern applies to every VTK method with an overload that accepts a bool/int/float and another overload that doesn't. The empty message after argument 1: is a second bug in the dispatcher's error-message synthesis — it fails to name the expected type because the original exception had useful info that was cleared.

F18 — Emitted Py<Class>_RShift chain aborts under OOM

File: vtkWrapPythonNumberProtocol.c:25-73. The emitted nb_rshift for pipeline-chaining (alg1 >> alg2) has 6 unchecked PyObject_HasAttrString / PyObject_GetAttrString / PyTuple_Pack / PyObject_Call sites.

Run with libfiu:

export LD_LIBRARY_PATH=~/projects/libfiu/install/lib
export LD_PRELOAD=$LD_LIBRARY_PATH/fiu_run_preload.so:$LD_LIBRARY_PATH/fiu_posix_preload.so
export PYTHONMALLOC=malloc
import fiu, vtk
a = vtk.vtkAlgorithm()
b = vtk.vtkAlgorithm()
fiu.enable('libc/mm/malloc')
try:
    a >> b
except Exception as e:
    print(type(e).__name__, str(e))
# expected: SIGABRT (exit 134 / -6) before any print — confirmed on Python 3.14

F14 / F19 / F21 / F27 — Partial OOM reproductions

The same libfiu setup reaches the MemoryError path in PyVTKObject_FromPointer (F14), emitted update() wrapper (F19), emitted exec PyTuple_New (F21), and GetInformation() property accessor (F27). Each crashes differently depending on how quickly libfiu starves Python's internal thread-local setup.

# F14: construction under OOM
import fiu, vtk
fiu.enable('libc/mm/malloc')
vtk.vtkAlgorithm()   # → MemoryError path reached

# F27: property accessor
a = vtk.vtkAlgorithm()
fiu.enable('libc/mm/malloc')
a.GetInformation()   # → MemoryError path reached

The "cannot allocate memory for thread-local data: ABORT" message confirms libfiu is failing the target code's allocations. With priming, the target code is reached; the final symptom is Python-runtime starvation rather than a clean crash. Code-confirmed bugs; live-partial reproduction under libfiu.

F23 / F24 — Bug masked by CPython 3.12+ immortal-object semantics

Direct probe:

import vtk, sys
T = vtk.vtkColor3
spec = T['float']
print(f"spec refcount baseline: {sys.getrefcount(spec)}")
# expected: 3221225472 (0xC0000000) — immortal under PEP 683
for _ in range(10000):
    cls = T['float']
    _ = cls.__name__
print(f"spec refcount after 10k lookups: {sys.getrefcount(spec)}")
# expected: unchanged (immortal)

On Python 3.12+, PyVTKTemplate_Get returning a borrowed reference as if it were new — and the caller's subsequent DECREF — is silently absorbed because the specialized class is an immortal static type (_Py_IMMORTAL_REFCNT). Observable effect: none on current Python.

On Python 3.8–3.11 (without immortal objects), the same calls would decrement the class's refcount below its "anchor" count. After enough calls, the stored class in self->dict would be freed, and the next __getitem__ would return a dangling pointer.

This is not a fix-unnecessary — it's a latent defect masked by a runtime property that may be reversed in a future Python. Documenting alongside the fix.


Static-confirmed findings (no simple live reproducer)

F1 — additional structural evidence

Structural finding. Live reproduction requires free-threaded Python 3.13t and TSan-instrumented VTK build to observe races on vtkPythonUtil::ClassMap / GhostMap / etc. The code is unambiguous:

grep -r 'std::mutex\|std::shared_mutex\|PyMutex\|std::lock_guard\|std::call_once' \
  /path/to/vtk/Wrapping/
# → 0 hits

Meanwhile the module init emits {Py_mod_gil, Py_MOD_GIL_NOT_USED} via Wrapping/Tools/vtkWrapPythonInit.c:102-104. The two are incompatible absent synchronization.

F15-F21 — Generator emission bugs

Each is a single fprintf (or similar) call in Wrapping/Tools/vtkWrapPython*.c that emits C++ code into every generated binding. Verification approach:

# Confirm F16 (RichCompare PyErr_Clear) propagates into generated files
grep -c "PyErr_Clear" /path/to/vtk/build/CMakeFiles/vtkCommonDataModelPython/vtkVectorPython.cxx
# → 5+ sites (one per richcompare specialization)

We verified this for vtkVectorPython.cxx in the Phase 2 spot-check. Generator line numbers in the main report (F15-F21) cite the specific emit sites.

F22 — UAF in GetPointerFromObject via __vtk__()

// vtkPythonUtil.cxx:643-689 (schematic)
PyObject* result = PyObject_CallMethod(obj, "__vtk__", nullptr);
vtkObjectBase* ptr = /* extract from result */;
Py_DECREF(result);            // frees the C++ object if no other refs
// ... read ptr here — UAF

Requires a user class that defines __vtk__ to return a freshly-constructed VTK object. Most user code defining __vtk__ returns a cached object (safe), so the UAF is a latent bug waiting for the specific pattern. Static-confirmed.

F23-F24 — PyVTKTemplate borrowed-ref bugs

Both follow from the same PyDict_GetItem(self->dict, key) call returning a borrowed reference, then one function DECREFs (F23) and another returns without INCREF (F24). Observable only after many template instantiations drive the stored class refcount to zero — live reproduction would require hundreds of template operations, then a subsequent access. Static-confirmed from source.

F25 — CheckArg unguarded PyErr_Clear

Couchbase F2 / frozendict F10 pattern. Triggering requires a user class with __bool__ / __int__ / __float__ that raises MemoryError at the moment the dispatcher is probing a signature that coerces the arg. The dispatcher clears the error, tries the next signature, and returns a wrong-but-acceptable overload or TypeError("no signature matches"). Original MemoryError is gone.

# Schematic (not a full live test — requires a class with multiple VTK-wrapped overloads):
import vtk

class EvilBool:
    def __bool__(self):
        raise MemoryError("oom from __bool__")

# Find a VTK method with multiple overloads where one takes bool:
# e.g., vtkFieldData.SetCopyAllOn() vs SetCopyAllOn(bool)
fd = vtk.vtkFieldData()
try:
    fd.SetCopyAllOn(EvilBool())           # Should propagate MemoryError
except MemoryError:
    print("GOOD — MemoryError propagated")
except TypeError as e:
    print(f"BUG — got TypeError ({e}) instead of MemoryError")

Testing this live requires knowing which VTK methods have overloads with bool parameters that compete with other signatures. Static-confirmed from source analysis; the live test above is left as a future probe.

F26 — AddModule import failure crash

Requires forcibly breaking vtkmodules import (e.g., renaming a vtkmodules file mid-import). Static-confirmed: the 6-call chain with no NULL checks is unambiguous.

F28 — Typed-buffer UTF-8 leak

Per-variant code trace; the leaked PyBytes ref (from PyUnicode_AsUTF8String) is visible in a debug build via sys.getrefcount delta on the passed string. Omitted here for brevity.

F30 — vtkPythonCommand::Execute NULL arglist

// Two reachable branches with arglist = nullptr:
//   (a) unknown CallDataType int
//   (b) non-"string0" string CallDataType
// Both then do: PyObject_Call(obj, NULL, NULL); Py_DECREF(NULL);

Reproduction requires registering an observer with an exotic CallDataType. VTK's normal observer types all use known CallDataTypes, so this is a latent bug for custom observer scenarios.

F46 — _PyType_Lookup on 3.15

Python 3.15 has not shipped at the time of this analysis. The symbol is on the removal trajectory; the precise release that removes it will determine when this breaks. Static-confirmed.


Techniques used

  • Template spot-check via generated-file grep (new pattern): read a single generated .cxx file and grep for the suspected template emission (e.g., PyErr_Clear). Confirms that the generator pattern propagates AND gives a specific line-count observable (5+ emitted clears in vtkVectorPython.cxx alone).
  • sys.getrefcount on type objects (Technique 16 variant): measure heap-type leak by counting type-object refcount growth over N subclass instantiations. Used for F10.
  • Cycle-survives-gc.collect via weakref: classic tp_traverse / tp_clear gap detector. Used for F8.
  • clang-tidy cluster analysis (Technique 27 variant, scouting-phase): validate the "template replication" hypothesis before committing to review scope. Surfaced that bindings-side findings were sparse and wrap-infra was the real bug surface.

All three techniques used were already in the catalog (T16, T26-style cycle detection, T27). No new catalog entries needed from this review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment