Skip to content

Memory leak in test__interpchannels: _PyXIData_New not freed in channel_send #140306

@ashm-dev

Description

@ashm-dev

Bug 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:

  1. Build Python with address sanitizer:
CC=clang CXX=clang++ ./configure --with-address-sanitizer --with-pydebug --with-undefined-behavior-sanitizer --disable-optimizations && make -j$(nproc)
  1. 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:

  1. 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;
    }

  2. 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;
    }

  3. _sharednsitem_set_value():

    cpython/Python/crossinterp.c

    Lines 2065 to 2084 in d86ad87

    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;
    }

  4. _copy_string_obj_raw() via _PyXI_InitFailure():

    cpython/Python/crossinterp.c

    Lines 1041 to 1065 in d86ad87

    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;
    }

    cpython/Python/crossinterp.c

    Lines 1805 to 1825 in d86ad87

    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

Metadata

Metadata

Assignees

No one assigned

    Labels

    3.13bugs and security fixes3.14bugs and security fixes3.15new features, bugs and security fixesinterpreter-core(Objects, Python, Grammar, and Parser dirs)type-bugAn unexpected behavior, bug, or error

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions