You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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)
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:
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.
PyObject_GetOptionalAttr (3.13+, via pythoncapi-compat backport) — returns NULL without setting an error when the attr is missing.
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.
// Py_HashPointer added in 3.13 and _Py_HashPointer deprecated in 3.14.
#if PY_VERSION_HEX >= 0x030D0000
#definePY_HASHPOINTER Py_HashPointer
#else
#definePY_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_HashPointerexists 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.
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_FromSpecWithBases — blocker: 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_FromSpecWithBases — blocker: 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:
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):
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:85 — PyDict_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:101 — PyDict_DelItemString(typeobj->tp_dict, "__override__") — same pattern.
PyVTKObject.cxx:155 — pytype->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, 222 — PyDict_SetItemString(pytype->tp_dict, …) — replace with PyObject_SetAttrString or pre-populate via a __dict__ slot in PyType_Spec.
PyVTKObject.cxx:777 — pytype->tp_init != nullptr / :788 — pytype->tp_init((PyObject*)self, …) — replace with PyType_GetSlot(pytype, Py_tp_init) and call.
PyVTKObject.cxx:805 — message += 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, 64 — type->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:534 — return 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, 1262 — pytype->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:30 — PyLong_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
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:418 — PyObject *d = pytype->tp_dict; (for classes with constants/enums)
vtkWrapPythonType.c:895 — same pattern for special types
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
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, 529 — offsetof(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:750 — PyObject_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, 144 — PyObject_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
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.
Update wheel tag generation in vtkWheelPreparation.cmake / vtkWheelFinalization.cmake to emit abi3 wheel tags.
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
Architectural rewrites (wrap-infra, must be done by hand):
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.
PyVTKReference.cxx _PyType_Lookup — 2 lines → replaced by PyObject_GetAttr on the value object (see Finding 1.1). Effort: 30 minutes.
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.
PyVTKReference number-subtype hierarchy — PyVTKNumberReference_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.
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
_PyType_Lookup in PyVTKReference.cxx — no abi3 replacement, but workable alternatives exist (Finding 1.1 alternatives). Not a true hard blocker after workaround applied.
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.
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.
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
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.
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.
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.
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.
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).
#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 310voidvtkPythonUtil::AddObjectToMap(PyObject* obj, vtkObjectBase* ptr) {
((PyVTKObject*)obj)->vtk_ptr = ptr;
vtkPythonMap->ObjectMap->add(ptr, obj); // unprotected std::map insert
}
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):
importos, subprocess, sys, textwrapchild=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<0or"SEGV"instderr) 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:
importos, subprocess, sys, textwrapchild=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>classvtkPythonCommand : publicvtkCommand {
...
std::atomic<PyObject*> obj; // was: PyObject* obj
};
// 2. SetObject — atomic storevoidvtkPythonCommand::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 entryvoidvtkPythonCommand::Execute(vtkObject* ptr, unsignedlong 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 lockvtkPythonCommand::~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:
importos, subprocess, sys, textwrapchild=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
~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.cxxstatic std::mutex g_commandListMutex; // NOT PyMutex — may be held without GILvoidvtkPythonUtil::RegisterPythonCommand(vtkPythonCommand* c) {
std::lock_guard<std::mutex> lock(g_commandListMutex);
vtkPythonMap->PythonCommandList->push_back(c);
}
voidvtkPythonUtil::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
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 850unsignedlong* tmp = olist;
olist = newunsignedlong[2 * m]; // racing allocfor (unsignedlong 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).
voidPyVTKObject_AddObserver(PyObject* obj, unsignedlong id) {
Py_BEGIN_CRITICAL_SECTION(obj);
// ... existing body ...Py_END_CRITICAL_SECTION();
}
intPyVTKObject_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
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 differentSetNumberOfComponents values across threads reliably double-free.
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.
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++)
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)
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 */;
constchar* cname = ptr->GetClassName(); // read BEFORE Py_DECREFbool isTarget = ptr->IsA(className);
// or: ptr->Register(nullptr) to take a C++ ref before the DECREFPy_DECREF(result);
// ... use cname / isTarget
Reproducer (requires user code defining the hazardous pattern):
importvtkclassDefaultsToFresh:
def__vtk__(self):
returnvtk.vtkPolyData() # fresh, no other refs# Pass to any VTK method expecting a vtk.vtkObject argumentapp=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)
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:134assertnotnumpy.issubdtype(num_array.dtype, numpy.complexfloating), \
"Complex numpy arrays cannot be converted to vtk arrays."assertlen(shape) <3, "Only arrays of dimensionality 2 or lower are allowed"assertnum_array.flags.contiguous, "Only contiguous arrays are supported."
Fix: Use explicit raise:
ifnumpy.issubdtype(num_array.dtype, numpy.complexfloating):
raiseTypeError("Complex numpy arrays cannot be converted to vtk arrays.")
iflen(shape) >=3:
raiseValueError("Only arrays of dimensionality 2 or lower are allowed.")
ifnotnum_array.flags.contiguous:
raiseValueError("Only contiguous arrays are supported; use numpy.ascontiguousarray.")
Reproducer:
python3 -O -c "from vtkmodules.util import numpy_support as nsimport numpy as npr = 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).
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.
Severity: HIGH
Classification: TYPE-SLOT / REFCOUNT
Source: [F8] (cext) with [#48] architectural corroboration
Verdict: CONFIRMED live — cycle through user attribute not collected
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:255intPyVTKObject_Traverse(PyObject* o, visitproc visit, void* arg) {
// walks vtk_observers and maybe others — but NOT vtk_dict// ...return0;
}
Fix (3 lines):
intPyVTKObject_Traverse(PyObject* o, visitproc visit, void* arg) {
Py_VISIT(((PyVTKObject*)o)->vtk_dict); // add this// ... existing body ...return0;
}
Reproducer:
importgc, weakref, vtkclassMyPlane(vtk.vtkPlane): passobj=MyPlane()
obj.self_ref=obj# cycle via per-instance dictwr=weakref.ref(obj)
delobjgc.collect()
assertwr() isnotNone# STILL ALIVE — bugprint("BUG CONFIRMED"ifwr() isnotNoneelse"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:intPyVTKObject_Clear(PyObject* o) {
PyVTKObject* self = (PyVTKObject*)o;
Py_CLEAR(self->vtk_dict); // drop dict ref; GC can continuereturn0;
}
// 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.
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)staticvoidPyVTKObject_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
}
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)
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
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 exceptionPy_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).
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:
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.
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.henum 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.
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 borrowedreturn (rval != nullptr);
// PyVTKTemplate.cxx:148 (Get)
PyObject* rval = PyDict_GetItem(self->dict, key);
Py_XINCREF(rval); // add this — caller expects a new referencereturn 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_*.
voidvtkPythonUtil::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
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).
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.cxx — vtkPythonGetFilePath(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_CallvtkErrorMacro("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) returnfalse;
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)
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)
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(constvoid* ptr, constchar* 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.
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
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
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
Severity: MEDIUM (46 existing deprecation warnings become actionable)
Source: [#45] (Python layer)
Verdict: CONFIRMED live — warnings point to util/misc.py:28, not caller
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
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:classDataSet(...):
point_data=property(...)
PointData=point_data# forward for numpy_interface consumerscell_data=property(...)
CellData=cell_data
Fix (long-term — Month 2+, item 77): Publish a canonical dataset-wrapping
approach, deprecate the other.
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:#defineSAFE_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
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.
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);
}
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.
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:
#ifdefVTK_PYTHON_FULL_THREADSAFEPyThreadState*ts=PyEval_SaveThread();
#endif// ... C++ method call ...#ifdefVTK_PYTHON_FULL_THREADSAFEPyEval_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:
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 imp → importlib.util, Queue → queue,
narrow all bare excepts. ~half-day.
Recommendation: delete unless an out-of-tree user is known to depend
on it.
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.
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.
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.
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.
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
Zero — grep 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)
ObjectMap std::_Rb_tree_insert_and_rebalance SEGV — 2 scenarios.
Null-pointer dereference in libstdc++ rb-tree rebalance called from
vtkPythonObjectMap::add → PyVTKObject_FromPointer →
PyVTKObject_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.
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.
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.
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.
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.
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:
PyObject* obj; → std::atomic<PyObject*> obj; in
vtkPythonCommand.h.
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);
~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
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.
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.
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.
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.
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
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
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
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)
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.
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.
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.
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
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
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 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
Decide on the vtkPythonScopeGilEnsurer architectural choice
(M1). This keystones all other work.
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_observers — Py_BEGIN_CRITICAL_SECTION(obj) in
AddObserver and Traverse.
R6 vtk_buffer — Py_BEGIN_CRITICAL_SECTION(obj) in
AsBuffer_GetBuffer.
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).
importos, subprocess, sys, textwrapchild=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<0or"SEGV"instderror"_Rb_tree"instderr)
print(f"CONFIRMED: crash"ifcrashedelsef"NOT REPRODUCED: exit={r.returncode}")
Output: CONFIRMED: crash — AddressSanitizer: SEGV on unknown address 0x000000000000 at std::_Rb_tree_insert_and_rebalance in vtkPythonObjectMap::add → PyVTKObject_FromPointer → PyVTKObject_New. Root cause: concurrent insert into ObjectMap's unprotected std::map leaves rb-tree parent/child pointers inconsistent for rebalance walk.
importos, subprocess, sys, textwrapchild=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<0or"SEGV"instderror"heap-use-after-free"instderror"0xdd"instderr)
print(f"CONFIRMED: crash"ifcrashedelsef"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).
importos, subprocess, sys, textwrapchild=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<0or"tstate_"instderror"SEGV"instderror"Aborted"instderr)
print(f"CONFIRMED: crash"ifcrashedelsef"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
importos, subprocess, sys, textwrapchild=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<0or"SEGV"instderror"Aborted"instderr)
print(f"CONFIRMED: crash"ifcrashedelsef"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.
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.
importos, subprocess, sys, textwrapchild=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<0or"SEGV"instderror"double-free"instderr)
print(f"CONFIRMED: crash"ifcrashedelsef"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 differentSetNumberOfComponents across threads to trigger the lazy-realloc code block.
importos, subprocess, sys, textwrapchild=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<0or"SEGV"instderror"double-free"instderror"heap-"instderr)
print(f"CONFIRMED: crash"ifcrashedelsef"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)
importos, subprocess, sys, textwrapchild=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.
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.
importos, subprocess, sys, textwrapchild=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<0or"SEGV"instderror"_Rb_tree"instderr)
print(f"CONFIRMED: crash"ifcrashedelsef"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
importos, subprocess, sys, textwrapchild=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<0or"SEGV"instderror"Aborted"instderr)
print(f"CONFIRMED: crash"ifcrashedelsef"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).
importos, subprocess, sys, textwrap, timechild=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() -t0stdout=r.stdout.decode("utf-8", errors="replace")
ifr.returncode<0andelapsed>12:
print(f"CONFIRMED: deadlock/hang ({elapsed:.1f}s)")
elif"churn completed cleanly"instdout:
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)
#defineVTK_PYTHON_HAS_GIL#else/* VTK_PYTHON_FULL_THREADSAFE does not make sense without GIL */#undef VTK_PYTHON_FULL_THREADSAFE
#endif#ifndefVTK_PYTHON_HAS_GIL#undef PyGILState_Ensure
#definePyGILState_Ensure() ((PyGILState_STATE)0)
#undef PyGILState_Release
#definePyGILState_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
importos, subprocess, sys, textwrapchild=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'inr.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.
U4 — PyDict_Next iteration at 6 sites in PyVTKTemplate.cxx
Verdict: NOT-OBSERVED (templates registered serially at import)
importos, subprocess, sys, textwrapchild=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
# 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)
importos, subprocess, sys, textwrapchild=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:importsubprocessout=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"inoutor"GIL_NOT_USED"inout:
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)
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.
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.
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.
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.
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.sqrt — algs.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.
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.
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).
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 assignment — if not errflag: at line 621 is inside the except block; errflag = False is at line 641. First ValueError → UnboundLocalError.
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 typo — vtkInformationDataObjectKey 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).
Error messages in pickle_support.py swapped — 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).
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.
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
F811 — vtkDataObject re-imported within same from … import (…) block.
util/data_model.py:14
consistency, dead-code
31
F811 — vtkActor 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 untested — TestPythonAlgorithm.py has 4 tests, all return 1 (success). Any misreport of return code or exception swallowing in the Python↔C++ glue is untested.
_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.pyREFUTED-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:331sampling_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 seb — updateOrientationAxesVisibility 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.
Should Consider (CONSIDER) — 48 (47 after 1 refutation: #60)
#
Finding
File:Line
48
Two parallel class hierarchies — util/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).
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 modules — web/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.
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.pyPARTIALLY 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.
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.
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 files — numpy_interface/dataset_adapter.py has vtkDataArrayToVTKArray, numpyTovtkDataArray, VTKObjectWrapper.GetArrays, reshape_append_ones, _make_tensor_array_contiguous and arrLength/validAssociation/narray all coexisting.
Numpy-conversion name inconsistency — numpy_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 mixedAttributeError 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.py — SphericalCamera, 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().
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.
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.
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 layer — generate_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 withinnumpy_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:
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.
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.
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
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.
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.
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.
Only one real circular import — numpy_interface.algorithms ↔ numpy_interface.numpy_algorithms, with lazy in-function imports on the callback side. Understood, isolated, safe.
Low complexity density — 17 hotspots (score ≥5) out of 980 functions (1.7%). Average score 1.2–1.3. Complexity is localized, not pervasive.
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.
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)
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)
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)
Findings #66, #67: Refactor _global_per_block and VTKCompositeDataArray.__getitem__/__setitem__ into phase-separated helpers. Requires MPI test harness. (2 engineer-days)
Findings #55–56: Add smoke tests for all zero-coverage modules (one-test-per-module minimum rule — finding #96 POLICY). (2-3 engineer-days)
Findings #58, #59, #61, #63, #64: Run ruff check --fix --select=UP004,UP010,UP031,E711,B905 over the scope. (1 hour — mostly mechanical)
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)
Finding #45: Add stacklevel=2 to util/misc.py::deprecated. One-line change, makes 46 existing warnings actionable. (5 minutes)
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)
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)
Finding #105: Schedule removal of 46 deprecated algorithms.py wrappers at VTK 10.
Findings #98: Document camelCase/snake_case rule in CONTRIBUTING / coding_conventions.md.
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)
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).
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/python — Rendering, 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/).
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)
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:790 — reciprocal = 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)
importvtkmodules.util.pickle_supportaspsimportnumpyasnpbad_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:49 — raise TypeError("Could not find type " + type_string + ...). The correct variable is state["Type"].
fromvtkmodules.util.execution_modelimportselect_portsfromvtkmodules.vtkFiltersCoreimportvtkAppendFilteraf=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`:fromvtkmodules.vtkFiltersGeneralimportvtkShrinkFilterpipeline=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)
importimportlib.util, sys# Force the ValueError branch in the try/except at lines 618-623defbad_find_spec(name, *a, **kw):
raiseValueError(f'injected for {name}')
importlib.util.find_spec=bad_find_specimportvtkmodules.generate_pyiasgpsys.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)
importwarnings; warnings.simplefilter('ignore', DeprecationWarning)
fromvtkmodules.numpy_interfaceimportalgorithmsasalgsfromvtkmodules.numpy_interface.dataset_adapterimportVTKCompositeDataArray, NoneArrayempty_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)
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)
importwarningsfromvtkmodules.numpy_interfaceimportalgorithmsasalgswithwarnings.catch_warnings(record=True) asw:
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 "
fromvtkmodules.utilimportnumpy_supportasnsimportnumpyasnpns.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 "fromvtkmodules.utilimportnumpy_supportasnsimportnumpyasnpr=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.
fromunittest.mockimportMagicMockimportnumpyasnpimportvtkmodules.util.pickle_supportaspsfromvtkmodules.vtkCommonDataModelimportvtkPolyData# Force marshaling to fail (returns 0):fake=MagicMock()
fake.MarshalDataObject=MagicMock(return_value=0)
fake.UnMarshalDataObject=MagicMock(return_value=0)
ps.vtkCommunicator=faketry:
ps.serialize_VTK_data_object(vtkPolyData())
exceptRuntimeErrorase:
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)})
exceptRuntimeErrorase:
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)
fromvtkmodules.numpy_interface.dataset_adapterimportVTKCompositeDataArraya=VTKCompositeDataArray()
b=VTKCompositeDataArray()
print(f'a._Arrays is b._Arrays: {a._Arraysisb._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:deftestGetSet(self, obj, excluded_methods=[]): # mutable defaultreturnexcluded_methodscall_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).
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):classTimeFilter:
def__init__(self, idx):
self._timeindex=idxdeffilter_active(self):
ifself._timeindex: # <-- line 235 of xarray_support.pyreturnTrue# "filter by timestep"returnFalse# "no time filtering"TimeFilter(0).filter_active() # False <- timestep 0 silently unfilteredTimeFilter(1).filter_active() # TrueTimeFilter(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)
importosos.environ.pop('PATH', None) # PATH is unset (rare but possible)# Line 634 of generate_pyi.py:forpinos.environ.get('PATH').split(';'):
...
# AttributeError: 'NoneType' object has no attribute 'split'
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)
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)
fromvtkmodules.numpy_interface.dataset_adapterimportDataSetasDSA_DataSetfromvtkmodules.util.data_modelimportDataSetasDM_DataSetprint(DSA_DataSetisDM_DataSet)
# False <- two different Python classes named DataSetfromvtkmodules.vtkCommonDataModelimportvtkPolyDatafromvtkmodules.numpy_interface.dataset_adapterimportWrapDataObjectvpd=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 forwardedprint(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)
forsnippetin [
"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')
exceptSyntaxErrorase: 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)
>>> 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.
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)
>>>importvtkmodules.tk.vtkTkImageViewerWidgetImportError: cannotimportname'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)
>>>importimpModuleNotFoundError: Nomodulenamed'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)
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.pyREFUTED
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.pyREFUTED
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:
#3vtkPNGWriter missing import — needs the Imaging backend to actually call vtkRegressionTestImage with a missing-baseline scenario.
#6mapper undefined in mergeToPolydataSerializer — needs vtkmodules.web.render_window_serializer (web disabled in our build) and a custom registered serializer to exercise the else: branch.
#36PyQtImpl NameError risk — requires an unusual Qt-import race condition; pattern confirmed from source.
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.
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 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.
#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++.
#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 hierarchies — vtkPolyData() 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):
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.
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 construction — sys.getrefcount(MyAlgorithm) grew by 1000 over 1000 constructions.
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.
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.
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.
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):
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
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.
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.
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.
Phase 3 (live reproducers).pip install vtk in Python 3.14 venv, scripted live confirmation of top 2 crown jewels.
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.cxxstatic std::mutex MapMutex;
PyVTKClass* vtkPythonUtil::FindClass(constchar* 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
PyThreadState* ts = PyEval_SaveThread();
result = op->method(...); // May throw std::bad_alloc or explicit throwPyEval_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):
importgc, weakref, vtkclassMyPlane(vtk.vtkPlane): passobj=MyPlane()
obj.self_ref=obj# cycle via per-instance dictwr=weakref.ref(obj)
delobjgc.collect()
assertwr() isnotNone# 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.
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.
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)
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");
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_HasAttrString → PyObject_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.
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.
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.
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.
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.
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 PyBufferProcs — Py_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)
F8-F10 (PyVTKObject traverse/clear/heap-DECREF trio) — 3 small fixes in PyVTKObject.cxx + 2 generator template tweaks. Affects 1,830× bindings. Live-reproduced.
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.
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.
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.
importgcimportweakrefimportvtkclassMyPlane(vtk.vtkPlane):
passobj=MyPlane()
obj.self_ref=obj# cycle via per-instance dictwr=weakref.ref(obj)
delobjgc.collect()
ifwr() isNone:
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.
importthreadingimportvtkdefhammer():
for_inrange(5000):
o=vtk.vtkDoubleArray()
o.SetNumberOfTuples(1)
delothreads= [threading.Thread(target=hammer) for_inrange(8)]
fortinthreads: t.start()
fortinthreads: 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:
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.
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.
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.
importsys, vtkclassEvilSeq:
"""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): return3def__getitem__(self, i):
raiseMemoryError(f"OOM during vector[{i}] read")
def__iter__(self):
raiseMemoryError("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.
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.
importvtkclassEvilBool:
def__bool__(self):
raiseMemoryError("OOM marker from __bool__")
formethod_namein ['SetAbortExecute', 'SetReleaseDataFlag']:
a=vtk.vtkAlgorithm()
method=getattr(a, method_name)
try:
method(EvilBool())
exceptMemoryErrorase:
print(f"{method_name}: MemoryError propagated (OK)")
exceptTypeErrorase:
# Note the EMPTY message — the MemoryError was cleared and replacedprint(f"{method_name}: BUG — got {e!r} instead of MemoryError")
# Also works on class methods:try:
vtk.vtkObject.SetGlobalWarningDisplay(EvilBool())
exceptTypeErrorase:
print(f"SetGlobalWarningDisplay: BUG — got {e!r}")
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.
importfiu, vtka=vtk.vtkAlgorithm()
b=vtk.vtkAlgorithm()
fiu.enable('libc/mm/malloc')
try:
a>>bexceptExceptionase:
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.
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.
importvtk, sysT=vtk.vtkColor3spec=T['float']
print(f"spec refcount baseline: {sys.getrefcount(spec)}")
# expected: 3221225472 (0xC0000000) — immortal under PEP 683for_inrange(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:
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:
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):importvtkclassEvilBool:
def__bool__(self):
raiseMemoryError("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 MemoryErrorexceptMemoryError:
print("GOOD — MemoryError propagated")
exceptTypeErrorase:
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.