From 478f1efbb9009bf3134d9189a779210ad4ab3326 Mon Sep 17 00:00:00 2001 From: Kumar Aditya Date: Wed, 3 Sep 2025 18:48:32 +0530 Subject: [PATCH 01/16] initial thread safety fixes --- msgspec/_core.c | 72 +++++++++++++++++++++++++++++-------------------- 1 file changed, 43 insertions(+), 29 deletions(-) diff --git a/msgspec/_core.c b/msgspec/_core.c index 147ad95a..4314e417 100644 --- a/msgspec/_core.c +++ b/msgspec/_core.c @@ -57,11 +57,47 @@ ms_popcount(uint64_t i) { \ #define MS_UNICODE_EQ(a, b) _PyUnicode_EQ(a, b) #endif +#if defined(Py_GIL_DISABLED) && !PY314_PLUS +#error "Py_GIL_DISABLED is only supported in Python 3.14+" +#endif + #if PY314_PLUS -#define MS_IMMORTAL_INITIAL_REFCNT _Py_IMMORTAL_INITIAL_REFCNT +#ifdef Py_GIL_DISABLED +#define _PyObject_HEAD_INIT(type) \ + { \ + 0, \ + _Py_STATICALLY_ALLOCATED_FLAG, \ + { 0 }, \ + 0, \ + _Py_IMMORTAL_REFCNT_LOCAL, \ + 0, \ + (type), \ + } #else -#define MS_IMMORTAL_INITIAL_REFCNT _Py_IMMORTAL_REFCNT -#endif +#if SIZEOF_VOID_P > 4 +#define _PyObject_HEAD_INIT(type) \ + { \ + .ob_refcnt = _Py_IMMORTAL_INITIAL_REFCNT, \ + .ob_flags = _Py_STATIC_FLAG_BITS, \ + .ob_type = (type) \ + } +#else +#define _PyObject_HEAD_INIT(type) \ + { \ + .ob_refcnt = _Py_STATIC_IMMORTAL_INITIAL_REFCNT, \ + .ob_type = (type) \ + } +#endif // SIZEOF_VOID_P > 4 +#endif // Py_GIL_DISABLED +#else +#define _PyObject_HEAD_INIT(type) \ + { \ + _PyObject_EXTRA_INIT \ + .ob_refcnt = _Py_IMMORTAL_REFCNT, \ + .ob_type = (type) \ + }, +#endif // PY314_PLUS + #define DIV_ROUND_CLOSEST(n, d) ((((n) < 0) == ((d) < 0)) ? (((n) + (d)/2)/(d)) : (((n) - (d)/2)/(d))) @@ -243,16 +279,9 @@ static const char base64_encode_table[] = * GC Utilities * *************************************************************************/ -/* Mirrored from pycore_gc.h in cpython */ -typedef struct { - uintptr_t _gc_next; - uintptr_t _gc_prev; -} MS_PyGC_Head; - -#define MS_AS_GC(o) ((MS_PyGC_Head *)(o)-1) #define MS_TYPE_IS_GC(t) (((PyTypeObject *)(t))->tp_flags & Py_TPFLAGS_HAVE_GC) #define MS_OBJECT_IS_GC(obj) MS_TYPE_IS_GC(Py_TYPE(obj)) -#define MS_IS_TRACKED(o) (MS_AS_GC(o)->_gc_next != 0) +#define MS_IS_TRACKED(o) PyObject_GC_IsTracked(o) /* Is this object something that is/could be GC tracked? True if * - the value supports GC @@ -2144,15 +2173,7 @@ PyTypeObject NoDefault_Type = { .tp_basicsize = 0 }; -#if PY312_PLUS -PyObject _NoDefault_Object = { - _PyObject_EXTRA_INIT - { MS_IMMORTAL_INITIAL_REFCNT }, - &NoDefault_Type -}; -#else -PyObject _NoDefault_Object = {1, &NoDefault_Type}; -#endif +PyObject _NoDefault_Object = _PyObject_HEAD_INIT(&NoDefault_Type); /************************************************************************* * UNSET singleton * @@ -2248,15 +2269,7 @@ PyTypeObject Unset_Type = { .tp_basicsize = 0 }; -#if PY312_PLUS -PyObject _Unset_Object = { - _PyObject_EXTRA_INIT - { MS_IMMORTAL_INITIAL_REFCNT }, - &Unset_Type -}; -#else -PyObject _Unset_Object = {1, &Unset_Type}; -#endif +PyObject _Unset_Object = _PyObject_HEAD_INIT(&Unset_Type); /************************************************************************* @@ -22271,5 +22284,6 @@ PyInit__core(void) Py_INCREF(st->StructType); if (PyModule_AddObject(m, "Struct", st->StructType) < 0) return NULL; + PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); return m; } From a7f4bc458e31a738f5ed62cfece18652b2e8fd4b Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 26 May 2025 09:06:43 -0700 Subject: [PATCH 02/16] Fix annotations support on 3.14 With this change, the tests run for me on a local build of Python 3.14. There are a lot of failures related to sys.getrefcount() but that seems to be an unrelated issue. Closes #810. Fixes #651. Fixes #795. --- msgspec/_core.c | 59 +++++++++++++++++++++++++++++++++++++++++++++-- msgspec/_utils.py | 16 +++++++++---- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/msgspec/_core.c b/msgspec/_core.c index 4314e417..3617eddc 100644 --- a/msgspec/_core.c +++ b/msgspec/_core.c @@ -481,6 +481,7 @@ typedef struct { #endif PyObject *astimezone; PyObject *re_compile; + PyObject *get_annotate_from_class_namespace; uint8_t gc_cycle; } MsgspecState; @@ -5827,12 +5828,45 @@ structmeta_is_classvar( static int structmeta_collect_fields(StructMetaInfo *info, MsgspecState *mod, bool kwonly) { - PyObject *annotations = PyDict_GetItemString( + PyObject *annotations = PyDict_GetItemString( // borrowed reference info->namespace, "__annotations__" ); - if (annotations == NULL) return 0; + if (annotations == NULL) { + if (mod->get_annotate_from_class_namespace != NULL) { + PyObject *annotate = PyObject_CallOneArg( + mod->get_annotate_from_class_namespace, info->namespace + ); + if (annotate == NULL) { + return -1; + } + if (annotate == Py_None) { + Py_DECREF(annotate); + return 0; + } + PyObject *format = PyLong_FromLong(1); /* annotationlib.Format.VALUE */ + if (format == NULL) { + Py_DECREF(annotate); + return -1; + } + annotations = PyObject_CallOneArg( + annotate, format + ); + Py_DECREF(annotate); + Py_DECREF(format); + if (annotations == NULL) { + return -1; + } + } + else { + return 0; // No annotations, nothing to do + } + } + else { + Py_INCREF(annotations); + } if (!PyDict_Check(annotations)) { + Py_DECREF(annotations); PyErr_SetString(PyExc_TypeError, "__annotations__ must be a dict"); return -1; } @@ -5882,6 +5916,7 @@ structmeta_collect_fields(StructMetaInfo *info, MsgspecState *mod, bool kwonly) } return 0; error: + Py_DECREF(annotations); Py_XDECREF(module_ns); return -1; } @@ -22238,6 +22273,26 @@ PyInit__core(void) Py_DECREF(temp_module); if (st->re_compile == NULL) return NULL; + /* annotationlib.get_annotate_from_class_namespace */ + temp_module = PyImport_ImportModule("annotationlib"); + if (temp_module == NULL) { + if (PyErr_ExceptionMatches(PyExc_ModuleNotFoundError)) { + // Below Python 3.14 + PyErr_Clear(); + st->get_annotate_from_class_namespace = NULL; + } + else { + return NULL; + } + } + else { + st->get_annotate_from_class_namespace = PyObject_GetAttrString( + temp_module, "get_annotate_from_class_namespace" + ); + Py_DECREF(temp_module); + if (st->get_annotate_from_class_namespace == NULL) return NULL; + } + /* Initialize cached constant strings */ #define CACHED_STRING(attr, str) \ if ((st->attr = PyUnicode_InternFromString(str)) == NULL) return NULL diff --git a/msgspec/_utils.py b/msgspec/_utils.py index 6d338104..534d17f5 100644 --- a/msgspec/_utils.py +++ b/msgspec/_utils.py @@ -1,5 +1,6 @@ # type: ignore import collections +import inspect import sys import typing @@ -71,6 +72,13 @@ def _eval_type(t, globalns, localns): _eval_type = typing._eval_type +if sys.version_info >= (3, 10): + from inspect import get_annotations as _get_class_annotations +else: + def _get_class_annotations(cls): + return cls.__dict__.get("__annotations__", {}) + + def _apply_params(obj, mapping): if isinstance(obj, typing.TypeVar): return mapping.get(obj, obj) @@ -149,17 +157,17 @@ def get_class_annotations(obj): cls_locals = dict(vars(cls)) cls_globals = getattr(sys.modules.get(cls.__module__, None), "__dict__", {}) - ann = cls.__dict__.get("__annotations__", {}) + ann = _get_class_annotations(cls) for name, value in ann.items(): if name in hints: continue - if value is None: - value = type(None) - elif isinstance(value, str): + if isinstance(value, str): value = _forward_ref(value) value = _eval_type(value, cls_locals, cls_globals) if mapping is not None: value = _apply_params(value, mapping) + if value is None: + value = type(None) hints[name] = value return hints From 776c2a166996c8d8a5e899a7c395c824ba8134d8 Mon Sep 17 00:00:00 2001 From: Kumar Aditya Date: Wed, 10 Sep 2025 19:38:39 +0530 Subject: [PATCH 03/16] more thread safety fixes --- msgspec/_core.c | 124 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 99 insertions(+), 25 deletions(-) diff --git a/msgspec/_core.c b/msgspec/_core.c index 3617eddc..0e084981 100644 --- a/msgspec/_core.c +++ b/msgspec/_core.c @@ -92,12 +92,39 @@ ms_popcount(uint64_t i) { \ #else #define _PyObject_HEAD_INIT(type) \ { \ - _PyObject_EXTRA_INIT \ .ob_refcnt = _Py_IMMORTAL_REFCNT, \ .ob_type = (type) \ - }, + } #endif // PY314_PLUS +#if PY_VERSION_HEX < 0x030D00A1 +static inline int +PyDict_GetItemRef(PyObject *mp, PyObject *key, PyObject **result) +{ +#if PY_VERSION_HEX >= 0x03000000 + PyObject *item = PyDict_GetItemWithError(mp, key); +#else + PyObject *item = _PyDict_GetItemWithError(mp, key); +#endif + if (item != NULL) { + *result = Py_NewRef(item); + return 1; // found + } + if (!PyErr_Occurred()) { + *result = NULL; + return 0; // not found + } + *result = NULL; + return -1; +} +#endif // PY_VERSION_HEX < 0x030D00A1 + +#if PY_VERSION_HEX < 0x030D00B3 +# define Py_BEGIN_CRITICAL_SECTION(op) { +# define Py_END_CRITICAL_SECTION() } +# define Py_BEGIN_CRITICAL_SECTION2(a, b) { +# define Py_END_CRITICAL_SECTION2() } +#endif // PY_VERSION_HEX < 0x030D00B3 #define DIV_ROUND_CLOSEST(n, d) ((((n) < 0) == ((d) < 0)) ? (((n) + (d)/2)/(d)) : (((n) - (d)/2)/(d))) @@ -147,8 +174,10 @@ unicode_str_and_size_nocheck(PyObject *str, Py_ssize_t *size) { /* XXX: Optimized `PyUnicode_AsUTF8AndSize` */ static inline const char * unicode_str_and_size(PyObject *str, Py_ssize_t *size) { +#ifndef Py_GIL_DISABLED const char *out = unicode_str_and_size_nocheck(str, size); if (MS_LIKELY(out != NULL)) return out; +#endif return PyUnicode_AsUTF8AndSize(str, size); } @@ -338,6 +367,7 @@ murmur2(const char *p, Py_ssize_t len) { /************************************************************************* * String Cache * *************************************************************************/ +#ifndef Py_GIL_DISABLED #ifndef STRING_CACHE_SIZE #define STRING_CACHE_SIZE 512 @@ -362,6 +392,7 @@ string_cache_clear(void) { } } } +#endif /************************************************************************* * Endian handling macros * @@ -4411,7 +4442,7 @@ typenode_collect_convert_literals(TypeNodeCollectState *state) { } static int -typenode_collect_convert_structs(TypeNodeCollectState *state) { +typenode_collect_convert_structs_lock_held(TypeNodeCollectState *state) { if (state->struct_obj == NULL && state->structs_set == NULL) { return 0; } @@ -4433,12 +4464,13 @@ typenode_collect_convert_structs(TypeNodeCollectState *state) { * Try looking the structs_set up in the cache first, to avoid building a * new one below. */ - PyObject *lookup = PyDict_GetItem( - state->mod->struct_lookup_cache, state->structs_set - ); + PyObject *lookup = NULL; + if (PyDict_GetItemRef(state->mod->struct_lookup_cache, state->structs_set, &lookup) < 0) { + return -1; + } + if (lookup != NULL) { /* Lookup was in the cache, update the state and return */ - Py_INCREF(lookup); state->structs_lookup = lookup; if (Lookup_array_like(lookup)) { @@ -4585,6 +4617,16 @@ typenode_collect_convert_structs(TypeNodeCollectState *state) { return status; } +static int +typenode_collect_convert_structs(TypeNodeCollectState *state) { + int status; + Py_BEGIN_CRITICAL_SECTION(state->mod->struct_lookup_cache); + status = typenode_collect_convert_structs_lock_held(state); + Py_END_CRITICAL_SECTION(); + return status; +} + + static void typenode_collect_clear_state(TypeNodeCollectState *state) { Py_CLEAR(state->struct_obj); @@ -6727,7 +6769,7 @@ static PyTypeObject StructInfo_Type = { }; static PyObject * -StructInfo_Convert(PyObject *obj) { +StructInfo_Convert_lock_held(PyObject *obj) { MsgspecState *mod = msgspec_get_global_state(); StructMetaObject *class; PyObject *annotations = NULL; @@ -6792,16 +6834,6 @@ StructInfo_Convert(PyObject *obj) { Py_INCREF(class); info->class = class; - /* Cache the new StuctInfo on the original type annotation */ - if (is_struct) { - Py_INCREF(info); - class->struct_info = info; - } - else { - if (PyObject_SetAttr(obj, mod->str___msgspec_cache__, (PyObject *)info) < 0) goto error; - } - cache_set = true; - /* Process all the struct fields */ for (Py_ssize_t i = 0; i < nfields; i++) { PyObject *field = PyTuple_GET_ITEM(class->struct_fields, i); @@ -6812,6 +6844,16 @@ StructInfo_Convert(PyObject *obj) { info->types[i] = type; } + /* Cache the new StuctInfo on the original type annotation */ + if (is_struct) { + Py_INCREF(info); + class->struct_info = info; + } + else { + if (PyObject_SetAttr(obj, mod->str___msgspec_cache__, (PyObject *)info) < 0) goto error; + } + cache_set = true; + Py_DECREF(class); Py_DECREF(annotations); PyObject_GC_Track(info); @@ -6839,6 +6881,15 @@ StructInfo_Convert(PyObject *obj) { return NULL; } +static PyObject * +StructInfo_Convert(PyObject *obj) { + PyObject *res = NULL; + Py_BEGIN_CRITICAL_SECTION(obj); + res = StructInfo_Convert_lock_held(obj); + Py_END_CRITICAL_SECTION(); + return res; +} + static int StructMeta_traverse(StructMetaObject *self, visitproc visit, void *arg) { @@ -10235,6 +10286,7 @@ ms_encode_err_type_unsupported(PyTypeObject *type) { ((PyDateTime_Time *)(o))->tzinfo : Py_None) #endif +#ifndef Py_GIL_DISABLED #ifndef TIMEZONE_CACHE_SIZE #define TIMEZONE_CACHE_SIZE 512 #endif @@ -10260,9 +10312,11 @@ timezone_cache_clear(void) { } } +#endif /* Py_GIL_DISABLED */ /* Returns a new reference */ static PyObject* timezone_from_offset(int32_t offset) { +#ifndef Py_GIL_DISABLED uint32_t index = ((uint32_t)offset) % TIMEZONE_CACHE_SIZE; if (timezone_cache[index].offset == offset) { PyObject *tz = timezone_cache[index].tz; @@ -10279,6 +10333,14 @@ timezone_from_offset(int32_t offset) { Py_INCREF(tz); timezone_cache[index].tz = tz; return tz; +#else + PyObject *delta = PyDelta_FromDSU(0, offset * 60, 0); + if (delta == NULL) return NULL; + PyObject *tz = PyTimeZone_FromOffset(delta); + Py_DECREF(delta); + return tz; +#endif + } static bool @@ -15595,6 +15657,7 @@ mpack_decode_key(DecoderState *self, TypeNode *type, PathNode *path) { char *str; if (MS_UNLIKELY(mpack_read(self, &str, size) < 0)) return NULL; +#ifndef Py_GIL_DISABLED /* Attempt a cache lookup. We don't know if it's ascii yet, but * checking if it's ascii is more expensive than just doing a lookup, * and most dict key strings are ascii */ @@ -15610,16 +15673,18 @@ mpack_decode_key(DecoderState *self, TypeNode *type, PathNode *path) { return existing; } } - +#endif /* Cache miss, create a new string */ PyObject *new = PyUnicode_DecodeUTF8(str, size, NULL); if (new == NULL) return NULL; /* If ascii, add it to the cache */ if (PyUnicode_IS_COMPACT_ASCII(new)) { - Py_XDECREF(existing); +#ifndef Py_GIL_DISABLED Py_INCREF(new); + Py_XDECREF(existing); string_cache[index] = new; +#endif } return new; } @@ -16166,7 +16231,9 @@ Decoder_decode(Decoder *self, PyObject *const *args, Py_ssize_t nargs) state.input_pos = buffer.buf; state.input_end = state.input_pos + buffer.len; - PyObject *res = mpack_decode(&state, state.type, NULL, false); + PyObject *res = NULL; + + res = mpack_decode(&state, state.type, NULL, false); if (res != NULL && mpack_has_trailing_characters(&state)) { Py_CLEAR(res); @@ -17302,7 +17369,7 @@ json_decode_dict_key(JSONDecoderState *self, TypeNode *type, PathNode *path) { size = json_decode_string_view(self, &view, &is_ascii); if (size < 0) return NULL; - +#ifndef Py_GIL_DISABLED bool cacheable = is_str && is_ascii && size > 0 && size <= STRING_CACHE_MAX_STRING_LENGTH; if (MS_UNLIKELY(!cacheable)) { return json_decode_dict_key_fallback(self, view, size, is_ascii, type, path); @@ -17331,6 +17398,9 @@ json_decode_dict_key(JSONDecoderState *self, TypeNode *type, PathNode *path) { Py_INCREF(new); string_cache[index] = new; return new; +#else + return json_decode_dict_key_fallback(self, view, size, is_ascii, type, path); +#endif } static PyObject * @@ -18394,6 +18464,7 @@ json_decode_struct_map_inner( if (MS_LIKELY(field_index >= 0)) { field_path.index = field_index; TypeNode *type = info->types[field_index]; + assert(type != NULL); val = json_decode(self, type, &field_path); if (val == NULL) goto error; Struct_set_index(out, field_index, val); @@ -19114,8 +19185,8 @@ JSONDecoder_decode(JSONDecoder *self, PyObject *const *args, Py_ssize_t nargs) state.input_pos = buffer.buf; state.input_end = state.input_pos + buffer.len; - PyObject *res = json_decode(&state, state.type, NULL); - + PyObject *res; + res = json_decode(&state, state.type, NULL); if (res != NULL && json_has_trailing_characters(&state)) { Py_CLEAR(res); } @@ -21976,8 +22047,10 @@ msgspec_traverse(PyObject *m, visitproc visit, void *arg) st->gc_cycle++; if (st->gc_cycle == 10) { st->gc_cycle = 0; +#ifndef Py_GIL_DISABLED string_cache_clear(); timezone_cache_clear(); +#endif } Py_VISIT(st->MsgspecError); @@ -22338,7 +22411,8 @@ PyInit__core(void) if (st->StructType == NULL) return NULL; Py_INCREF(st->StructType); if (PyModule_AddObject(m, "Struct", st->StructType) < 0) return NULL; - +#ifdef Py_GIL_DISABLED PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); +#endif return m; } From 81dc696ed1adb42076cba4e013782e0b4d0699ae Mon Sep 17 00:00:00 2001 From: Kumar Aditya Date: Sat, 13 Sep 2025 10:25:28 +0530 Subject: [PATCH 04/16] fmt --- msgspec/_core.c | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/msgspec/_core.c b/msgspec/_core.c index 0e084981..7f2ab0b2 100644 --- a/msgspec/_core.c +++ b/msgspec/_core.c @@ -6834,16 +6834,6 @@ StructInfo_Convert_lock_held(PyObject *obj) { Py_INCREF(class); info->class = class; - /* Process all the struct fields */ - for (Py_ssize_t i = 0; i < nfields; i++) { - PyObject *field = PyTuple_GET_ITEM(class->struct_fields, i); - PyObject *field_type = PyDict_GetItem(annotations, field); - if (field_type == NULL) goto error; - TypeNode *type = TypeNode_Convert(field_type); - if (type == NULL) goto error; - info->types[i] = type; - } - /* Cache the new StuctInfo on the original type annotation */ if (is_struct) { Py_INCREF(info); @@ -6854,6 +6844,16 @@ StructInfo_Convert_lock_held(PyObject *obj) { } cache_set = true; + /* Process all the struct fields */ + for (Py_ssize_t i = 0; i < nfields; i++) { + PyObject *field = PyTuple_GET_ITEM(class->struct_fields, i); + PyObject *field_type = PyDict_GetItem(annotations, field); + if (field_type == NULL) goto error; + TypeNode *type = TypeNode_Convert(field_type); + if (type == NULL) goto error; + info->types[i] = type; + } + Py_DECREF(class); Py_DECREF(annotations); PyObject_GC_Track(info); @@ -15876,6 +15876,7 @@ mpack_decode_struct_map( } } else { + PathNode field_path = {path, field_index, (PyObject *)st_type}; val = mpack_decode(self, info->types[field_index], &field_path, is_key); if (val == NULL) goto error; @@ -16231,9 +16232,7 @@ Decoder_decode(Decoder *self, PyObject *const *args, Py_ssize_t nargs) state.input_pos = buffer.buf; state.input_end = state.input_pos + buffer.len; - PyObject *res = NULL; - - res = mpack_decode(&state, state.type, NULL, false); + PyObject *res = mpack_decode(&state, state.type, NULL, false); if (res != NULL && mpack_has_trailing_characters(&state)) { Py_CLEAR(res); @@ -19185,8 +19184,8 @@ JSONDecoder_decode(JSONDecoder *self, PyObject *const *args, Py_ssize_t nargs) state.input_pos = buffer.buf; state.input_end = state.input_pos + buffer.len; - PyObject *res; - res = json_decode(&state, state.type, NULL); + PyObject *res = json_decode(&state, state.type, NULL); + if (res != NULL && json_has_trailing_characters(&state)) { Py_CLEAR(res); } From d239f4ab4c030c551207f0dad7ea71859859fe20 Mon Sep 17 00:00:00 2001 From: Kumar Aditya Date: Sat, 13 Sep 2025 11:25:36 +0530 Subject: [PATCH 05/16] use pyevent --- msgspec/_core.c | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/msgspec/_core.c b/msgspec/_core.c index 7f2ab0b2..823595b3 100644 --- a/msgspec/_core.c +++ b/msgspec/_core.c @@ -9,6 +9,8 @@ #include "Python.h" #include "datetime.h" #include "structmember.h" +#define Py_BUILD_CORE +#include "internal/pycore_lock.h" #include "common.h" #include "itoa.h" @@ -2959,6 +2961,7 @@ typedef struct { typedef struct StructInfo { PyObject_VAR_HEAD StructMetaObject *class; + PyEvent initialized; TypeNode *types[]; } StructInfo; @@ -2993,7 +2996,9 @@ static PyObject* NamedTupleInfo_Convert(PyObject*); static MS_INLINE StructInfo * TypeNode_get_struct_info(TypeNode *type) { /* Struct types are always first */ - return type->details[0].pointer; + StructInfo *info = type->details[0].pointer; + PyEvent_Wait(&info->initialized); + return info; } static MS_INLINE Lookup * @@ -6831,6 +6836,7 @@ StructInfo_Convert_lock_held(PyObject *obj) { for (Py_ssize_t i = 0; i < nfields; i++) { info->types[i] = NULL; } + info->initialized = (PyEvent){0}; Py_INCREF(class); info->class = class; @@ -6857,6 +6863,7 @@ StructInfo_Convert_lock_held(PyObject *obj) { Py_DECREF(class); Py_DECREF(annotations); PyObject_GC_Track(info); + _PyEvent_Notify(&info->initialized); return (PyObject *)info; error: From d302d39ff532388e0e559327d39259f029c1e2af Mon Sep 17 00:00:00 2001 From: Kumar Aditya Date: Sat, 13 Sep 2025 11:50:07 +0530 Subject: [PATCH 06/16] use atomics --- msgspec/_core.c | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/msgspec/_core.c b/msgspec/_core.c index 823595b3..b0e867d1 100644 --- a/msgspec/_core.c +++ b/msgspec/_core.c @@ -4,13 +4,12 @@ #include #include #include +#include #define PY_SSIZE_T_CLEAN #include "Python.h" #include "datetime.h" #include "structmember.h" -#define Py_BUILD_CORE -#include "internal/pycore_lock.h" #include "common.h" #include "itoa.h" @@ -2961,7 +2960,9 @@ typedef struct { typedef struct StructInfo { PyObject_VAR_HEAD StructMetaObject *class; - PyEvent initialized; +#ifdef Py_GIL_DISABLED + uint8_t initialized; +#endif TypeNode *types[]; } StructInfo; @@ -2997,7 +2998,16 @@ static MS_INLINE StructInfo * TypeNode_get_struct_info(TypeNode *type) { /* Struct types are always first */ StructInfo *info = type->details[0].pointer; - PyEvent_Wait(&info->initialized); +#ifdef Py_GIL_DISABLED + if (atomic_load(&info->initialized)) { + return info; + } + Py_BEGIN_ALLOW_THREADS + /* wait for the StructInfo to be fully initialized by other thread */ + while (!atomic_load(&info->initialized)) { + } + Py_END_ALLOW_THREADS +#endif return info; } @@ -6836,7 +6846,9 @@ StructInfo_Convert_lock_held(PyObject *obj) { for (Py_ssize_t i = 0; i < nfields; i++) { info->types[i] = NULL; } - info->initialized = (PyEvent){0}; +#ifdef Py_GIL_DISABLED + atomic_store(&info->initialized, 0); +#endif Py_INCREF(class); info->class = class; @@ -6863,7 +6875,9 @@ StructInfo_Convert_lock_held(PyObject *obj) { Py_DECREF(class); Py_DECREF(annotations); PyObject_GC_Track(info); - _PyEvent_Notify(&info->initialized); +#ifdef Py_GIL_DISABLED + atomic_store(&info->initialized, 1); +#endif return (PyObject *)info; error: From 0a3fad8db15c14f534be65ac96c79546d4c53c22 Mon Sep 17 00:00:00 2001 From: Kumar Aditya Date: Sat, 13 Sep 2025 12:19:26 +0530 Subject: [PATCH 07/16] add atomic specifier --- msgspec/_core.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msgspec/_core.c b/msgspec/_core.c index b0e867d1..067d718b 100644 --- a/msgspec/_core.c +++ b/msgspec/_core.c @@ -2961,7 +2961,7 @@ typedef struct StructInfo { PyObject_VAR_HEAD StructMetaObject *class; #ifdef Py_GIL_DISABLED - uint8_t initialized; + _Atomic(uint8_t) initialized; #endif TypeNode *types[]; } StructInfo; From bf7c3bff1b3bfa44b8ee2aaef54a9ce56f943d64 Mon Sep 17 00:00:00 2001 From: Kumar Aditya Date: Sat, 11 Oct 2025 13:37:49 +0530 Subject: [PATCH 08/16] test fixes --- tests/test_common.py | 26 +++++++++++++------------- tests/test_convert.py | 18 +++++++++--------- tests/test_json.py | 8 +++++--- tests/test_msgpack.py | 11 ++++++----- tests/test_struct.py | 23 ++++++++++++----------- 5 files changed, 45 insertions(+), 41 deletions(-) diff --git a/tests/test_common.py b/tests/test_common.py index 38898be3..10127945 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -1370,14 +1370,14 @@ class Ex(Struct, Generic[T]): dec = proto.Decoder(typ) info = typ.__msgspec_cache__ assert info is not None - assert sys.getrefcount(info) == 4 # info + attr + decoder + func call + assert sys.getrefcount(info) <= 4 # info + attr + decoder + func call dec2 = proto.Decoder(typ) assert typ.__msgspec_cache__ is info - assert sys.getrefcount(info) == 5 + assert sys.getrefcount(info) <= 5 del dec del dec2 - assert sys.getrefcount(info) == 3 + assert sys.getrefcount(info) <= 3 def test_generic_struct_invalid_types_not_cached(self, proto): class Ex(Struct, Generic[T]): @@ -1545,7 +1545,7 @@ class Ex2(Struct, array_like=array_like, tag=True): res = proto.decode(buf, type=typ) assert res == msg assert count == 2 # 1 for Ex(), 1 for decode - assert sys.getrefcount(singleton) == 2 # 1 for ref, 1 for call + assert sys.getrefcount(singleton) <= 2 # 1 for ref, 1 for call @pytest.mark.parametrize("array_like", [False, True]) @pytest.mark.parametrize("union", [False, True]) @@ -1606,14 +1606,14 @@ class Ex(Generic[T]): dec = proto.Decoder(typ) info = typ.__msgspec_cache__ assert info is not None - assert sys.getrefcount(info) == 4 # info + attr + decoder + func call + assert sys.getrefcount(info) <= 4 # info + attr + decoder + func call dec2 = proto.Decoder(typ) assert typ.__msgspec_cache__ is info - assert sys.getrefcount(info) == 5 + assert sys.getrefcount(info) <= 5 del dec del dec2 - assert sys.getrefcount(info) == 3 + assert sys.getrefcount(info) <= 3 def test_generic_invalid_types_not_cached(self, decorator, proto): @decorator @@ -2179,14 +2179,14 @@ class Ex(TypedDict, Generic[T]): dec = proto.Decoder(typ) info = typ.__msgspec_cache__ assert info is not None - assert sys.getrefcount(info) == 4 # info + attr + decoder + func call + assert sys.getrefcount(info) <= 4 # info + attr + decoder + func call dec2 = proto.Decoder(typ) assert typ.__msgspec_cache__ is info - assert sys.getrefcount(info) == 5 + assert sys.getrefcount(info) <= 5 del dec del dec2 - assert sys.getrefcount(info) == 3 + assert sys.getrefcount(info) <= 3 def test_generic_typeddict_invalid_types_not_cached(self, proto): TypedDict = pytest.importorskip("typing_extensions").TypedDict @@ -2398,14 +2398,14 @@ class Ex(NamedTuple, Generic[T]): dec = proto.Decoder(typ) info = typ.__msgspec_cache__ assert info is not None - assert sys.getrefcount(info) == 4 # info + attr + decoder + func call + assert sys.getrefcount(info) <= 4 # info + attr + decoder + func call dec2 = proto.Decoder(typ) assert typ.__msgspec_cache__ is info - assert sys.getrefcount(info) == 5 + assert sys.getrefcount(info) <= 5 del dec del dec2 - assert sys.getrefcount(info) == 3 + assert sys.getrefcount(info) <= 3 def test_generic_namedtuple_invalid_types_not_cached(self, proto): NamedTuple = pytest.importorskip("typing_extensions").NamedTuple diff --git a/tests/test_convert.py b/tests/test_convert.py index da1b664c..afb668ee 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -220,7 +220,7 @@ class Custom: x = Custom() res = convert(x, Any) assert res is x - assert sys.getrefcount(x) == 3 # x + res + 1 + assert sys.getrefcount(x) <= 3 # x + res + 1 def test_custom_input_type_works_with_custom(self): class Custom: @@ -229,7 +229,7 @@ class Custom: x = Custom() res = convert(x, Custom) assert res is x - assert sys.getrefcount(x) == 3 # x + res + 1 + assert sys.getrefcount(x) <= 3 # x + res + 1 def test_custom_input_type_works_with_dec_hook(self): class Custom: @@ -247,8 +247,8 @@ def dec_hook(typ, x): x = Custom() res = convert(x, Custom2, dec_hook=dec_hook) assert isinstance(res, Custom2) - assert sys.getrefcount(res) == 2 # res + 1 - assert sys.getrefcount(x) == 2 # x + 1 + assert sys.getrefcount(res) <= 2 # res + 1 + assert sys.getrefcount(x) <= 2 # x + 1 def test_unsupported_output_type(self): with pytest.raises(TypeError, match="more than one array-like"): @@ -397,7 +397,7 @@ class MyInt(int): x = MyInt(100) sol = convert(x, MyInt) assert sol is x - assert sys.getrefcount(x) == 3 # x + sol + 1 + assert sys.getrefcount(x) <= 3 # x + sol + 1 class TestFloat: @@ -535,10 +535,10 @@ class MyBytes(bytes): del sol - assert sys.getrefcount(msg) == 2 # msg + 1 + assert sys.getrefcount(msg) <= 2 # msg + 1 sol = convert(msg, MyBytes) assert sol is msg - assert sys.getrefcount(msg) == 3 # msg + sol + 1 + assert sys.getrefcount(msg) <= 3 # msg + sol + 1 class TestDateTime: @@ -828,7 +828,7 @@ class Ex(enum.IntEnum): msg = MyInt(1) assert convert(msg, Ex) is Ex.x - assert sys.getrefcount(msg) == 2 # msg + 1 + assert sys.getrefcount(msg) <= 2 # msg + 1 assert convert(MyInt(2), Ex) is Ex.y def test_enum_missing(self): @@ -2223,7 +2223,7 @@ class Ex2(Struct, array_like=array_like, tag=True): res = convert(msg, type=typ, from_attributes=from_attributes) assert type(res) is Ex assert called - assert sys.getrefcount(singleton) == 2 # 1 for ref, 1 for call + assert sys.getrefcount(singleton) <= 2 # 1 for ref, 1 for call @pytest.mark.parametrize("union", [False, True]) @pytest.mark.parametrize("exc_class", [ValueError, TypeError, OSError]) diff --git a/tests/test_json.py b/tests/test_json.py index 1d3776e5..dd3c7c7f 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -892,13 +892,14 @@ def test_decode_datetime_with_timezone(self, dt, sign, hour, minute): res = msgspec.json.decode(json_s, type=datetime.datetime) assert res == exp + @pytest.mark.skipif(hasattr(sys.flags, 'gil') and not sys.flags.gil, reason="cache is disabled without GIL") def test_decode_timezone_cache(self): msg = b'"2000-01-01T00:00:01+03:02"' tz = msgspec.json.decode(msg, type=datetime.datetime).tzinfo tz2 = msgspec.json.decode(msg, type=datetime.datetime).tzinfo assert tz is tz2 del tz2 - assert sys.getrefcount(tz) == 3 # 1 tz, 1 cache, 1 func call + assert sys.getrefcount(tz) <= 3 # 1 tz, 1 cache, 1 func call for _ in range(10): gc.collect() # cache is cleared every 10 full collections @@ -1966,6 +1967,7 @@ def test_decode_dict_str_key_constraints(self): dec.decode(b'{"a": 1}') @pytest.mark.parametrize("length", [3, 32, 33]) + @pytest.mark.skipif(hasattr(sys.flags, 'gil') and not sys.flags.gil, reason="cache is disabled without GIL") def test_decode_dict_string_cache(self, length): key = "x" * length msg = [{key: 1}, {key: 2}, {key: 3}] @@ -2293,7 +2295,7 @@ def test_decode_struct(self): assert x == Person("harry", "potter", 13, False) # one for struct, one for output of getattr, and one for getrefcount - assert sys.getrefcount(x.first) == 3 + assert sys.getrefcount(x.first) <= 3 with pytest.raises( msgspec.ValidationError, match="Expected `object`, got `int`" @@ -3025,7 +3027,7 @@ def test_decode_raw_from_str(self, wrap): r = msgspec.json.decode(msg, type=msgspec.Raw) assert bytes(r) == b'{"x": 1}' # Raw holds a ref to the original str - assert sys.getrefcount(msg) == c + 1 + assert sys.getrefcount(msg) <= c + 1 del r assert sys.getrefcount(msg) == c diff --git a/tests/test_msgpack.py b/tests/test_msgpack.py index b60477ca..6da8b879 100644 --- a/tests/test_msgpack.py +++ b/tests/test_msgpack.py @@ -547,6 +547,7 @@ class Test(msgspec.Struct): @pytest.mark.parametrize("length", [3, 31, 33]) @pytest.mark.parametrize("typed", [False, True]) + @pytest.mark.skipif(hasattr(sys.flags, 'gil') and not sys.flags.gil, reason="cache is disabled without GIL") def test_decode_dict_string_cache(self, length, typed): key = "x" * length msg = [{key: 1}, {key: 2}, {key: 3}] @@ -685,13 +686,13 @@ def test_decode_memoryview_zerocopy(self, input_type): assert bytes(res) == b"abcde" assert len(res) == 5 if input_type is memoryview: - assert sys.getrefcount(ref) == 3 + assert sys.getrefcount(ref) <= 3 del msg - assert sys.getrefcount(ref) == 3 + assert sys.getrefcount(ref) <= 3 del res - assert sys.getrefcount(ref) == 2 + assert sys.getrefcount(ref) <= 2 elif input_type is bytes: - assert sys.getrefcount(msg) == 3 + assert sys.getrefcount(msg) <= 3 def test_datetime_aware_ext(self): dec = msgspec.msgpack.Decoder(datetime.datetime) @@ -816,7 +817,7 @@ def test_vartuple_lengths(self, size): res = dec.decode(enc.encode(x)) assert res == x if res: - assert sys.getrefcount(res[0]) == 3 # 1 tuple, 1 index, 1 func call + assert sys.getrefcount(res[0]) <= 3 # 1 tuple, 1 index, 1 func call @pytest.mark.parametrize("typ", [tuple, Tuple, Tuple[Any, ...]]) def test_vartuple_any(self, typ): diff --git a/tests/test_struct.py b/tests/test_struct.py index 66971fa1..12eb70b7 100644 --- a/tests/test_struct.py +++ b/tests/test_struct.py @@ -931,16 +931,16 @@ class Test(Struct): data = [1, 2, 3] t = Test(data) - assert sys.getrefcount(data) == 3 + assert sys.getrefcount(data) <= 3 repr(t) - assert sys.getrefcount(data) == 3 + assert sys.getrefcount(data) <= 3 t2 = t.__copy__() - assert sys.getrefcount(data) == 4 + assert sys.getrefcount(data) <= 4 assert t == t2 - assert sys.getrefcount(data) == 4 + assert sys.getrefcount(data) <= 4 def test_struct_gc_not_added_if_not_needed(): @@ -987,6 +987,7 @@ class Test(Struct): class TestStructGC: + @pytest.mark.skipif(hasattr(sys.flags, 'gil') and not sys.flags.gil, reason="object layout is different on free-threading builds") def test_memory_layout(self): sizes = {} for has_gc in [False, True]: @@ -1124,15 +1125,15 @@ class Test2(Struct, gc=has_gc): orig_1 = sys.getrefcount(Test1) orig_2 = sys.getrefcount(Test2) t = Test1(1, 2) - assert sys.getrefcount(Test1) == orig_1 + 1 + assert sys.getrefcount(Test1) <= orig_1 + 1 del t - assert sys.getrefcount(Test1) == orig_1 + assert sys.getrefcount(Test1) <= orig_1 t = Test2(1, 2) - assert sys.getrefcount(Test1) == orig_1 - assert sys.getrefcount(Test2) == orig_2 + 1 + assert sys.getrefcount(Test1) <= orig_1 + assert sys.getrefcount(Test2) <= orig_2 + 1 del t - assert sys.getrefcount(Test1) == orig_1 - assert sys.getrefcount(Test2) == orig_2 + assert sys.getrefcount(Test1) <= orig_1 + assert sys.getrefcount(Test2) <= orig_2 gc.collect() assert sys.getrefcount(Test1) == orig_1 assert sys.getrefcount(Test2) == orig_2 @@ -2581,7 +2582,7 @@ def __post_init__(self): Ex(1) assert called # Return value is decref'd - assert sys.getrefcount(singleton) == 2 # 1 for ref, 1 for call + assert sys.getrefcount(singleton) <= 2 # 1 for ref, 1 for call def test_post_init_errors(self): class Ex(Struct): From 4f195f489ed98da1ad18654dda283060e750aef7 Mon Sep 17 00:00:00 2001 From: Kumar Aditya Date: Sat, 11 Oct 2025 13:47:27 +0530 Subject: [PATCH 09/16] fmt --- msgspec/_utils.py | 2 +- tests/test_json.py | 10 ++++++++-- tests/test_msgpack.py | 5 ++++- tests/test_struct.py | 5 ++++- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/msgspec/_utils.py b/msgspec/_utils.py index 534d17f5..7565610c 100644 --- a/msgspec/_utils.py +++ b/msgspec/_utils.py @@ -1,6 +1,5 @@ # type: ignore import collections -import inspect import sys import typing @@ -75,6 +74,7 @@ def _eval_type(t, globalns, localns): if sys.version_info >= (3, 10): from inspect import get_annotations as _get_class_annotations else: + def _get_class_annotations(cls): return cls.__dict__.get("__annotations__", {}) diff --git a/tests/test_json.py b/tests/test_json.py index dd3c7c7f..88ce5a30 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -892,7 +892,10 @@ def test_decode_datetime_with_timezone(self, dt, sign, hour, minute): res = msgspec.json.decode(json_s, type=datetime.datetime) assert res == exp - @pytest.mark.skipif(hasattr(sys.flags, 'gil') and not sys.flags.gil, reason="cache is disabled without GIL") + @pytest.mark.skipif( + hasattr(sys.flags, "gil") and not sys.flags.gil, + reason="cache is disabled without GIL", + ) def test_decode_timezone_cache(self): msg = b'"2000-01-01T00:00:01+03:02"' tz = msgspec.json.decode(msg, type=datetime.datetime).tzinfo @@ -1967,7 +1970,10 @@ def test_decode_dict_str_key_constraints(self): dec.decode(b'{"a": 1}') @pytest.mark.parametrize("length", [3, 32, 33]) - @pytest.mark.skipif(hasattr(sys.flags, 'gil') and not sys.flags.gil, reason="cache is disabled without GIL") + @pytest.mark.skipif( + hasattr(sys.flags, "gil") and not sys.flags.gil, + reason="cache is disabled without GIL", + ) def test_decode_dict_string_cache(self, length): key = "x" * length msg = [{key: 1}, {key: 2}, {key: 3}] diff --git a/tests/test_msgpack.py b/tests/test_msgpack.py index 6da8b879..ab5affd2 100644 --- a/tests/test_msgpack.py +++ b/tests/test_msgpack.py @@ -547,7 +547,10 @@ class Test(msgspec.Struct): @pytest.mark.parametrize("length", [3, 31, 33]) @pytest.mark.parametrize("typed", [False, True]) - @pytest.mark.skipif(hasattr(sys.flags, 'gil') and not sys.flags.gil, reason="cache is disabled without GIL") + @pytest.mark.skipif( + hasattr(sys.flags, "gil") and not sys.flags.gil, + reason="cache is disabled without GIL", + ) def test_decode_dict_string_cache(self, length, typed): key = "x" * length msg = [{key: 1}, {key: 2}, {key: 3}] diff --git a/tests/test_struct.py b/tests/test_struct.py index 12eb70b7..4d551626 100644 --- a/tests/test_struct.py +++ b/tests/test_struct.py @@ -987,7 +987,10 @@ class Test(Struct): class TestStructGC: - @pytest.mark.skipif(hasattr(sys.flags, 'gil') and not sys.flags.gil, reason="object layout is different on free-threading builds") + @pytest.mark.skipif( + hasattr(sys.flags, "gil") and not sys.flags.gil, + reason="object layout is different on free-threading builds", + ) def test_memory_layout(self): sizes = {} for has_gc in [False, True]: From 51598fd1971ca56933e1471f83dda5fe8b94bcea Mon Sep 17 00:00:00 2001 From: Kumar Aditya Date: Sat, 11 Oct 2025 14:33:54 +0530 Subject: [PATCH 10/16] final thread safety changes --- msgspec/_core.c | 144 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 116 insertions(+), 28 deletions(-) diff --git a/msgspec/_core.c b/msgspec/_core.c index 067d718b..8d9439f6 100644 --- a/msgspec/_core.c +++ b/msgspec/_core.c @@ -2633,18 +2633,24 @@ AssocList_FromDict(PyObject *dict) { PyObject *key, *val; Py_ssize_t pos = 0; + int err = 0; + Py_BEGIN_CRITICAL_SECTION(dict); while (PyDict_Next(dict, &pos, &key, &val)) { if (!PyUnicode_Check(key)) { PyErr_SetString( PyExc_TypeError, "Only dicts with str keys are supported when `order` is not `None`" ); - goto error; + err = 1; + break; + } + if (AssocList_Append(out, key, val) < 0) { + err = 1; + break; } - if (AssocList_Append(out, key, val) < 0) goto error; } - return out; -error: + Py_END_CRITICAL_SECTION(); + if (!err) return out; AssocList_Free(out); return NULL; } @@ -9206,22 +9212,32 @@ AssocList_FromObject(PyObject *obj) { } out = AssocList_New(max_size); + Py_BEGIN_CRITICAL_SECTION(obj); if (out == NULL) goto cleanup; - /* Append everything in `__dict__` */ if (dict != NULL) { PyObject *key, *val; Py_ssize_t pos = 0; + int err = 0; + Py_BEGIN_CRITICAL_SECTION(dict); while (PyDict_Next(dict, &pos, &key, &val)) { if (MS_LIKELY(PyUnicode_CheckExact(key))) { Py_ssize_t key_len; if (MS_UNLIKELY(val == UNSET)) continue; const char* key_buf = unicode_str_and_size(key, &key_len); - if (MS_UNLIKELY(key_buf == NULL)) goto cleanup; + if (MS_UNLIKELY(key_buf == NULL)) { + err = 1; + break; + } if (MS_UNLIKELY(*key_buf == '_')) continue; - if (MS_UNLIKELY(AssocList_Append(out, key, val) < 0)) goto cleanup; + if (MS_UNLIKELY(AssocList_Append(out, key, val) < 0)) { + err = 1; + break; + } } } + Py_END_CRITICAL_SECTION(); + if (MS_UNLIKELY(err)) goto cleanup; } /* Then append everything in slots */ type = Py_TYPE(obj); @@ -9246,6 +9262,7 @@ AssocList_FromObject(PyObject *obj) { cleanup: Py_XDECREF(dict); + Py_END_CRITICAL_SECTION(); Py_LeaveRecursiveCall(); if (!ok) { AssocList_Free(out); @@ -12490,8 +12507,12 @@ static int mpack_encode_bytearray(EncoderState *self, PyObject *obj) { Py_ssize_t len = PyByteArray_GET_SIZE(obj); + int ret = 0; + Py_BEGIN_CRITICAL_SECTION(obj); const char* buf = PyByteArray_AS_STRING(obj); - return mpack_encode_bin(self, buf, len); + ret = mpack_encode_bin(self, buf, len); + Py_END_CRITICAL_SECTION(); + return ret; } static int @@ -12559,12 +12580,14 @@ mpack_encode_list(EncoderState *self, PyObject *obj) if (mpack_encode_array_header(self, len, "list") < 0) return -1; if (Py_EnterRecursiveCall(" while serializing an object")) return -1; + Py_BEGIN_CRITICAL_SECTION(obj); for (i = 0; i < len; i++) { if (mpack_encode_inline(self, PyList_GET_ITEM(obj, i)) < 0) { status = -1; break; } } + Py_END_CRITICAL_SECTION(); Py_LeaveRecursiveCall(); return status; } @@ -12703,12 +12726,14 @@ mpack_encode_dict(EncoderState *self, PyObject *obj) if (mpack_encode_map_header(self, len, "dicts") < 0) return -1; if (Py_EnterRecursiveCall(" while serializing an object")) return -1; + Py_BEGIN_CRITICAL_SECTION(obj); while (PyDict_Next(obj, &pos, &key, &val)) { if (mpack_encode_dict_key_inline(self, key) < 0) goto cleanup; if (mpack_encode_inline(self, val) < 0) goto cleanup; } status = 0; -cleanup: +cleanup:; + Py_END_CRITICAL_SECTION(); Py_LeaveRecursiveCall(); return status; } @@ -12805,23 +12830,36 @@ mpack_encode_object(EncoderState *self, PyObject *obj) /* Cache header offset in case we need to adjust the header after writing */ Py_ssize_t header_offset = self->output_len; if (mpack_encode_map_header(self, max_size, "objects") < 0) goto cleanup; - + Py_BEGIN_CRITICAL_SECTION(obj); /* First encode everything in `__dict__` */ if (dict != NULL) { PyObject *key, *val; Py_ssize_t pos = 0; + int err = 0; + Py_BEGIN_CRITICAL_SECTION(dict); while (PyDict_Next(dict, &pos, &key, &val)) { if (MS_LIKELY(PyUnicode_CheckExact(key))) { Py_ssize_t key_len; if (MS_UNLIKELY(val == UNSET)) continue; const char* key_buf = unicode_str_and_size(key, &key_len); - if (MS_UNLIKELY(key_buf == NULL)) goto cleanup; + if (MS_UNLIKELY(key_buf == NULL)) { + err = 1; + break; + } if (MS_UNLIKELY(*key_buf == '_')) continue; - if (MS_UNLIKELY(mpack_encode_cstr(self, key_buf, key_len) < 0)) goto cleanup; - if (MS_UNLIKELY(mpack_encode(self, val) < 0)) goto cleanup; + if (MS_UNLIKELY(mpack_encode_cstr(self, key_buf, key_len) < 0)) { + err = 1; + break; + } + if (MS_UNLIKELY(mpack_encode(self, val) < 0)) { + err = 1; + break; + } size++; } } + Py_END_CRITICAL_SECTION(); + if (MS_UNLIKELY(err)) goto cleanup; } /* Then encode everything in slots */ type = Py_TYPE(obj); @@ -12861,6 +12899,7 @@ mpack_encode_object(EncoderState *self, PyObject *obj) status = 0; cleanup: Py_XDECREF(dict); + Py_END_CRITICAL_SECTION(); Py_LeaveRecursiveCall(); return status; } @@ -13835,9 +13874,13 @@ json_encode_sequence(EncoderState *self, Py_ssize_t size, PyObject **arr) static MS_NOINLINE int json_encode_list(EncoderState *self, PyObject *obj) { - return json_encode_sequence( + int ret; + Py_BEGIN_CRITICAL_SECTION(obj); + ret = json_encode_sequence( self, PyList_GET_SIZE(obj), ((PyListObject *)obj)->ob_item ); + Py_END_CRITICAL_SECTION(); + return ret; } static MS_NOINLINE int @@ -14012,6 +14055,7 @@ json_encode_dict(EncoderState *self, PyObject *obj) if (ms_write(self, "{", 1) < 0) return -1; if (Py_EnterRecursiveCall(" while serializing an object")) return -1; + Py_BEGIN_CRITICAL_SECTION(obj); while (PyDict_Next(obj, &pos, &key, &val)) { if (json_encode_dict_key(self, key) < 0) goto cleanup; if (ms_write(self, ":", 1) < 0) goto cleanup; @@ -14022,6 +14066,7 @@ json_encode_dict(EncoderState *self, PyObject *obj) *(self->output_buffer_raw + self->output_len - 1) = '}'; status = 0; cleanup: + Py_END_CRITICAL_SECTION(); Py_LeaveRecursiveCall(); return status; } @@ -14091,25 +14136,45 @@ json_encode_object(EncoderState *self, PyObject *obj) if (Py_EnterRecursiveCall(" while serializing an object")) return -1; /* First encode everything in `__dict__` */ PyObject *dict = PyObject_GenericGetDict(obj, NULL); + Py_BEGIN_CRITICAL_SECTION(obj); if (MS_UNLIKELY(dict == NULL)) { PyErr_Clear(); } else { PyObject *key, *val; Py_ssize_t pos = 0; + int err = 0; + Py_BEGIN_CRITICAL_SECTION(dict); while (PyDict_Next(dict, &pos, &key, &val)) { if (MS_LIKELY(PyUnicode_CheckExact(key))) { Py_ssize_t key_len; const char* key_buf = unicode_str_and_size(key, &key_len); if (MS_UNLIKELY(val == UNSET)) continue; - if (MS_UNLIKELY(key_buf == NULL)) goto cleanup; + if (MS_UNLIKELY(key_buf == NULL)) { + err = 1; + break; + } if (MS_UNLIKELY(*key_buf == '_')) continue; - if (MS_UNLIKELY(json_encode_cstr_noescape(self, key_buf, key_len) < 0)) goto cleanup; - if (MS_UNLIKELY(ms_write(self, ":", 1) < 0)) goto cleanup; - if (MS_UNLIKELY(json_encode(self, val) < 0)) goto cleanup; - if (MS_UNLIKELY(ms_write(self, ",", 1) < 0)) goto cleanup; + if (MS_UNLIKELY(json_encode_cstr_noescape(self, key_buf, key_len) < 0)) { + err = 1; + break; + } + if (MS_UNLIKELY(ms_write(self, ":", 1) < 0)) { + err = 1; + break; + } + if (MS_UNLIKELY(json_encode(self, val) < 0)) { + err = 1; + break; + } + if (MS_UNLIKELY(ms_write(self, ",", 1) < 0)) { + err = 1; + break; + } } } + Py_END_CRITICAL_SECTION(); + if (MS_UNLIKELY(err)) goto cleanup; } /* Then encode everything in slots */ PyTypeObject *type = Py_TYPE(obj); @@ -14142,6 +14207,7 @@ json_encode_object(EncoderState *self, PyObject *obj) status = ms_write(self, "}", 1); } cleanup: + Py_END_CRITICAL_SECTION(); Py_XDECREF(dict); Py_LeaveRecursiveCall(); return status; @@ -19688,6 +19754,7 @@ to_builtins_dict(ToBuiltinsState *self, PyObject *obj) { PyObject *new_key = NULL, *new_val = NULL, *key, *val; bool ok = false; PyObject *out = PyDict_New(); + Py_BEGIN_CRITICAL_SECTION(obj); if (out == NULL) goto cleanup; Py_ssize_t pos = 0; @@ -19721,6 +19788,7 @@ to_builtins_dict(ToBuiltinsState *self, PyObject *obj) { ok = true; cleanup: + Py_END_CRITICAL_SECTION(); Py_LeaveRecursiveCall(); if (!ok) { Py_CLEAR(out); @@ -19861,6 +19929,7 @@ to_builtins_object(ToBuiltinsState *self, PyObject *obj) { if (Py_EnterRecursiveCall(" while serializing an object")) return NULL; out = PyDict_New(); + Py_BEGIN_CRITICAL_SECTION(obj); if (out == NULL) goto cleanup; /* First encode everything in `__dict__` */ @@ -19871,21 +19940,34 @@ to_builtins_object(ToBuiltinsState *self, PyObject *obj) { else { PyObject *key, *val; Py_ssize_t pos = 0; + int err = 0; + Py_BEGIN_CRITICAL_SECTION(dict); while (PyDict_Next(dict, &pos, &key, &val)) { if (MS_LIKELY(PyUnicode_CheckExact(key))) { Py_ssize_t key_len; if (MS_UNLIKELY(val == UNSET)) continue; const char* key_buf = unicode_str_and_size(key, &key_len); - if (MS_UNLIKELY(key_buf == NULL)) goto cleanup; + if (MS_UNLIKELY(key_buf == NULL)) { + err = 1; + break; + } if (MS_UNLIKELY(*key_buf == '_')) continue; PyObject *val2 = to_builtins(self, val, false); - if (val2 == NULL) goto cleanup; + if (val2 == NULL) { + err = 1; + break; + } int status = PyDict_SetItem(out, key, val2); Py_DECREF(val2); - if (status < 0) goto cleanup; + if (status < 0) { + err = 1; + break; + } } } + Py_END_CRITICAL_SECTION(); + if (MS_UNLIKELY(err)) goto cleanup; } /* Then encode everything in slots */ PyTypeObject *type = Py_TYPE(obj); @@ -19924,6 +20006,7 @@ to_builtins_object(ToBuiltinsState *self, PyObject *obj) { cleanup: Py_XDECREF(dict); + Py_END_CRITICAL_SECTION(); Py_LeaveRecursiveCall(); if (!ok) { Py_CLEAR(out); @@ -21345,23 +21428,28 @@ static PyObject * convert_dict( ConvertState *self, PyObject *obj, TypeNode *type, PathNode *path ) { + PyObject *res = NULL; + Py_BEGIN_CRITICAL_SECTION(obj); if (type->types & MS_TYPE_DICT) { - return convert_dict_to_dict(self, obj, type, path); + res = convert_dict_to_dict(self, obj, type, path); } else if (type->types & MS_TYPE_STRUCT) { StructInfo *info = TypeNode_get_struct_info(type); - return convert_dict_to_struct(self, obj, info, path, false); + res = convert_dict_to_struct(self, obj, info, path, false); } else if (type->types & MS_TYPE_STRUCT_UNION) { - return convert_dict_to_struct_union(self, obj, type, path); + res = convert_dict_to_struct_union(self, obj, type, path); } else if (type->types & MS_TYPE_TYPEDDICT) { - return convert_dict_to_typeddict(self, obj, type, path); + res = convert_dict_to_typeddict(self, obj, type, path); } else if (type->types & MS_TYPE_DATACLASS) { - return convert_dict_to_dataclass(self, obj, type, path); + res = convert_dict_to_dataclass(self, obj, type, path); + } else { + res = ms_validation_error("object", type, path); } - return ms_validation_error("object", type, path); + Py_END_CRITICAL_SECTION(); + return res; } static PyObject * From e6bef684d4350f4e49b622436a8f9c3937d58013 Mon Sep 17 00:00:00 2001 From: Kumar Aditya Date: Mon, 20 Oct 2025 09:27:46 +0530 Subject: [PATCH 11/16] fix merge --- msgspec/_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/msgspec/_utils.py b/msgspec/_utils.py index e9f836bc..665c5c1f 100644 --- a/msgspec/_utils.py +++ b/msgspec/_utils.py @@ -71,8 +71,8 @@ def _eval_type(t, globalns, localns): _eval_type = typing._eval_type -if sys.version_info >= (3, 10): - from inspect import get_annotations as _get_class_annotations +if sys.version_info >= (3, 14): + from annotationlib import get_annotations as _get_class_annotations else: def _get_class_annotations(cls): From 2554635906948647c7d76dae024fe22a65ab3432 Mon Sep 17 00:00:00 2001 From: Kumar Aditya Date: Mon, 20 Oct 2025 09:30:51 +0530 Subject: [PATCH 12/16] fix msvc --- setup.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/setup.py b/setup.py index 2a92252f..fd86dc3c 100644 --- a/setup.py +++ b/setup.py @@ -39,6 +39,13 @@ if DEBUG: extra_compile_args.extend(["-O0", "-g", "-UNDEBUG"]) +# from https://py-free-threading.github.io/faq/#im-trying-to-build-a-library-on-windows-but-msvc-says-c-atomic-support-is-not-enabled +if sys.platform == "win32": + extra_compile_args.extend([ + "/std:c11", + "/experimental:c11atomics", + ]) + ext_modules = [ Extension( "msgspec._core", From 43c7fbfa3b213736fb78189020f45f17d171b178 Mon Sep 17 00:00:00 2001 From: Kumar Aditya Date: Mon, 20 Oct 2025 09:34:39 +0530 Subject: [PATCH 13/16] fmt --- setup.py | 10 ++++++---- tests/test_common.py | 1 + 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index fd86dc3c..ec5c8d6b 100644 --- a/setup.py +++ b/setup.py @@ -41,10 +41,12 @@ # from https://py-free-threading.github.io/faq/#im-trying-to-build-a-library-on-windows-but-msvc-says-c-atomic-support-is-not-enabled if sys.platform == "win32": - extra_compile_args.extend([ - "/std:c11", - "/experimental:c11atomics", - ]) + extra_compile_args.extend( + [ + "/std:c11", + "/experimental:c11atomics", + ] + ) ext_modules = [ Extension( diff --git a/tests/test_common.py b/tests/test_common.py index 9a870306..b405a3fc 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -2124,6 +2124,7 @@ def test_broken_typeddict(self, proto, use_typing_extensions): class Ex(cls, total=False): c: str + Ex.__annotations__ = {"c": "str"} Ex.__required_keys__ = {"a", "b"} From 0f82709c3b0cf1e1d62ceec2c0202c0e78674334 Mon Sep 17 00:00:00 2001 From: Kumar Aditya Date: Mon, 20 Oct 2025 09:42:13 +0530 Subject: [PATCH 14/16] fix build on 3.11 --- msgspec/_core.c | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/msgspec/_core.c b/msgspec/_core.c index b7b2a96f..2d0cdaa4 100644 --- a/msgspec/_core.c +++ b/msgspec/_core.c @@ -91,6 +91,9 @@ ms_popcount(uint64_t i) { \ #endif // SIZEOF_VOID_P > 4 #endif // Py_GIL_DISABLED #else +#ifndef _Py_IMMORTAL_REFCNT +#define _Py_IMMORTAL_REFCNT 999999999 +#endif #define _PyObject_HEAD_INIT(type) \ { \ .ob_refcnt = _Py_IMMORTAL_REFCNT, \ @@ -108,7 +111,8 @@ PyDict_GetItemRef(PyObject *mp, PyObject *key, PyObject **result) PyObject *item = _PyDict_GetItemWithError(mp, key); #endif if (item != NULL) { - *result = Py_NewRef(item); + Py_INCREF(item); + *result = item; return 1; // found } if (!PyErr_Occurred()) { From 9fafd82b6d3c796d72d2be4b7e52c4fc5206399d Mon Sep 17 00:00:00 2001 From: Kumar Aditya Date: Mon, 20 Oct 2025 09:50:31 +0530 Subject: [PATCH 15/16] fix gcc not supporting empty labels --- msgspec/_core.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/msgspec/_core.c b/msgspec/_core.c index 2d0cdaa4..6de39d6d 100644 --- a/msgspec/_core.c +++ b/msgspec/_core.c @@ -14072,7 +14072,7 @@ json_encode_dict(EncoderState *self, PyObject *obj) /* Overwrite trailing comma with } */ *(self->output_buffer_raw + self->output_len - 1) = '}'; status = 0; -cleanup: +cleanup:; Py_END_CRITICAL_SECTION(); Py_LeaveRecursiveCall(); return status; @@ -14213,7 +14213,7 @@ json_encode_object(EncoderState *self, PyObject *obj) else { status = ms_write(self, "}", 1); } -cleanup: +cleanup:; Py_END_CRITICAL_SECTION(); Py_XDECREF(dict); Py_LeaveRecursiveCall(); @@ -19794,7 +19794,7 @@ to_builtins_dict(ToBuiltinsState *self, PyObject *obj) { } ok = true; -cleanup: +cleanup:; Py_END_CRITICAL_SECTION(); Py_LeaveRecursiveCall(); if (!ok) { From 746c159bb25ede0d93bf86330edf8ddcc555d48b Mon Sep 17 00:00:00 2001 From: Kumar Aditya Date: Mon, 20 Oct 2025 10:28:50 +0530 Subject: [PATCH 16/16] add 3.14t CI --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ec9eb8b..f1f9e4e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,7 @@ jobs: env: CIBW_TEST_EXTRAS: "test" CIBW_TEST_COMMAND: "pytest {project}/tests" - CIBW_BUILD: "cp39-* cp310-* cp311-* cp312-* cp313-* cp314-*" + CIBW_BUILD: "cp39-* cp310-* cp311-* cp312-* cp313-* cp314-* cp314t-*" CIBW_SKIP: "*-win32 *_i686 *_s390x *_ppc64le" CIBW_ARCHS_MACOS: "x86_64 arm64" CIBW_ARCHS_LINUX: "x86_64 aarch64" @@ -100,7 +100,7 @@ jobs: - name: Set up Environment if: github.event_name != 'release' run: | - echo "CIBW_SKIP=${CIBW_SKIP} *-musllinux_* cp39-*_aarch64 cp311-*_aarch64 cp312-*_aarch64 cp313-*_aarch64 cp314-*_aarch64" >> $GITHUB_ENV + echo "CIBW_SKIP=${CIBW_SKIP} *-musllinux_* cp39-*_aarch64 cp311-*_aarch64 cp312-*_aarch64 cp313-*_aarch64 cp314-*_aarch64 cp314t-*_aarch64" >> $GITHUB_ENV - name: Build & Test Wheels uses: pypa/cibuildwheel@v3.2.1