Repository: python/cpython
Pull request #134289: gh-133885: Use locks instead of critical sections for _zstd
Author: login: emmatyping
Labels: name: skip news
Created: 2025-05-19T21:44:37Z | Merged: 2025-05-23T03:30:10Z

Move from using critical sections to locks for the (de)compression methods. Since the methods allow other threads to run, we should use a lock rather than a critical section.

I'm not totally sure about the removal of the critical sections in the `_get_(C|D)Dict` function, but it should be safe to yield to other threads in an initializer as the values cannot be modified (ZstdDicts are immutable).



<!-- gh-issue-number: gh-133885 -->
* Issue: gh-133885
<!-- /gh-issue-number -->



Diff:
diff --git a/Lib/test/test_zstd.py b/Lib/test/test_zstd.py
index 53ca592ea38828..084f8f24fc009c 100644
--- a/Lib/test/test_zstd.py
+++ b/Lib/test/test_zstd.py
@@ -2430,10 +2430,8 @@ def test_buffer_protocol(self):
             self.assertEqual(f.write(arr), LENGTH)
             self.assertEqual(f.tell(), LENGTH)
 
-@unittest.skip("it fails for now, see gh-133885")
 class FreeThreadingMethodTests(unittest.TestCase):
 
-    @unittest.skipUnless(Py_GIL_DISABLED, 'this test can only possibly fail with GIL disabled')
     @threading_helper.reap_threads
     @threading_helper.requires_working_threading()
     def test_compress_locking(self):
@@ -2470,7 +2468,6 @@ def run_method(method, input_data, output_data):
         actual = b''.join(output) + rest2
         self.assertEqual(expected, actual)
 
-    @unittest.skipUnless(Py_GIL_DISABLED, 'this test can only possibly fail with GIL disabled')
     @threading_helper.reap_threads
     @threading_helper.requires_working_threading()
     def test_decompress_locking(self):
@@ -2506,6 +2503,59 @@ def run_method(method, input_data, output_data):
         actual = b''.join(output)
         self.assertEqual(expected, actual)
 
+    @threading_helper.reap_threads
+    @threading_helper.requires_working_threading()
+    def test_compress_shared_dict(self):
+        num_threads = 8
+
+        def run_method(b):
+            level = threading.get_ident() % 4
+            # sync threads to increase chance of contention on
+            # capsule storing dictionary levels
+            b.wait()
+            ZstdCompressor(level=level,
+                           zstd_dict=TRAINED_DICT.as_digested_dict)
+            b.wait()
+            ZstdCompressor(level=level,
+                           zstd_dict=TRAINED_DICT.as_undigested_dict)
+            b.wait()
+            ZstdCompressor(level=level,
+                           zstd_dict=TRAINED_DICT.as_prefix)
+        threads = []
+
+        b = threading.Barrier(num_threads)
+        for i in range(num_threads):
+            thread = threading.Thread(target=run_method, args=(b,))
+
+            threads.append(thread)
+
+        with threading_helper.start_threads(threads):
+            pass
+
+    @threading_helper.reap_threads
+    @threading_helper.requires_working_threading()
+    def test_decompress_shared_dict(self):
+        num_threads = 8
+
+        def run_method(b):
+            # sync threads to increase chance of contention on
+            # decompression dictionary
+            b.wait()
+            ZstdDecompressor(zstd_dict=TRAINED_DICT.as_digested_dict)
+            b.wait()
+            ZstdDecompressor(zstd_dict=TRAINED_DICT.as_undigested_dict)
+            b.wait()
+            ZstdDecompressor(zstd_dict=TRAINED_DICT.as_prefix)
+        threads = []
+
+        b = threading.Barrier(num_threads)
+        for i in range(num_threads):
+            thread = threading.Thread(target=run_method, args=(b,))
+
+            threads.append(thread)
+
+        with threading_helper.start_threads(threads):
+            pass
 
 
 if __name__ == "__main__":
diff --git a/Modules/_zstd/clinic/decompressor.c.h b/Modules/_zstd/clinic/decompressor.c.h
index 4ecb19e9bde6ed..c6fdae74ab0447 100644
--- a/Modules/_zstd/clinic/decompressor.c.h
+++ b/Modules/_zstd/clinic/decompressor.c.h
@@ -7,7 +7,6 @@ preserve
 #  include "pycore_runtime.h"     // _Py_ID()
 #endif
 #include "pycore_abstract.h"      // _PyNumber_Index()
