Repository navigation
Modernization roadmap: multi-phase init, stable ABI, free-threading, GC support, deprecated-API cleanup (discussion) #302
Description
Summary
A discussion / tracking issue for five modernization topics that are all feasible on zstandard's current codebase but involve design trade-offs better agreed up-front before PR work begins. Each sub-topic could be its own issue + PR if you'd prefer smaller surfaces; grouping here so the full landscape is visible.
The five topics:
- Multi-phase module init (sub-interpreter readiness).
- Stable ABI (
abi3) adoption — blocked only by one private API. Py_MOD_GIL_NOT_USEDis declared but structurally premature.- GC support missing on 15 types that store
PyObject *fields. - Deprecated-API cleanup — small, mechanical.
1. Multi-phase module init
Current state: m_size = -1 + single-phase init blocks sub-interpreter use. 19 global PyTypeObject *, 1 global PyObject *ZstdError, and a features set all live in C globals rather than per-module state.
Migration path: PyModuleDef_Slot array with Py_mod_create / Py_mod_exec; types and other globals move into a per-module state struct, looked up via PyType_GetModuleByDef(...).
Effort: Medium. The global-to-state migration is the bulk of the work; slot adoption is mechanical once state is in place.
2. Stable ABI (abi3) feasibility
Current state: Close to feasible — only one real blocker.
- Heap types already in place via
PyType_FromSpec. pythoncapi_compat.halready included.- ~6
PyBytes_AS_STRING+ list/tuple macros → replace with their non-limited equivalents (mechanical). _PyBytes_Resizeis the actual blocker — private API used for copy-avoidance insafe_pybytes_resize.
Target: Py_LIMITED_API = 0x030A0000 (3.10 floor); guard _PyBytes_Resize with #ifndef Py_LIMITED_API, falling back to a copy; replace unlimited macros with limited-API equivalents.
Impact: Wheel matrix collapses from per-minor-version to one abi3 wheel per platform. Minor perf cost on _PyBytes_Resize fallback path (one extra copy per resize), which is only hit on the abi3 build.
3. Py_MOD_GIL_NOT_USED — premature?
The declaration asserts free-threading safety. The current structural reality:
- 19 global
PyTypeObject *(not per-interpreter). - No per-object locking (no
Py_BEGIN_CRITICAL_SECTION). - Single-phase init (see topic 1).
- CFFI backend has no FT story.
Options:
- (A) Gate the declaration behind topic 1 + a critical-section audit.
- (B)
#if 0it so maintainers flip it intentionally after the structural work lands.
Today the declaration is a promise the code doesn't fully keep. Free-threaded CPython users who load zstandard into a shared-type program can hit races on the global type pointers during multi-interpreter or multi-thread setup/teardown.
4. GC support missing — 15 types
15 of the 19 heap types store PyObject * fields (compressor refs, source refs, result refs, etc.) without Py_TPFLAGS_HAVE_GC. Reference cycles through these fields are uncollectible.
Not actively leaking today — the fields are typically short-lived — but user code that creates a cycle through one of these fields (e.g., a callback closure that retains the compressor) leaks until process exit.
Fix: Add Py_TPFLAGS_HAVE_GC to each spec; implement tp_traverse visiting every PyObject * field; tp_clear using Py_CLEAR on each.
Interaction: The separately-filed heap-type refcount issue migrates 4 factories from PyObject_New to tp_alloc; tp_alloc already handles GC registration if the type is flagged HAVE_GC. The two changes compose cleanly.
5. Deprecated-API cleanup
Small, mechanical; can be done in a single PR independent of the above.
- Remove 9 dead
#if PY_VERSION_HEX < 0x03090000guards inc-ext/bufferutil.c— the supported-Python floor is 3.9. - Drop
#include "structmember.h"(deprecated in 3.12;pythoncapi_compat.hsupplies it). PyModule_AddObject→PyModule_AddObjectRef— covered in the separately-filed OOM-hardening issue.- 16
PyObject_CallObject→PyObject_CallNoArgs/PyObject_Call— clarity;CallObjectis still stable ABI, so this is cosmetic. PY_SSIZE_T_CLEANis a no-op from 3.13; can be removed alongside the 3.13+ bump if/when it happens.pythoncapi_compat.his included but largely unused —PyObject_HasAttrStringWithError,Py_XSETREF, and others in it could replace existing less-robust patterns (theHasAttrString→HasAttrStringWithErrormigration is in the separately-filedMemoryError-swallowing issue).
Filing as one discussion vs. 5 issues
Grouped here because these decisions are correlated: "where does zstandard want to be in 2-3 Python releases?". Multi-phase init is a precondition for (3) meaningfully; abi3 is orthogonal but valuable to plan alongside; GC and deprecated-API are standalone.
Happy to split into 5 separate issues + staged PRs if you prefer smaller surfaces for review. Also happy to take on any of these as a PR once you've indicated priority — my suggestion would be topic 5 first (small mechanical cleanup, low risk), topic 2 next (abi3 is high-leverage at modest cost), topic 4 (GC support), and save topics 1 + 3 for last since they interact and benefit from an aligned design.
Methodology
Found via cext-review-toolkit — the module-state, stable-abi, free-threading, type-slot, and version-compat scanners collectively covered these topics. All five items are static observations about the codebase's current shape vs. modern CPython C-extension conventions; no live reproducer is applicable.
Discovery, root-cause analysis, and issue drafting were performed by Claude Code and reviewed by a human before filing.
Full report
Complete multi-agent analysis (48 FIX findings across 13 categories, plus a reproducer appendix): https://gist.github.com/devdanzin/b86039ac097141579590c1a0f3a43605
Activity
pylaterreur commentedon Oct 17, 2026
Some test results on section 3 (
Py_MOD_GIL_NOT_USED), in case they help with the support decision. I'm not suggesting any particular outcome.tjobs really use the free-threaded interpreters (run), all 14 3.13t/3.14t jobs pass on Linux, macOS and Windows. The C backend gets 292 passed, and the CFFI backend on 3.14t gets 266 passed, 26 skipped.ZSTD_WARNINGS_AS_ERRORS=1andZSTD_SLOW_TESTS=1, I get the same results.c-ext/backend_c.c:138declaresPy_MOD_GIL_NOT_USED, andsys._is_gil_enabled()staysFalseafter importing either backend.compressobj/decompressobj, stream andread_to_iterAPIs. I ran it on 3.15.0 and 3.15.0t, with the C and CFFI backends, 3 times each:ZstdCompressionDict: fine with the C backend, segfault with CFFI both with and without the GIL. That is a CFFI bug unrelated to free-threading: CFFI backend: crash when ZstdDecompressor instances share a ZstdCompressionDict #361, with a fix in cffi: create the DDict once per ZstdCompressionDict #362.concurrent.interpreters) refuse to load the C backend (ImportError: module backend_c does not support loading in subinterpreters), so the global type pointers can't be shared across interpreters that way.Prepared with the help of Claude Code.