GeezerVison is showing github.com — Memory leak in test__interpchannels: _PyXIData_New not freed in channel_send · Issue #140306 · python/cpython
Memory leak in test__interpchannels: _PyXIData_New not freed in channel_send #140306Labels 3.13only security fixesonly security fixes3.14bugs and security fixesbugs and security fixes3.15bugs and security fixesbugs and security fixesinterpreter-core(Objects, Python, Grammar, and Parser dirs)(Objects, Python, Grammar, and Parser dirs)type-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error DescriptionBug report
Bug description:
LeakSanitizer detects memory leaks in the test__interpchannels test module. The leaks occur in _PyXIData_New() function called from channel_send() in Modules/_interpchannelsmodule.c.
Affected versions:
- Python 3.13
- Python 3.14
- Python main branch
Steps to reproduce:
- Build Python with address sanitizer:
CC=clang CXX=clang++ ./configure --with-address-sanitizer --with-pydebug --with-undefined-behavior-sanitizer --disable-optimizations && make -j$(nproc)
- Run the test:
./python -X dev -X showrefcount -m test test__interpchannels -j$(nproc)
Expected behavior:
No memory leaks detected.
Actual behavior:
LeakSanitizer reports multiple direct memory leaks. The exact number of leaked bytes and allocations varies between Python versions.
Problematic code locations:
All leaks originate from _PyXIData_New():
|
_PyXIData_t * |
|
_PyXIData_New(void) |
|
{ |
|
_PyXIData_t *xid = PyMem_RawCalloc(1, sizeof(_PyXIData_t)); |
|
if (xid == NULL) { |
|
PyErr_NoMemory(); |
|
} |
|
return xid; |
|
} |
Called through:
-
channel_send():
|
static int |
|
channel_send(_channels *channels, int64_t cid, PyObject *obj, |
|
_waiting_t *waiting, unboundop_t unboundop, xidata_fallback_t fallback) |
|
{ |
|
PyThreadState *tstate = _PyThreadState_GET(); |
|
PyInterpreterState *interp = tstate->interp; |
|
int64_t interpid = PyInterpreterState_GetID(interp); |
|
|
|
// Look up the channel. |
|
PyThread_type_lock mutex = NULL; |
|
_channel_state *chan = NULL; |
|
int err = _channels_lookup(channels, cid, &mutex, &chan); |
|
if (err != 0) { |
|
return err; |
|
} |
|
assert(chan != NULL); |
|
// Past this point we are responsible for releasing the mutex. |
|
|
|
if (chan->closing != NULL) { |
|
PyThread_release_lock(mutex); |
|
return ERR_CHANNEL_CLOSED; |
|
} |
|
|
|
// Convert the object to cross-interpreter data. |
|
_PyXIData_t *data = _PyXIData_New(); |
|
if (data == NULL) { |
|
PyThread_release_lock(mutex); |
|
return -1; |
|
} |
|
if (_PyObject_GetXIData(tstate, obj, fallback, data) != 0) { |
|
PyThread_release_lock(mutex); |
|
GLOBAL_FREE(data); |
|
return -1; |
|
} |
|
|
|
// Add the data to the channel. |
|
int res = _channel_add(chan, interpid, data, waiting, unboundop); |
|
PyThread_release_lock(mutex); |
|
if (res != 0) { |
|
// We may chain an exception here: |
|
(void)_release_xid_data(data, 0); |
|
GLOBAL_FREE(data); |
|
return res; |
|
} |
|
|
|
return 0; |
|
} |
-
channel_send_wait():
|
// Like channel_send(), but strictly wait for the object to be received. |
|
static int |
|
channel_send_wait(_channels *channels, int64_t cid, PyObject *obj, |
|
unboundop_t unboundop, PY_TIMEOUT_T timeout, |
|
xidata_fallback_t fallback) |
|
{ |
|
// We use a stack variable here, so we must ensure that &waiting |
|
// is not held by any channel item at the point this function exits. |
|
_waiting_t waiting; |
|
if (_waiting_init(&waiting) < 0) { |
|
assert(PyErr_Occurred()); |
|
return -1; |
|
} |
|
|
|
/* Queue up the object. */ |
|
int res = channel_send(channels, cid, obj, &waiting, unboundop, fallback); |
|
if (res < 0) { |
|
assert(waiting.status == WAITING_NO_STATUS); |
|
goto finally; |
|
} |
|
|
|
/* Wait until the object is received. */ |
|
if (wait_for_lock(waiting.mutex, timeout) < 0) { |
|
assert(PyErr_Occurred()); |
|
_waiting_finish_releasing(&waiting); |
|
/* The send() call is failing now, so make sure the item |
|
won't be received. */ |
|
channel_clear_sent(channels, cid, &waiting); |
|
assert(waiting.status == WAITING_RELEASED); |
|
if (!waiting.received) { |
|
res = -1; |
|
goto finally; |
|
} |
|
// XXX Emit a warning if not a TimeoutError? |
|
PyErr_Clear(); |
|
} |
|
else { |
|
_waiting_finish_releasing(&waiting); |
|
assert(waiting.status == WAITING_RELEASED); |
|
if (!waiting.received) { |
|
res = ERR_CHANNEL_CLOSED_WAITING; |
|
goto finally; |
|
} |
|
} |
|
|
|
/* success! */ |
|
res = 0; |
|
|
|
finally: |
|
_waiting_clear(&waiting); |
|
return res; |
|
} |
-
_sharednsitem_set_value():
|
static int |
|
_sharednsitem_set_value(_PyXI_namespace_item *item, PyObject *value, |
|
xidata_fallback_t fallback) |
|
{ |
|
assert(_sharednsitem_is_initialized(item)); |
|
assert(item->xidata == NULL); |
|
item->xidata = _PyXIData_New(); |
|
if (item->xidata == NULL) { |
|
return -1; |
|
} |
|
PyThreadState *tstate = PyThreadState_Get(); |
|
if (_PyObject_GetXIData(tstate, value, fallback, item->xidata) < 0) { |
|
PyMem_RawFree(item->xidata); |
|
item->xidata = NULL; |
|
// The caller may want to propagate PyExc_NotShareableError |
|
// if currently switched between interpreters. |
|
return -1; |
|
} |
|
return 0; |
|
} |
-
_copy_string_obj_raw() via _PyXI_InitFailure():
|
static const char * |
|
_copy_string_obj_raw(PyObject *strobj, Py_ssize_t *p_size) |
|
{ |
|
Py_ssize_t size = -1; |
|
const char *str = PyUnicode_AsUTF8AndSize(strobj, &size); |
|
if (str == NULL) { |
|
return NULL; |
|
} |
|
|
|
if (size != (Py_ssize_t)strlen(str)) { |
|
PyErr_SetString(PyExc_ValueError, "found embedded NULL character"); |
|
return NULL; |
|
} |
|
|
|
char *copied = PyMem_RawMalloc(size+1); |
|
if (copied == NULL) { |
|
PyErr_NoMemory(); |
|
return NULL; |
|
} |
|
strcpy(copied, str); |
|
if (p_size != NULL) { |
|
*p_size = size; |
|
} |
|
return copied; |
|
} |
|
int |
|
_PyXI_InitFailure(_PyXI_failure *failure, _PyXI_errcode code, PyObject *obj) |
|
{ |
|
PyObject *msgobj = PyObject_Str(obj); |
|
if (msgobj == NULL) { |
|
return -1; |
|
} |
|
// This will leak if not paired with clear_xi_failure(). |
|
// That happens automatically in _capture_current_exception(). |
|
const char *msg = _copy_string_obj_raw(msgobj, NULL); |
|
Py_DECREF(msgobj); |
|
if (PyErr_Occurred()) { |
|
return -1; |
|
} |
|
*failure = (_PyXI_failure){ |
|
.code = code, |
|
.msg = msg, |
|
.msg_owned = 1, |
|
}; |
|
return 0; |
|
} |
Analysis:
The allocated memory in _PyXIData_New() is not being properly freed. The function allocates memory using _PyMem_DebugRawAlloc() but the corresponding cleanup appears to be missing in error paths or normal execution flow.
Full leak reports:
See attached log files for detailed stack traces on Python 3.13, 3.14, and main branch.
313.log
314.log
main.log
CPython versions tested on:
3.13, 3.14, CPython main branch
Operating systems tested on:
Linux
Linked PRs
Reacted by Mikhail Efimov Activityserhiy-storchaka commented on Oct 20, 2025 Please check that all leaks are gone in 3.13. Not all changes were backported to 3.13, because the code was too different. ashm-dev commented on Oct 20, 2025 ContributorAuthor More actions serhiy-storchaka commented on Oct 20, 2025 Do you mind to look what is missed and create a PR, @ashm-dev? serhiy-storchaka commented on Jan 12, 2026 Thank you for your report, and your fix, and chasing the runaway bugs in 3.13.
MetadataMetadata3.13only security fixesonly security fixes3.14bugs and security fixesbugs and security fixes3.15bugs and security fixesbugs and security fixesinterpreter-core(Objects, Python, Grammar, and Parser dirs)(Objects, Python, Grammar, and Parser dirs)type-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error DevelopmentNo branches or pull requestsIssue actions
|