-#include "pycore_critical_section.h"// Py_BEGIN_CRITICAL_SECTION()
 #include "pycore_modsupport.h"    // _PyArg_UnpackKeywords()
 
 PyDoc_STRVAR(_zstd_ZstdDecompressor_new__doc__,
@@ -114,13 +113,7 @@ _zstd_ZstdDecompressor_unused_data_get_impl(ZstdDecompressor *self);
 static PyObject *
 _zstd_ZstdDecompressor_unused_data_get(PyObject *self, void *Py_UNUSED(context))
 {
-    PyObject *return_value = NULL;
-
-    Py_BEGIN_CRITICAL_SECTION(self);
-    return_value = _zstd_ZstdDecompressor_unused_data_get_impl((ZstdDecompressor *)self);
-    Py_END_CRITICAL_SECTION();
-
-    return return_value;
+    return _zstd_ZstdDecompressor_unused_data_get_impl((ZstdDecompressor *)self);
 }
 
 PyDoc_STRVAR(_zstd_ZstdDecompressor_decompress__doc__,
@@ -227,4 +220,4 @@ _zstd_ZstdDecompressor_decompress(PyObject *self, PyObject *const *args, Py_ssiz
 
     return return_value;
 }
-/*[clinic end generated code: output=7a4d278f9244e684 input=a9049054013a1b77]*/
+/*[clinic end generated code: output=30c12ef047027ede input=a9049054013a1b77]*/
diff --git a/Modules/_zstd/clinic/zstddict.c.h b/Modules/_zstd/clinic/zstddict.c.h
index 34e0e4b3ecfe72..aaa29e491bc1bb 100644
--- a/Modules/_zstd/clinic/zstddict.c.h
+++ b/Modules/_zstd/clinic/zstddict.c.h
@@ -6,7 +6,6 @@ preserve
 #  include "pycore_gc.h"          // PyGC_Head
 #  include "pycore_runtime.h"     // _Py_ID()
 #endif
-#include "pycore_critical_section.h"// Py_BEGIN_CRITICAL_SECTION()
 #include "pycore_modsupport.h"    // _PyArg_UnpackKeywords()
 
 PyDoc_STRVAR(_zstd_ZstdDict_new__doc__,
@@ -118,13 +117,7 @@ _zstd_ZstdDict_as_digested_dict_get_impl(ZstdDict *self);
 static PyObject *
 _zstd_ZstdDict_as_digested_dict_get(PyObject *self, void *Py_UNUSED(context))
 {
-    PyObject *return_value = NULL;
-
-    Py_BEGIN_CRITICAL_SECTION(self);
-    return_value = _zstd_ZstdDict_as_digested_dict_get_impl((ZstdDict *)self);
-    Py_END_CRITICAL_SECTION();
-
-    return return_value;
+    return _zstd_ZstdDict_as_digested_dict_get_impl((ZstdDict *)self);
 }
 
 PyDoc_STRVAR(_zstd_ZstdDict_as_undigested_dict__doc__,
@@ -156,13 +149,7 @@ _zstd_ZstdDict_as_undigested_dict_get_impl(ZstdDict *self);
 static PyObject *
 _zstd_ZstdDict_as_undigested_dict_get(PyObject *self, void *Py_UNUSED(context))
 {
-    PyObject *return_value = NULL;
-
-    Py_BEGIN_CRITICAL_SECTION(self);
-    return_value = _zstd_ZstdDict_as_undigested_dict_get_impl((ZstdDict *)self);
-    Py_END_CRITICAL_SECTION();
-
-    return return_value;
+    return _zstd_ZstdDict_as_undigested_dict_get_impl((ZstdDict *)self);
 }
 
 PyDoc_STRVAR(_zstd_ZstdDict_as_prefix__doc__,
@@ -194,12 +181,6 @@ _zstd_ZstdDict_as_prefix_get_impl(ZstdDict *self);
 static PyObject *
 _zstd_ZstdDict_as_prefix_get(PyObject *self, void *Py_UNUSED(context))
 {
-    PyObject *return_value = NULL;
-
-    Py_BEGIN_CRITICAL_SECTION(self);
-    return_value = _zstd_ZstdDict_as_prefix_get_impl((ZstdDict *)self);
-    Py_END_CRITICAL_SECTION();
-
-    return return_value;
+    return _zstd_ZstdDict_as_prefix_get_impl((ZstdDict *)self);
 }
-/*[clinic end generated code: output=bfb31c1187477afd input=a9049054013a1b77]*/
+/*[clinic end generated code: output=8692eabee4e0d1fe input=a9049054013a1b77]*/
diff --git a/Modules/_zstd/compressor.c b/Modules/_zstd/compressor.c
index 38baee2be1e95b..8f934858ef784f 100644
--- a/Modules/_zstd/compressor.c
+++ b/Modules/_zstd/compressor.c
@@ -17,6 +17,7 @@ class _zstd.ZstdCompressor "ZstdCompressor *" "&zstd_compressor_type_spec"
 #include "_zstdmodule.h"
 #include "buffer.h"
 #include "zstddict.h"
+#include "internal/pycore_lock.h" // PyMutex_IsLocked
 
 #include <stddef.h>               // offsetof()
 #include <zstd.h>                 // ZSTD_*()
@@ -38,6 +39,9 @@ typedef struct {
 
     /* Compression level */
     int compression_level;
+
+    /* Lock to protect the compression context */
+    PyMutex lock;
 } ZstdCompressor;
 
 #define ZstdCompressor_CAST(op) ((ZstdCompressor *)op)
@@ -149,12 +153,12 @@ capsule_free_cdict(PyObject *capsule)
 ZSTD_CDict *
 _get_CDict(ZstdDict *self, int compressionLevel)
 {
+    assert(PyMutex_IsLocked(&self->lock));
     PyObject *level = NULL;
-    PyObject *capsule;
+    PyObject *capsule = NULL;
     ZSTD_CDict *cdict;
+    int ret;
 
-    // TODO(emmatyping): refactor critical section code into a lock_held function
-    Py_BEGIN_CRITICAL_SECTION(self);
 
     /* int level object */
     level = PyLong_FromLong(compressionLevel);
@@ -163,12 +167,11 @@ _get_CDict(ZstdDict *self, int compressionLevel)
     }
 
     /* Get PyCapsule object from self->c_dicts */
-    capsule = PyDict_GetItemWithError(self->c_dicts, level);
+    ret = PyDict_GetItemRef(self->c_dicts, level, &capsule);
+    if (ret < 0) {
+        goto error;
+    }
     if (capsule == NULL) {
-        if (PyErr_Occurred()) {
-            goto error;
-        }
-
         /* Create ZSTD_CDict instance */
         char *dict_buffer = PyBytes_AS_STRING(self->dict_content);
         Py_ssize_t dict_len = Py_SIZE(self->dict_content);
@@ -196,11 +199,10 @@ _get_CDict(ZstdDict *self, int compressionLevel)
         }
 
         /* Add PyCapsule object to self->c_dicts */
-        if (PyDict_SetItem(self->c_dicts, level, capsule) < 0) {
-            Py_DECREF(capsule);
+        ret = PyDict_SetItem(self->c_dicts, level, capsule);
+        if (ret < 0) {
             goto error;
         }
-        Py_DECREF(capsule);
     }
     else {
         /* ZSTD_CDict instance already exists */
@@ -212,15 +214,55 @@ _get_CDict(ZstdDict *self, int compressionLevel)
     cdict = NULL;
 success:
     Py_XDECREF(level);
-    Py_END_CRITICAL_SECTION();
+    Py_XDECREF(capsule);
     return cdict;
 }
 
 static int
-_zstd_load_c_dict(ZstdCompressor *self, PyObject *dict)
+_zstd_load_impl(ZstdCompressor *self, ZstdDict *zd,
+                _zstd_state *mod_state, int type)
 {
-
     size_t zstd_ret;
+    if (type == DICT_TYPE_DIGESTED) {
+        /* Get ZSTD_CDict */
+        ZSTD_CDict *c_dict = _get_CDict(zd, self->compression_level);
+        if (c_dict == NULL) {
+            return -1;
+        }
+        /* Reference a prepared dictionary.
+           It overrides some compression context's parameters. */
+        zstd_ret = ZSTD_CCtx_refCDict(self->cctx, c_dict);
+    }
+    else if (type == DICT_TYPE_UNDIGESTED) {
+        /* Load a dictionary.
+           It doesn't override compression context's parameters. */
+        zstd_ret = ZSTD_CCtx_loadDictionary(
+                            self->cctx,
+                            PyBytes_AS_STRING(zd->dict_content),
+                            Py_SIZE(zd->dict_content));
+    }
+    else if (type == DICT_TYPE_PREFIX) {
+        /* Load a prefix */
+        zstd_ret = ZSTD_CCtx_refPrefix(
+                            self->cctx,
+                            PyBytes_AS_STRING(zd->dict_content),
+                            Py_SIZE(zd->dict_content));
+    }
+    else {
+        Py_UNREACHABLE();
+    }
+
+    /* Check error */
+    if (ZSTD_isError(zstd_ret)) {
+        set_zstd_error(mod_state, ERR_LOAD_C_DICT, zstd_ret);
+        return -1;
+    }
+    return 0;
+}
+
+static int
+_zstd_load_c_dict(ZstdCompressor *self, PyObject *dict)
+{
     _zstd_state* const mod_state = PyType_GetModuleState(Py_TYPE(self));
     if (mod_state == NULL) {
         return -1;
@@ -237,7 +279,10 @@ _zstd_load_c_dict(ZstdCompressor *self, PyObject *dict)
         /* When compressing, use undigested dictionary by default. */
         zd = (ZstdDict*)dict;
         type = DICT_TYPE_UNDIGESTED;
-        goto load;
+        PyMutex_Lock(&zd->lock);
+        ret = _zstd_load_impl(self, zd, mod_state, type);
+        PyMutex_Unlock(&zd->lock);
+        return ret;
     }
 
     /* Check (ZstdDict, type) */
@@ -257,7 +302,10 @@ _zstd_load_c_dict(ZstdCompressor *self, PyObject *dict)
             {
                 assert(type >= 0);
                 zd = (ZstdDict*)PyTuple_GET_ITEM(dict, 0);
-                goto load;
+                PyMutex_Lock(&zd->lock);
+                ret = _zstd_load_impl(self, zd, mod_state, type);
+                PyMutex_Unlock(&zd->lock);
+                return ret;
             }
         }
     }
@@ -266,49 +314,6 @@ _zstd_load_c_dict(ZstdCompressor *self, PyObject *dict)
     PyErr_SetString(PyExc_TypeError,
                     "zstd_dict argument should be ZstdDict object.");
     return -1;
-
-load:
-    if (type == DICT_TYPE_DIGESTED) {
-        /* Get ZSTD_CDict */
-        ZSTD_CDict *c_dict = _get_CDict(zd, self->compression_level);
-        if (c_dict == NULL) {
-            return -1;
-        }
-        /* Reference a prepared dictionary.
-           It overrides some compression context's parameters. */
-        Py_BEGIN_CRITICAL_SECTION(self);
-        zstd_ret = ZSTD_CCtx_refCDict(self->cctx, c_dict);
-        Py_END_CRITICAL_SECTION();
-    }
-    else if (type == DICT_TYPE_UNDIGESTED) {
-        /* Load a dictionary.
-           It doesn't override compression context's parameters. */
-        Py_BEGIN_CRITICAL_SECTION2(self, zd);
-        zstd_ret = ZSTD_CCtx_loadDictionary(
-                            self->cctx,
-                            PyBytes_AS_STRING(zd->dict_content),
-                            Py_SIZE(zd->dict_content));
-        Py_END_CRITICAL_SECTION2();
-    }
-    else if (type == DICT_TYPE_PREFIX) {
-        /* Load a prefix */
-        Py_BEGIN_CRITICAL_SECTION2(self, zd);
-        zstd_ret = ZSTD_CCtx_refPrefix(
-                            self->cctx,
-                            PyBytes_AS_STRING(zd->dict_content),
-                            Py_SIZE(zd->dict_content));
-        Py_END_CRITICAL_SECTION2();
-    }
-    else {
-        Py_UNREACHABLE();
-    }
-
-    /* Check error */
-    if (ZSTD_isError(zstd_ret)) {
-        set_zstd_error(mod_state, ERR_LOAD_C_DICT, zstd_ret);
-        return -1;
-    }
-    return 0;
 }
 
 /*[clinic input]
@@ -339,6 +344,7 @@ _zstd_ZstdCompressor_new_impl(PyTypeObject *type, PyObject *level,
 
     self->use_multithread = 0;
     self->dict = NULL;
+    self->lock = (PyMutex){0};
 
     /* Compression context */
     self->cctx = ZSTD_createCCtx();
@@ -403,6 +409,8 @@ ZstdCompressor_dealloc(PyObject *ob)
         ZSTD_freeCCtx(self->cctx);
     }
 
+    assert(!PyMutex_IsLocked(&self->lock));
+
     /* Py_XDECREF the dict after free the compression context */
     Py_CLEAR(self->dict);
 
@@ -412,9 +420,10 @@ ZstdCompressor_dealloc(PyObject *ob)
 }
 
 static PyObject *
-compress_impl(ZstdCompressor *self, Py_buffer *data,
-              ZSTD_EndDirective end_directive)
+compress_lock_held(ZstdCompressor *self, Py_buffer *data,
+                   ZSTD_EndDirective end_directive)
 {
+    assert(PyMutex_IsLocked(&self->lock));
     ZSTD_inBuffer in;
     ZSTD_outBuffer out;
     _BlocksOutputBuffer buffer = {.list = NULL};
@@ -495,8 +504,9 @@ mt_continue_should_break(ZSTD_inBuffer *in, ZSTD_outBuffer *out)
 #endif
 
 static PyObject *
-compress_mt_continue_impl(ZstdCompressor *self, Py_buffer *data)
+compress_mt_continue_lock_held(ZstdCompressor *self, Py_buffer *data)
 {
+    assert(PyMutex_IsLocked(&self->lock));
     ZSTD_inBuffer in;
     ZSTD_outBuffer out;
     _BlocksOutputBuffer buffer = {.list = NULL};
@@ -529,7 +539,7 @@ compress_mt_continue_impl(ZstdCompressor *self, Py_buffer *data)
             goto error;
         }
 
-        /* Like compress_impl(), output as much as possible. */
+        /* Like compress_lock_held(), output as much as possible. */
         if (out.pos == out.size) {
             if (_OutputBuffer_Grow(&buffer, &out) < 0) {
                 goto error;
@@ -588,14 +598,14 @@ _zstd_ZstdCompressor_compress_impl(ZstdCompressor *self, Py_buffer *data,
     }
 
     /* Thread-safe code */
-    Py_BEGIN_CRITICAL_SECTION(self);
+    PyMutex_Lock(&self->lock);
 
     /* Compress */
     if (self->use_multithread && mode == ZSTD_e_continue) {
-        ret = compress_mt_continue_impl(self, data);
+        ret = compress_mt_continue_lock_held(self, data);
     }
     else {
-        ret = compress_impl(self, data, mode);
+        ret = compress_lock_held(self, data, mode);
     }
 
     if (ret) {
@@ -607,7 +617,7 @@ _zstd_ZstdCompressor_compress_impl(ZstdCompressor *self, Py_buffer *data,
         /* Resetting cctx's session never fail */
         ZSTD_CCtx_reset(self->cctx, ZSTD_reset_session_only);
     }
-    Py_END_CRITICAL_SECTION();
+    PyMutex_Unlock(&self->lock);
 
     return ret;
 }
@@ -642,8 +652,9 @@ _zstd_ZstdCompressor_flush_impl(ZstdCompressor *self, int mode)
     }
 
     /* Thread-safe code */
-    Py_BEGIN_CRITICAL_SECTION(self);
-    ret = compress_impl(self, NULL, mode);
+    PyMutex_Lock(&self->lock);
+
+    ret = compress_lock_held(self, NULL, mode);
 
     if (ret) {
         self->last_mode = mode;
@@ -654,7 +665,7 @@ _zstd_ZstdCompressor_flush_impl(ZstdCompressor *self, int mode)
         /* Resetting cctx's session never fail */
         ZSTD_CCtx_reset(self->cctx, ZSTD_reset_session_only);
     }
-    Py_END_CRITICAL_SECTION();
+    PyMutex_Unlock(&self->lock);
 
     return ret;
 }
diff --git a/Modules/_zstd/decompressor.c b/Modules/_zstd/decompressor.c
index 58f9c9f804e549..e299f73b071353 100644
--- a/Modules/_zstd/decompressor.c
+++ b/Modules/_zstd/decompressor.c
@@ -17,6 +17,7 @@ class _zstd.ZstdDecompressor "ZstdDecompressor *" "&zstd_decompressor_type_spec"
 #include "_zstdmodule.h"
 #include "buffer.h"
 #include "zstddict.h"
+#include "internal/pycore_lock.h" // PyMutex_IsLocked
 
 #include <stdbool.h>              // bool
 #include <stddef.h>               // offsetof()
@@ -45,6 +46,9 @@ typedef struct {
     /* For ZstdDecompressor, 0 or 1.
        1 means the end of the first frame has been reached. */
     bool eof;
+
+    /* Lock to protect the decompression context */
+    PyMutex lock;
 } ZstdDecompressor;
 
 #define ZstdDecompressor_CAST(op) ((ZstdDecompressor *)op)
@@ -54,6 +58,7 @@ typedef struct {
 static inline ZSTD_DDict *
 _get_DDict(ZstdDict *self)
 {
+    assert(PyMutex_IsLocked(&self->lock));
     ZSTD_DDict *ret;
 
     /* Already created */
@@ -61,15 +66,14 @@ _get_DDict(ZstdDict *self)
         return self->d_dict;
     }
 
-    Py_BEGIN_CRITICAL_SECTION(self);
     if (self->d_dict == NULL) {
         /* Create ZSTD_DDict instance from dictionary content */
         char *dict_buffer = PyBytes_AS_STRING(self->dict_content);
         Py_ssize_t dict_len = Py_SIZE(self->dict_content);
         Py_BEGIN_ALLOW_THREADS
-        self->d_dict = ZSTD_createDDict(dict_buffer,
-                                        dict_len);
+        ret = ZSTD_createDDict(dict_buffer, dict_len);
         Py_END_ALLOW_THREADS
+        self->d_dict = ret;
 
         if (self->d_dict == NULL) {
             _zstd_state* const mod_state = PyType_GetModuleState(Py_TYPE(self));
@@ -81,11 +85,7 @@ _get_DDict(ZstdDict *self)
         }
     }
 
-    /* Don't lose any exception */
-    ret = self->d_dict;
-    Py_END_CRITICAL_SECTION();
-
-    return ret;
+    return self->d_dict;
 }
 
 /* Set decompression parameters to decompression context */
@@ -134,9 +134,7 @@ _zstd_set_d_parameters(ZstdDecompressor *self, PyObject *options)
         }
 
         /* Set parameter to compression context */
-        Py_BEGIN_CRITICAL_SECTION(self);
         zstd_ret = ZSTD_DCtx_setParameter(self->dctx, key_v, value_v);
-        Py_END_CRITICAL_SECTION();
 
         /* Check error */
         if (ZSTD_isError(zstd_ret)) {
@@ -147,11 +145,53 @@ _zstd_set_d_parameters(ZstdDecompressor *self, PyObject *options)
     return 0;
 }
 
+static int
+_zstd_load_impl(ZstdDecompressor *self, ZstdDict *zd,
+                _zstd_state *mod_state, int type)
+{
+    size_t zstd_ret;
+    if (type == DICT_TYPE_DIGESTED) {
+        /* Get ZSTD_DDict */
+        ZSTD_DDict *d_dict = _get_DDict(zd);
+        if (d_dict == NULL) {
+            return -1;
+        }
+        /* Reference a prepared dictionary */
+        zstd_ret = ZSTD_DCtx_refDDict(self->dctx, d_dict);
+    }
+    else if (type == DICT_TYPE_UNDIGESTED) {
+        /* Load a dictionary */
+        zstd_ret = ZSTD_DCtx_loadDictionary(
+                            self->dctx,
+                            PyBytes_AS_STRING(zd->dict_content),
+                            Py_SIZE(zd->dict_content));
+    }
+    else if (type == DICT_TYPE_PREFIX) {
+        /* Load a prefix */
+        zstd_ret = ZSTD_DCtx_refPrefix(
+                            self->dctx,
+                            PyBytes_AS_STRING(zd->dict_content),
+                            Py_SIZE(zd->dict_content));
+    }
+    else {
+        /* Impossible code path */
+        PyErr_SetString(PyExc_SystemError,
+                        "load_d_dict() impossible code path");
+        return -1;
+    }
+
+    /* Check error */
+    if (ZSTD_isError(zstd_ret)) {
+        set_zstd_error(mod_state, ERR_LOAD_D_DICT, zstd_ret);
+        return -1;
+    }
+    return 0;
+}
+
 /* Load dictionary or prefix to decompression context */
 static int
 _zstd_load_d_dict(ZstdDecompressor *self, PyObject *dict)
 {
-    size_t zstd_ret;
     _zstd_state* const mod_state = PyType_GetModuleState(Py_TYPE(self));
     if (mod_state == NULL) {
         return -1;
@@ -168,7 +208,10 @@ _zstd_load_d_dict(ZstdDecompressor *self, PyObject *dict)
         /* When decompressing, use digested dictionary by default. */
         zd = (ZstdDict*)dict;
         type = DICT_TYPE_DIGESTED;
-        goto load;
+        PyMutex_Lock(&zd->lock);
+        ret = _zstd_load_impl(self, zd, mod_state, type);
+        PyMutex_Unlock(&zd->lock);
+        return ret;
     }
 
     /* Check (ZstdDict, type) */
@@ -188,7 +231,10 @@ _zstd_load_d_dict(ZstdDecompressor *self, PyObject *dict)
             {
                 assert(type >= 0);
                 zd = (ZstdDict*)PyTuple_GET_ITEM(dict, 0);
-                goto load;
+                PyMutex_Lock(&zd->lock);
+                ret = _zstd_load_impl(self, zd, mod_state, type);
+                PyMutex_Unlock(&zd->lock);
+                return ret;
             }
         }
     }
