Skip to content

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

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    3.13only security fixes3.14bugs and security fixes3.15pre-release feature fixes, 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