@@ -197,50 +243,6 @@ _zstd_load_d_dict(ZstdDecompressor *self, PyObject *dict)
     PyErr_SetString(PyExc_TypeError,
                     "zstd_dict argument should be ZstdDict object.");
     return -1;
-
-load:
-    if (type == DICT_TYPE_DIGESTED) {
-        /* Get ZSTD_DDict */
-        ZSTD_DDict *d_dict = _get_DDict(zd);
-        if (d_dict == NULL) {
-            return -1;
-        }
-        /* Reference a prepared dictionary */
-        Py_BEGIN_CRITICAL_SECTION(self);
-        zstd_ret = ZSTD_DCtx_refDDict(self->dctx, d_dict);
-        Py_END_CRITICAL_SECTION();
-    }
-    else if (type == DICT_TYPE_UNDIGESTED) {
-        /* Load a dictionary */
-        Py_BEGIN_CRITICAL_SECTION2(self, zd);
-        zstd_ret = ZSTD_DCtx_loadDictionary(
-                            self->dctx,
-                            PyBytes_AS_STRING(zd->dict_content),
-                            Py_SIZE(zd->dict_content));
-        Py_END_CRITICAL_SECTION2();
-    }
-    else if (type == DICT_TYPE_PREFIX) {
-        /* Load a prefix */
-        Py_BEGIN_CRITICAL_SECTION2(self, zd);
-        zstd_ret = ZSTD_DCtx_refPrefix(
-                            self->dctx,
-                            PyBytes_AS_STRING(zd->dict_content),
-                            Py_SIZE(zd->dict_content));
-        Py_END_CRITICAL_SECTION2();
-    }
-    else {
-        /* Impossible code path */
-        PyErr_SetString(PyExc_SystemError,
-                        "load_d_dict() impossible code path");
-        return -1;
-    }
-
-    /* Check error */
-    if (ZSTD_isError(zstd_ret)) {
-        set_zstd_error(mod_state, ERR_LOAD_D_DICT, zstd_ret);
-        return -1;
-    }
-    return 0;
 }
 
 /*
@@ -268,8 +270,8 @@ _zstd_load_d_dict(ZstdDecompressor *self, PyObject *dict)
       Note, decompressing "an empty input" in any case will make it > 0.
 */
 static PyObject *
-decompress_impl(ZstdDecompressor *self, ZSTD_inBuffer *in,
-                Py_ssize_t max_length)
+decompress_lock_held(ZstdDecompressor *self, ZSTD_inBuffer *in,
+                     Py_ssize_t max_length)
 {
     size_t zstd_ret;
     ZSTD_outBuffer out;
@@ -339,10 +341,9 @@ decompress_impl(ZstdDecompressor *self, ZSTD_inBuffer *in,
 }
 
 static void
-decompressor_reset_session(ZstdDecompressor *self)
+decompressor_reset_session_lock_held(ZstdDecompressor *self)
 {
-    // TODO(emmatyping): use _Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED here
-    // and ensure lock is always held
+    assert(PyMutex_IsLocked(&self->lock));
 
     /* Reset variables */
     self->in_begin = 0;
@@ -359,8 +360,10 @@ decompressor_reset_session(ZstdDecompressor *self)
 }
 
 static PyObject *
-stream_decompress(ZstdDecompressor *self, Py_buffer *data, Py_ssize_t max_length)
+stream_decompress_lock_held(ZstdDecompressor *self, Py_buffer *data,
+                            Py_ssize_t max_length)
 {
+    assert(PyMutex_IsLocked(&self->lock));
     ZSTD_inBuffer in;
     PyObject *ret = NULL;
     int use_input_buffer;
@@ -456,7 +459,7 @@ stream_decompress(ZstdDecompressor *self, Py_buffer *data, Py_ssize_t max_length
     assert(in.pos == 0);
 
     /* Decompress */
-    ret = decompress_impl(self, &in, max_length);
+    ret = decompress_lock_held(self, &in, max_length);
     if (ret == NULL) {
         goto error;
     }
@@ -517,7 +520,7 @@ stream_decompress(ZstdDecompressor *self, Py_buffer *data, Py_ssize_t max_length
 
 error:
     /* Reset decompressor's states/session */
-    decompressor_reset_session(self);
+    decompressor_reset_session_lock_held(self);
 
     Py_CLEAR(ret);
     return NULL;
@@ -555,6 +558,7 @@ _zstd_ZstdDecompressor_new_impl(PyTypeObject *type, PyObject *zstd_dict,
     self->unused_data = NULL;
     self->eof = 0;
     self->dict = NULL;
+    self->lock = (PyMutex){0};
 
     /* needs_input flag */
     self->needs_input = 1;
@@ -608,6 +612,8 @@ ZstdDecompressor_dealloc(PyObject *ob)
         ZSTD_freeDCtx(self->dctx);
     }
 
+    assert(!PyMutex_IsLocked(&self->lock));
+
     /* Py_CLEAR the dict after free decompression context */
     Py_CLEAR(self->dict);
 
@@ -623,7 +629,6 @@ ZstdDecompressor_dealloc(PyObject *ob)
 }
 
 /*[clinic input]
-@critical_section
 @getter
 _zstd.ZstdDecompressor.unused_data
 
@@ -635,11 +640,14 @@ decompressed, unused input data after the frame. Otherwise this will be b''.
 
 static PyObject *
 _zstd_ZstdDecompressor_unused_data_get_impl(ZstdDecompressor *self)
-/*[clinic end generated code: output=f3a20940f11b6b09 input=5233800bef00df04]*/
+/*[clinic end generated code: output=f3a20940f11b6b09 input=54d41ecd681a3444]*/
 {
     PyObject *ret;
 
+    PyMutex_Lock(&self->lock);
+
     if (!self->eof) {
+        PyMutex_Unlock(&self->lock);
         return Py_GetConstant(Py_CONSTANT_EMPTY_BYTES);
     }
     else {
@@ -656,6 +664,7 @@ _zstd_ZstdDecompressor_unused_data_get_impl(ZstdDecompressor *self)
         }
     }
 
+    PyMutex_Unlock(&self->lock);
     return ret;
 }
 
@@ -693,10 +702,9 @@ _zstd_ZstdDecompressor_decompress_impl(ZstdDecompressor *self,
 {
     PyObject *ret;
     /* Thread-safe code */
-    Py_BEGIN_CRITICAL_SECTION(self);
-
-    ret = stream_decompress(self, data, max_length);
-    Py_END_CRITICAL_SECTION();
+    PyMutex_Lock(&self->lock);
+    ret = stream_decompress_lock_held(self, data, max_length);
+    PyMutex_Unlock(&self->lock);
     return ret;
 }
 
diff --git a/Modules/_zstd/zstddict.c b/Modules/_zstd/zstddict.c
index 7df187a6fa69d7..39828c9b36b5c2 100644
--- a/Modules/_zstd/zstddict.c
+++ b/Modules/_zstd/zstddict.c
@@ -17,6 +17,7 @@ class _zstd.ZstdDict "ZstdDict *" "&zstd_dict_type_spec"
 #include "_zstdmodule.h"
 #include "zstddict.h"
 #include "clinic/zstddict.c.h"
+#include "internal/pycore_lock.h" // PyMutex_IsLocked
 
 #include <zstd.h>                 // ZSTD_freeDDict(), ZSTD_getDictID_fromDict()
 
@@ -53,6 +54,7 @@ _zstd_ZstdDict_new_impl(PyTypeObject *type, PyObject *dict_content,
     self->dict_content = NULL;
     self->d_dict = NULL;
     self->dict_id = 0;
+    self->lock = (PyMutex){0};
 
     /* ZSTD_CDict dict */
     self->c_dicts = PyDict_New();
@@ -109,6 +111,8 @@ ZstdDict_dealloc(PyObject *ob)
         ZSTD_freeDDict(self->d_dict);
     }
 
+    assert(!PyMutex_IsLocked(&self->lock));
+
     /* Release dict_content after Free ZSTD_CDict/ZSTD_DDict instances */
     Py_CLEAR(self->dict_content);
     Py_CLEAR(self->c_dicts);
@@ -143,7 +147,6 @@ static PyMemberDef ZstdDict_members[] = {
 };
 
 /*[clinic input]
-@critical_section
 @getter
 _zstd.ZstdDict.as_digested_dict
 
@@ -160,13 +163,12 @@ Pass this attribute as zstd_dict argument: compress(dat, zstd_dict=zd.as_digeste
 
 static PyObject *
 _zstd_ZstdDict_as_digested_dict_get_impl(ZstdDict *self)
-/*[clinic end generated code: output=09b086e7a7320dbb input=585448c79f31f74a]*/
+/*[clinic end generated code: output=09b086e7a7320dbb input=10cd2b6165931b77]*/
 {
     return Py_BuildValue("Oi", self, DICT_TYPE_DIGESTED);
 }
 
 /*[clinic input]
-@critical_section
 @getter
 _zstd.ZstdDict.as_undigested_dict
 
@@ -181,13 +183,12 @@ Pass this attribute as zstd_dict argument: compress(dat, zstd_dict=zd.as_undiges
 
 static PyObject *
 _zstd_ZstdDict_as_undigested_dict_get_impl(ZstdDict *self)
-/*[clinic end generated code: output=43c7a989e6d4253a input=022b0829ffb1c220]*/
+/*[clinic end generated code: output=43c7a989e6d4253a input=11e5f5df690a85b4]*/
 {
     return Py_BuildValue("Oi", self, DICT_TYPE_UNDIGESTED);
 }
 
 /*[clinic input]
-@critical_section
 @getter
 _zstd.ZstdDict.as_prefix
 
@@ -202,7 +203,7 @@ Pass this attribute as zstd_dict argument: compress(dat, zstd_dict=zd.as_prefix)
 
 static PyObject *
 _zstd_ZstdDict_as_prefix_get_impl(ZstdDict *self)
-/*[clinic end generated code: output=6f7130c356595a16 input=09fb82a6a5407e87]*/
+/*[clinic end generated code: output=6f7130c356595a16 input=b028e0ae6ec4292b]*/
 {
     return Py_BuildValue("Oi", self, DICT_TYPE_PREFIX);
 }
diff --git a/Modules/_zstd/zstddict.h b/Modules/_zstd/zstddict.h
index e8a55a3670b869..dcba0f21852087 100644
--- a/Modules/_zstd/zstddict.h
+++ b/Modules/_zstd/zstddict.h
@@ -19,6 +19,9 @@ typedef struct {
     PyObject *dict_content;
     /* Dictionary id */
     uint32_t dict_id;
+
+    /* Lock to protect the digested dictionaries */
+    PyMutex lock;
 } ZstdDict;
 
 #endif  // !ZSTD_DICT_H


Reviews:
user: login: ZeroIntensity
body: This seems like a downgrade. Though rare, functions like `PyMem_Malloc` can call arbitrary Python code, so we lose our protection against deadlocks. What's the rationale here?
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T03:59:15Z
user: login: colesbury
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T18:17:34Z
user: login: emmatyping
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T18:51:49Z
user: login: emmatyping
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T19:01:04Z
user: login: ZeroIntensity
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T19:32:04Z
user: login: emmatyping
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T22:02:45Z
user: login: emmatyping
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T22:04:38Z
user: login: ZeroIntensity
body: Ok, I understand this a little better now, sorry!

I'm a bit iffy about the lock-ordering here. I *think* the lock is only held under non-reentrant calls? `PyErr_SetString` and friends seem to be safe, but it looks like they might be able to re-enter if someone did weird things to the exception state. I wouldn't worry about it, really, but it's worth bringing up.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T01:38:35Z
user: login: emmatyping
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T04:05:43Z
user: login: emmatyping
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T04:09:36Z
user: login: ZeroIntensity
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T13:21:31Z
user: login: emmatyping
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T14:12:07Z
user: login: emmatyping
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T14:12:25Z
user: login: colesbury
body: Looks pretty good to me. A few small comments below
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T18:54:24Z
user: login: emmatyping
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T20:52:15Z
user: login: emmatyping
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T20:54:59Z
user: login: emmatyping
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T20:56:22Z
user: login: ZeroIntensity
body: Yay, buildbots passed! LGTM too, apart from one (speculative) concern about re-entrancy. 
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T01:02:24Z
user: login: emmatyping
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T03:39:14Z
user: login: emmatyping
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T03:41:28Z
user: login: emmatyping
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T03:44:13Z
user: login: kumaraditya303
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T10:43:01Z
user: login: kumaraditya303
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T10:49:45Z
user: login: kumaraditya303
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T10:52:28Z
user: login: kumaraditya303
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T10:52:34Z
user: login: ZeroIntensity
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T13:28:18Z
user: login: colesbury
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T13:36:31Z
user: login: emmatyping
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T21:24:28Z
user: login: colesbury
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T21:29:03Z
user: login: emmatyping
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T21:33:21Z
user: login: colesbury
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T21:44:08Z
user: login: ZeroIntensity
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T00:31:17Z
user: login: emmatyping
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T00:41:07Z
user: login: ZeroIntensity
body: LGTM assuming buildbots pass
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-23T00:46:52Z
user: login: colesbury
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T03:06:22Z

Review comments:
diff_hunk: @@ -403,6 +399,10 @@ ZstdCompressor_dealloc(PyObject *ob)
         ZSTD_freeCCtx(self->cctx);
     }
 
+    if (PyMutex_IsLocked(&self->lock)) {
+        PyMutex_Unlock(&self->lock);
+    }
path: Modules/_zstd/compressor.c
user: login: ZeroIntensity
body: This pattern will cause a fatal error if people are using the locks correctly.
created_at: 2025-05-20T03:55:37Z
author_association: MEMBER
diff_hunk: @@ -403,6 +399,10 @@ ZstdCompressor_dealloc(PyObject *ob)
         ZSTD_freeCCtx(self->cctx);
     }
 
+    if (PyMutex_IsLocked(&self->lock)) {
+        PyMutex_Unlock(&self->lock);
+    }
path: Modules/_zstd/compressor.c
user: login: colesbury
body: Yeah, we should avoid this pattern. You can do something like:

```
assert(!PyMutex_IsLocked(&self->lock));
```

at the beginning of `ZstdCompressor_dealloc`, but we should structure the locking such that it's impossible for the compressor to be deallocated while the lock is held. 
created_at: 2025-05-20T18:03:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2430,7 +2430,6 @@ def test_buffer_protocol(self):
             self.assertEqual(f.write(arr), LENGTH)
             self.assertEqual(f.tell(), LENGTH)
 
-@unittest.skip("it fails for now, see gh-133885")
 class FreeThreadingMethodTests(unittest.TestCase):
 
     @unittest.skipUnless(Py_GIL_DISABLED, 'this test can only possibly fail with GIL disabled')
path: Lib/test/test_zstd.py
user: login: colesbury
body: We should remove this `@unittest.skipUnless(Py_GIL_DISABLED, ...);`. It's very possible for the test to fail with the GIL enabled.
created_at: 2025-05-20T18:04:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -38,6 +39,9 @@ typedef struct {
 
     /* Compression level */
     int compression_level;
+
+    /* Lock to protect the compression context */
+    PyMutex lock;
path: Modules/_zstd/compressor.c
user: login: colesbury
body: I think this is a right idea. We need a lock for both the GIL-enabled and FT builds, because the GIL doesn't protect things within a `Py_BEGIN_ALLOW_THREADS`, which is used before many ZSTD API calls.
created_at: 2025-05-20T18:05:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -153,8 +157,6 @@ _get_CDict(ZstdDict *self, int compressionLevel)
     PyObject *capsule;
     ZSTD_CDict *cdict;
 
-    // TODO(emmatyping): refactor critical section code into a lock_held function
path: Modules/_zstd/compressor.c
user: login: colesbury
body: I think `_get_CDict` needs some more work. It doesn't look thread-safe to me (even with the GIL) because the `Py_BEGIN_ALLOW_THREADS` below means multiple threads can try to create the capsule for the same level.

I can think of two high-level strategies:

1) Use a lock on `ZstdDict` to ensure that only one thread tries to initialize the `cdict` at a time.

2) Use `PyDict_SetDefaultRef` so that only one thread succeeds in setting the capsule on the dict.

Is `self->dict_content` mutable? If so, then the lock may be more appropriate.
created_at: 2025-05-20T18:13:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -403,6 +399,10 @@ ZstdCompressor_dealloc(PyObject *ob)
         ZSTD_freeCCtx(self->cctx);
     }
 
+    if (PyMutex_IsLocked(&self->lock)) {
+        PyMutex_Unlock(&self->lock);
+    }
path: Modules/_zstd/compressor.c
user: login: emmatyping
body: Yeah, that was what I was thinking, getting in that state should be impossible, but good to check in case it does happen somehow.
created_at: 2025-05-20T18:51:49Z
author_association: MEMBER
diff_hunk: @@ -153,8 +157,6 @@ _get_CDict(ZstdDict *self, int compressionLevel)
     PyObject *capsule;
     ZSTD_CDict *cdict;
 
-    // TODO(emmatyping): refactor critical section code into a lock_held function
path: Modules/_zstd/compressor.c
user: login: emmatyping
body: Yeah that's a great point! I think `PyDict_SetDefaultRef` sounds more reasonable, the content is immutable so it should be safe to use `PyDict_SetDefaultRef`.
created_at: 2025-05-20T19:01:04Z
author_association: MEMBER
diff_hunk: @@ -403,6 +399,10 @@ ZstdCompressor_dealloc(PyObject *ob)
         ZSTD_freeCCtx(self->cctx);
     }
 
+    if (PyMutex_IsLocked(&self->lock)) {
+        PyMutex_Unlock(&self->lock);
+    }
path: Modules/_zstd/compressor.c
user: login: ZeroIntensity
body: If we reach the destructor without releasing the lock, that's a good sign that something went horribly wrong, especially if we make sure the lock can't be held when calling into Python. I'd honestly rather a deadlock than letting it silently pass.
created_at: 2025-05-20T19:32:04Z
author_association: MEMBER
diff_hunk: @@ -2430,7 +2430,6 @@ def test_buffer_protocol(self):
             self.assertEqual(f.write(arr), LENGTH)
             self.assertEqual(f.tell(), LENGTH)
 
-@unittest.skip("it fails for now, see gh-133885")
 class FreeThreadingMethodTests(unittest.TestCase):
 
     @unittest.skipUnless(Py_GIL_DISABLED, 'this test can only possibly fail with GIL disabled')
path: Lib/test/test_zstd.py
user: login: emmatyping
body: Removed this, it's a good point!
created_at: 2025-05-20T22:02:45Z
author_association: MEMBER
diff_hunk: @@ -153,8 +157,6 @@ _get_CDict(ZstdDict *self, int compressionLevel)
     PyObject *capsule;
     ZSTD_CDict *cdict;
 
-    // TODO(emmatyping): refactor critical section code into a lock_held function
path: Modules/_zstd/compressor.c
user: login: emmatyping
body: I've switched to using PyDict_SetDefaultRef and PyDict_GetItemRef. I also added a test that should check sharing a dictionary across threads with several levels.
created_at: 2025-05-20T22:04:38Z
author_association: MEMBER
diff_hunk: @@ -2506,6 +2503,27 @@ def run_method(method, input_data, output_data):
         actual = b''.join(output)
         self.assertEqual(expected, actual)
 
+    @threading_helper.reap_threads
+    @threading_helper.requires_working_threading()
+    def test_compress_shared_dict(self):
+        num_threads = 8
+
+        def run_method(b):
+            level = threading.get_ident() % 2
+            # sync threads to increase chance of contention on
+            # capsule storing dictionary levels
+            b.wait()
+            ZstdCompressor(level=level, zstd_dict=TRAINED_DICT.as_digested_dict)
path: Lib/test/test_zstd.py
user: login: ZeroIntensity
body: It'd be nice if we tested for sharing a compressor between threads too.
created_at: 2025-05-21T01:10:57Z
author_association: MEMBER
diff_hunk: @@ -61,7 +65,6 @@ _get_DDict(ZstdDict *self)
         return self->d_dict;
     }
 
-    Py_BEGIN_CRITICAL_SECTION(self);
     if (self->d_dict == NULL) {
         /* Create ZSTD_DDict instance from dictionary content */
         char *dict_buffer = PyBytes_AS_STRING(self->dict_content);
path: Modules/_zstd/decompressor.c
user: login: ZeroIntensity
body: Similar to the other case, we need to lock here to prevent races on initializing `self->d_dict` with multiple threads.
created_at: 2025-05-21T01:20:14Z
author_association: MEMBER
diff_hunk: @@ -639,7 +635,10 @@ _zstd_ZstdDecompressor_unused_data_get_impl(ZstdDecompressor *self)
 {
path: Modules/_zstd/decompressor.c
user: login: ZeroIntensity
body: The `@critical_section` in the AC definition isn't needed anymore. We probably need to do that in a couple other places too.
created_at: 2025-05-21T01:24:33Z
author_association: MEMBER
diff_hunk: @@ -2506,6 +2503,27 @@ def run_method(method, input_data, output_data):
         actual = b''.join(output)
         self.assertEqual(expected, actual)
 
+    @threading_helper.reap_threads
+    @threading_helper.requires_working_threading()
+    def test_compress_shared_dict(self):
+        num_threads = 8
+
+        def run_method(b):
+            level = threading.get_ident() % 2
+            # sync threads to increase chance of contention on
+            # capsule storing dictionary levels
+            b.wait()
+            ZstdCompressor(level=level, zstd_dict=TRAINED_DICT.as_digested_dict)
path: Lib/test/test_zstd.py
user: login: emmatyping
body: We do that above in `test_compress_locking`, unless you had something else in mind?
created_at: 2025-05-21T04:05:43Z
author_association: MEMBER
diff_hunk: @@ -61,7 +65,6 @@ _get_DDict(ZstdDict *self)
         return self->d_dict;
     }
 
-    Py_BEGIN_CRITICAL_SECTION(self);
     if (self->d_dict == NULL) {
         /* Create ZSTD_DDict instance from dictionary content */
         char *dict_buffer = PyBytes_AS_STRING(self->dict_content);
path: Modules/_zstd/decompressor.c
user: login: emmatyping
body: Yeah, I should probably put locks around `ZstdDict` to protect here and in `_get_CDict`. 
created_at: 2025-05-21T04:09:36Z
author_association: MEMBER
diff_hunk: @@ -2506,6 +2503,27 @@ def run_method(method, input_data, output_data):
         actual = b''.join(output)
         self.assertEqual(expected, actual)
 
+    @threading_helper.reap_threads
+    @threading_helper.requires_working_threading()
+    def test_compress_shared_dict(self):
+        num_threads = 8
+
+        def run_method(b):
+            level = threading.get_ident() % 2
+            # sync threads to increase chance of contention on
+            # capsule storing dictionary levels
+            b.wait()
+            ZstdCompressor(level=level, zstd_dict=TRAINED_DICT.as_digested_dict)
path: Lib/test/test_zstd.py
user: login: ZeroIntensity
body: Oh, didn't see that.
created_at: 2025-05-21T13:21:31Z
author_association: MEMBER
diff_hunk: @@ -153,8 +157,6 @@ _get_CDict(ZstdDict *self, int compressionLevel)
     PyObject *capsule;
     ZSTD_CDict *cdict;
 
-    // TODO(emmatyping): refactor critical section code into a lock_held function
path: Modules/_zstd/compressor.c
user: login: emmatyping
body: Okay I actually used locking in the end, because we also want to make sure the member access of `ZstdDict->d_dict` is thread safe.
created_at: 2025-05-21T14:12:07Z
author_association: MEMBER
diff_hunk: @@ -61,7 +65,6 @@ _get_DDict(ZstdDict *self)
         return self->d_dict;
     }
 
-    Py_BEGIN_CRITICAL_SECTION(self);
     if (self->d_dict == NULL) {
         /* Create ZSTD_DDict instance from dictionary content */
         char *dict_buffer = PyBytes_AS_STRING(self->dict_content);
path: Modules/_zstd/decompressor.c
user: login: emmatyping
body: Okay, hopefully the new changes look good!
created_at: 2025-05-21T14:12:25Z
author_association: MEMBER
diff_hunk: @@ -195,7 +196,7 @@ _get_CDict(ZstdDict *self, int compressionLevel)
             goto error;
         }
 
-        /* Add PyCapsule object to self->c_dicts */
+        /* Add PyCapsule object to self->c_dicts if not already inserted */
path: Modules/_zstd/compressor.c
user: login: colesbury
body: I think you should use `PyDict_SetDefaultRef` instead of `PyDict_SetItem` below
created_at: 2025-05-21T18:28:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -61,7 +65,6 @@ _get_DDict(ZstdDict *self)
         return self->d_dict;
     }
 
-    Py_BEGIN_CRITICAL_SECTION(self);
     if (self->d_dict == NULL) {
         /* Create ZSTD_DDict instance from dictionary content */
         char *dict_buffer = PyBytes_AS_STRING(self->dict_content);
path: Modules/_zstd/decompressor.c
user: login: colesbury
body: GitHub won't let me comment on the actual line, but I think it's good practice to avoid modifying Python object fields within `Py_BEGIN_ALLOW_THREADS`. Instead of:

```c
        Py_BEGIN_ALLOW_THREADS
        self->d_dict = ZSTD_createDDict(dict_buffer,
                                        dict_len);
        Py_END_ALLOW_THREADS
```

use something like:

```c
        PyObject *d_dict;
        Py_BEGIN_ALLOW_THREADS
        d_dict = ZSTD_createDDict(dict_buffer, dict_len);
        Py_END_ALLOW_THREADS
        self->d_dict = d_dict;
```

I think the current code is probably safe, but it's easy to have subtle data races with this sort of pattern. For example, I think it would not be safe if you were instead assigning to `self->c_dicts` because that field can be read by a `tp_traverse` concurrently with a `Py_BEGIN_ALLOW_THREADS` block (in both the GIL-enabled and free-threaded builds).
created_at: 2025-05-21T18:36:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -83,7 +86,6 @@ _get_DDict(ZstdDict *self)
 
     /* Don't lose any exception */
     ret = self->d_dict;
-    Py_END_CRITICAL_SECTION();
path: Modules/_zstd/decompressor.c
user: login: colesbury
body: This can be simplified now to just `return self->d_dict;`
created_at: 2025-05-21T18:37:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -61,7 +65,6 @@ _get_DDict(ZstdDict *self)
         return self->d_dict;
path: Modules/_zstd/decompressor.c
user: login: colesbury
body: Up to you, but I'd consider adding something like a `assert(PyMutex_IsLocked(&self->lock));` at the beginning of `_get_DDict`
created_at: 2025-05-21T18:39:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -639,7 +635,10 @@ _zstd_ZstdDecompressor_unused_data_get_impl(ZstdDecompressor *self)
 {
path: Modules/_zstd/decompressor.c
user: login: colesbury
body: `_zstd.ZstdDecompressor.unused_data` still has the `@critical_section`.
created_at: 2025-05-21T18:52:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -61,7 +65,6 @@ _get_DDict(ZstdDict *self)
         return self->d_dict;
     }
 
-    Py_BEGIN_CRITICAL_SECTION(self);
     if (self->d_dict == NULL) {
         /* Create ZSTD_DDict instance from dictionary content */
         char *dict_buffer = PyBytes_AS_STRING(self->dict_content);
path: Modules/_zstd/decompressor.c
user: login: emmatyping
body: Excellent point, I've updated this to avoid that.
created_at: 2025-05-21T20:52:15Z
author_association: MEMBER
diff_hunk: @@ -639,7 +635,10 @@ _zstd_ZstdDecompressor_unused_data_get_impl(ZstdDecompressor *self)
 {
path: Modules/_zstd/decompressor.c
user: login: emmatyping
body: That should now be removed, thanks for catching it!
created_at: 2025-05-21T20:54:59Z
author_association: MEMBER
diff_hunk: @@ -195,7 +196,7 @@ _get_CDict(ZstdDict *self, int compressionLevel)
             goto error;
         }
 
-        /* Add PyCapsule object to self->c_dicts */
+        /* Add PyCapsule object to self->c_dicts if not already inserted */
path: Modules/_zstd/compressor.c
user: login: emmatyping
body: Done! I didn't realize the semantics around SetItem were quite so weird.
created_at: 2025-05-21T20:56:22Z
author_association: MEMBER
diff_hunk: @@ -149,12 +153,11 @@ capsule_free_cdict(PyObject *capsule)
 ZSTD_CDict *
 _get_CDict(ZstdDict *self, int compressionLevel)
 {
+    assert(PyMutex_IsLocked(&self->lock));
path: Modules/_zstd/compressor.c
user: login: ZeroIntensity
body: Ouch, we need to be really careful about this lock. Calls like `PyDict_GetItemWithError` or `Py_DECREF` can definitely call Python code, so it's cloudy with a chance of deadlocks. It *might* be fine--is `c_dicts` ever exposed to Python?
created_at: 2025-05-22T00:59:33Z
author_association: MEMBER
diff_hunk: @@ -149,12 +153,11 @@ capsule_free_cdict(PyObject *capsule)
 ZSTD_CDict *
 _get_CDict(ZstdDict *self, int compressionLevel)
 {
+    assert(PyMutex_IsLocked(&self->lock));
path: Modules/_zstd/compressor.c
user: login: emmatyping
body: cdicts holds compression dictionary data and is only mutated through internal functions, so no, it is only ever a normal dictionary. 
created_at: 2025-05-22T03:39:14Z
author_association: MEMBER
diff_hunk: @@ -149,12 +153,11 @@ capsule_free_cdict(PyObject *capsule)
 ZSTD_CDict *
 _get_CDict(ZstdDict *self, int compressionLevel)
 {
+    assert(PyMutex_IsLocked(&self->lock));
path: Modules/_zstd/compressor.c
user: login: emmatyping
body: more precisely it stores zstd compression dictionaries (bytes) in a Python dictionary keyed by compression level

created_at: 2025-05-22T03:41:28Z
author_association: MEMBER
diff_hunk: @@ -149,12 +153,11 @@ capsule_free_cdict(PyObject *capsule)
 ZSTD_CDict *
 _get_CDict(ZstdDict *self, int compressionLevel)
 {
+    assert(PyMutex_IsLocked(&self->lock));
path: Modules/_zstd/compressor.c
user: login: emmatyping
body: I agree the potential for deadlocks are concerning, perhaps it would be good to note this invariant in a comment?
created_at: 2025-05-22T03:44:13Z
author_association: MEMBER
diff_hunk: @@ -149,12 +153,11 @@ capsule_free_cdict(PyObject *capsule)
 ZSTD_CDict *
 _get_CDict(ZstdDict *self, int compressionLevel)
 {
+    assert(PyMutex_IsLocked(&self->lock));
path: Modules/_zstd/compressor.c
user: login: kumaraditya303
body: This may not be an issue here but `PyDict_GetItemRef` should be preferred over `PyDict_GetItemWithError` as latter returns borrowed reference which isn't safe. 
created_at: 2025-05-22T10:43:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,12 +197,15 @@ _get_CDict(ZstdDict *self, int compressionLevel)
             goto error;
         }
 
-        /* Add PyCapsule object to self->c_dicts */
-        if (PyDict_SetItem(self->c_dicts, level, capsule) < 0) {
-            Py_DECREF(capsule);
+        /* Add PyCapsule object to self->c_dicts if not already inserted */
+        PyObject *capsule_dict;
+        int ret = PyDict_SetDefaultRef(self->c_dicts, level, capsule,
+                                       &capsule_dict);
+        Py_XDECREF(capsule_dict);
path: Modules/_zstd/compressor.c
user: login: kumaraditya303
body: ```suggestion
        int ret = PyDict_SetDefaultRef(self->c_dicts, level, capsule, NULL);
```
You can pass NULL since `capsule_dict` is not needed
created_at: 2025-05-22T10:49:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -412,8 +422,8 @@ ZstdCompressor_dealloc(PyObject *ob)
 }
 
 static PyObject *
-compress_impl(ZstdCompressor *self, Py_buffer *data,
-              ZSTD_EndDirective end_directive)
+compress_lock_held(ZstdCompressor *self, Py_buffer *data,
+                   ZSTD_EndDirective end_directive)
path: Modules/_zstd/compressor.c
user: login: kumaraditya303
body: Add `assert(PyMutex_IsLocked(&self->lock));` to lock held functions to ensure the lock is held. 
created_at: 2025-05-22T10:52:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -495,7 +505,7 @@ mt_continue_should_break(ZSTD_inBuffer *in, ZSTD_outBuffer *out)
 #endif
 
 static PyObject *
-compress_mt_continue_impl(ZstdCompressor *self, Py_buffer *data)
+compress_mt_continue_lock_held(ZstdCompressor *self, Py_buffer *data)
 {
path: Modules/_zstd/compressor.c
user: login: kumaraditya303
body: Same
created_at: 2025-05-22T10:52:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -149,12 +153,11 @@ capsule_free_cdict(PyObject *capsule)
 ZSTD_CDict *
 _get_CDict(ZstdDict *self, int compressionLevel)
 {
+    assert(PyMutex_IsLocked(&self->lock));
path: Modules/_zstd/compressor.c
user: login: ZeroIntensity
body: Ok, yeah, let's use `PyDict_GetItemRef` while we're here. As long as `c_dicts` definitely isn't re-entrant, there shouldn't be any deadlocks.
created_at: 2025-05-22T13:28:18Z
author_association: MEMBER
diff_hunk: @@ -195,7 +196,7 @@ _get_CDict(ZstdDict *self, int compressionLevel)
             goto error;
         }
 
-        /* Add PyCapsule object to self->c_dicts */
+        /* Add PyCapsule object to self->c_dicts if not already inserted */
path: Modules/_zstd/compressor.c
user: login: colesbury
body: I'm sorry - I was confused and forgot this function is now called with the `ZstdDict` lock held. `PyDict_SetItem` was fine here.
created_at: 2025-05-22T13:36:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,7 +196,7 @@ _get_CDict(ZstdDict *self, int compressionLevel)
             goto error;
         }
 
-        /* Add PyCapsule object to self->c_dicts */
+        /* Add PyCapsule object to self->c_dicts if not already inserted */
path: Modules/_zstd/compressor.c
user: login: emmatyping
body: Ah, no worries! I don't think using PyDict_SetDefaultRef is wrong per se. But I can always undo that change.
created_at: 2025-05-22T21:24:28Z
author_association: MEMBER
diff_hunk: @@ -195,7 +196,7 @@ _get_CDict(ZstdDict *self, int compressionLevel)
             goto error;
         }
 
-        /* Add PyCapsule object to self->c_dicts */
+        /* Add PyCapsule object to self->c_dicts if not already inserted */
path: Modules/_zstd/compressor.c
user: login: colesbury
body: Yeah, I don't think it's wrong, just maybe more complicated than necessary. Up to you.
created_at: 2025-05-22T21:29:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,7 +196,7 @@ _get_CDict(ZstdDict *self, int compressionLevel)
             goto error;
         }
 
-        /* Add PyCapsule object to self->c_dicts */
+        /* Add PyCapsule object to self->c_dicts if not already inserted */
path: Modules/_zstd/compressor.c
user: login: emmatyping
body: I think I will revert to PyDict_SetItem. Do you have thoughts on using PyDict_GetItemRef here: https://github.com/python/cpython/pull/134289#discussion_r2102566026 ?

I would think similarly it's not really needed.
created_at: 2025-05-22T21:33:20Z
author_association: MEMBER
diff_hunk: @@ -195,7 +196,7 @@ _get_CDict(ZstdDict *self, int compressionLevel)
             goto error;
         }
 
-        /* Add PyCapsule object to self->c_dicts */
+        /* Add PyCapsule object to self->c_dicts if not already inserted */
path: Modules/_zstd/compressor.c
user: login: colesbury
body: `PyDict_GetItemWithError` is safe here because we only add new entries to `c_dicts`, and never remove or update them, but there is now generally a preference for APIs that return new references over borrowed references.

I think it's fine to switch to `PyDict_GetItemRef` -- it avoids having to worry about why borrowed references are safe in this context.
created_at: 2025-05-22T21:44:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -163,12 +167,12 @@ _get_CDict(ZstdDict *self, int compressionLevel)
     }
 
     /* Get PyCapsule object from self->c_dicts */
-    capsule = PyDict_GetItemWithError(self->c_dicts, level);
+    ret = PyDict_GetItemRef(self->c_dicts, level, &capsule);
+    if (ret < 0) {
+        Py_XDECREF(capsule);
path: Modules/_zstd/compressor.c
user: login: ZeroIntensity
body: Won't `capsule` always be `NULL` here?
created_at: 2025-05-23T00:29:33Z
author_association: MEMBER
diff_hunk: @@ -163,12 +167,12 @@ _get_CDict(ZstdDict *self, int compressionLevel)
     }
 
     /* Get PyCapsule object from self->c_dicts */
-    capsule = PyDict_GetItemWithError(self->c_dicts, level);
+    ret = PyDict_GetItemRef(self->c_dicts, level, &capsule);
+    if (ret < 0) {
+        Py_XDECREF(capsule);
path: Modules/_zstd/compressor.c
user: login: emmatyping
body: Yes, good point! I'll remove this.
created_at: 2025-05-23T00:41:07Z
author_association: MEMBER

PR comments:
user: login: colesbury
created_at: 2025-05-20T18:18:04Z
body: @ZeroIntensity - `PyMem_Malloc` is not allowed to call back into Python.
author_association: CONTRIBUTOR
user: login: ZeroIntensity
created_at: 2025-05-20T18:44:11Z
body: Oh, that's news to me. When did that change?
author_association: MEMBER
user: login: colesbury
created_at: 2025-05-20T18:54:23Z
body: @ZeroIntensity - that's always been the case, as far as I know. A lot of things would break if `PyMem_Malloc` were allowed to call back into Python. `PyObject_GC_Malloc` used to be able to call back into Python (via triggering GC), but that hasn't been the case since 3.12.
author_association: CONTRIBUTOR
user: login: ZeroIntensity
created_at: 2025-05-20T19:00:18Z
body: > PyObject_GC_Malloc used to be able to call back into Python (via triggering GC), but that hasn't been the case since 3.12.

Ah, that's what I was thinking of. 

We do need to document this contract somewhere. I'm imagining a case where an embedder sets their own allocator that calls Python code, because they have an attached thread state. The documentation doesn't say you can't do that, as far as I can tell.
author_association: MEMBER
user: login: dpdani
created_at: 2025-05-21T09:49:14Z
body: Are we sure about the implications of implicit locking in de/compressor contexts?
I believe we might carefully implement this now only to discourage it later.

If two threads concurrently write into a compressor context with implicit locking they might succeed in corrupting their data. Say:
1. thread 1 writes `<html>`
2. thread 2 writes a png file
3. thread 1 writes `</html>`

Or pick whichever other interleaving of the two threads.
The corruption is only evident after decompressing back the data: from zstd's point of view there is nothing wrong with the above.

This is just one example, but I think there's reasonable expectation that users won't want to share de/compressor contexts anyways.

I think raising an exception when detecting concurrent use is a more desirable outcome.
author_association: CONTRIBUTOR
user: login: emmatyping
created_at: 2025-05-21T12:59:06Z
body: > If two threads concurrently write into a compressor context with implicit locking they might succeed in corrupting their data. Say:
>
>    thread 1 writes <html>
>    thread 2 writes a png file
>    thread 1 writes </html>

I think I would say don't write different data to the same stream with threading. I can append things to a shared list that makes it invalid for what I want but that is a programmer error. I don't particularly see a reason to share a compression context between threads, but I do think we might see people move a compression context into a thread to do all the compression in another thread, and we should try to support that.
author_association: MEMBER
user: login: dpdani
created_at: 2025-05-21T17:32:14Z
body: Oh yes, I agree.

Let me explain a bit better what my intention was with python-zstandard.

Instead of using a lock-based approach, I used a flag to signify that a de/compressor context was in use by some thread.
If another thread tried to set the same de/compressor's flag (with an atomic compare-and-set operation) while it was still in use, then it would raise an exception.

The differences with using locks are:
1. a `ConcurrentUseException` is raised when appropriate, which should 
2. make users be aware that data corruption may occur, caused by multithreading.

And, in both approaches:
1. no segmentation fault can be issued by the underlying zstd implementation.

IIUC, you alluded to an ownership transfer model. Note that this approach is not in contrast with that model. If a de/compressor object is created by one thread and then transferred to a different thread for use, no exception is raised. The only problematic usage that the exception would signal is when a thread calls into a context method while another thread was already using the context.

In fact, this is not even in contrast with other thread synchronization mechanisms that don't use ownership transfer (e.g. barriers): if the program makes sure that there is no *concurrent* access, then the exception is never raised.

Of course, the choice is up to you. I just wanted to make sure we considered both approaches.
author_association: CONTRIBUTOR
user: login: ZeroIntensity
created_at: 2025-05-21T18:11:18Z
body: Hmm, I don't think we should be inventing object ownership models here. That makes sense for a third-party, but that sort of thing should get its own proposal for the stdlib as a whole. 

Locking should be sufficient, right? If torn writes at the method-level are an issue, we should just document that users need to have an additional layer of synchronization when using compressors concurrently.
author_association: MEMBER
user: login: emmatyping
created_at: 2025-05-21T18:35:33Z
body: > Locking should be sufficient, right? If torn writes at the method-level are an issue, we should just document that users need to have an additional layer of synchronization when using compressors concurrently.

It should be safe, I believe @dpdani is concerned about e.g. mixing input streams unintentionally with the same compressor context? If people want to make sure two threads writing different data end up with "correct" data, that is up to the user.
author_association: MEMBER
user: login: ZeroIntensity
created_at: 2025-05-21T22:45:17Z
body: !buildbot nogil refleak
author_association: MEMBER
user: login: bedevere-bot
created_at: 2025-05-21T22:45:20Z
body: :robot: New build scheduled with the buildbot fleet by @ZeroIntensity for commit d142aa849f56972b20a8df1e588b5dd540d7276c :robot:

Results will be shown at:

https://buildbot.python.org/all/#/grid?branch=refs%2Fpull%2F134289%2Fmerge

The command will test the builders whose names match following regular expression: `nogil refleak`

The builders matched are:
- `AMD64 CentOS9 NoGIL Refleaks PR`
- `PPC64LE Fedora Rawhide NoGIL refleaks PR`
- `s390x Fedora Rawhide NoGIL refleaks PR`
- `aarch64 Fedora Rawhide NoGIL refleaks PR`
- `AMD64 Fedora Rawhide NoGIL refleaks PR`

author_association: NONE
user: login: emmatyping
created_at: 2025-05-23T00:44:37Z
body: !buildbot nogil refleak
author_association: MEMBER
user: login: bedevere-bot
created_at: 2025-05-23T00:44:40Z
body: :robot: New build scheduled with the buildbot fleet by @emmatyping for commit 69a755bd828ed2729a3a622d3a92c353de90b184 :robot:

Results will be shown at:

https://buildbot.python.org/all/#/grid?branch=refs%2Fpull%2F134289%2Fmerge

The command will test the builders whose names match following regular expression: `nogil refleak`

The builders matched are:
- `AMD64 CentOS9 NoGIL Refleaks PR`
- `PPC64LE Fedora Rawhide NoGIL refleaks PR`
- `AMD64 Fedora Rawhide NoGIL refleaks PR`
- `s390x Fedora Rawhide NoGIL refleaks PR`
- `aarch64 Fedora Rawhide NoGIL refleaks PR`

author_association: NONE
user: login: emmatyping
created_at: 2025-05-23T01:10:59Z
body: Hm, that CentOS 9 buildbot doesn't have zstd installed but it is failing due to unrelated issues.
author_association: MEMBER
user: login: emmatyping
created_at: 2025-05-23T01:11:55Z
body: (FWIW, I ran the refleak hunter locally on a nogil build and it passed)
author_association: MEMBER
user: login: emmatyping
created_at: 2025-05-23T01:55:17Z
body: Opened https://github.com/python/cpython/issues/134557 for the free threaded buildbot refleak
author_association: MEMBER
user: login: miss-islington-app[bot]
created_at: 2025-05-23T03:30:13Z
body: Thanks @emmatyping for the PR, and @colesbury for merging it 🌮🎉.. I'm working now to backport this PR to: 3.14.
🐍🍒⛏🤖 
author_association: NONE
user: login: bedevere-app[bot]
created_at: 2025-05-23T03:30:23Z
body: [GH-134560](https://github.com/python/cpython/pull/134560) is a backport of this pull request to the [3.14 branch](https://github.com/python/cpython/tree/3.14).
author_association: NONE
user: login: emmatyping
created_at: 2025-05-23T04:14:08Z
body: Thanks all for the reviews! Thinking through free threading reentrancy at the C level is something I'm still getting a feel for. Chatting about this fix has definitely helped with my understanding :)
author_association: MEMBER