My C extension is leaking really badly

I have this newtype_init.c file which contains a class NewInit, it is a descriptor attempting to replace the constructor of a type.

#define PY_SSIZE_T_CLEAN
#include "newtype_init.h"

#include <Python.h>
#include <stddef.h>

#include "newtype_meth.h"
#include "structmember.h"

#define NEWTYPE_INIT_ARGS_STR "_newtype_init_args_"
#define NEWTYPE_INIT_KWARGS_STR "_newtype_init_kwargs_"

static int NewInit_init(NewInitObject* self, PyObject* args, PyObject* kwds)
{
  PyObject* func;

  if (!PyArg_ParseTuple(args, "O", &func)) {
    return -1;
  }

  if (PyObject_HasAttrString(func, "__get__")) {
    self->func_get = PyObject_GetAttrString(func, "__get__");
    self->has_get = 1;
  } else {
    self->func_get = func;
    self->has_get = 0;
  }

  if (PyErr_Occurred()) {
    return -1;
  }

  return 0;
}

static PyObject* NewInit_get(NewInitObject* self,
                             PyObject* inst,
                             PyObject* owner)
{
  Py_XDECREF(self->obj);  // Decrease reference to old object
  Py_XDECREF(self->cls);  // Decrease reference to old class

  self->obj = inst;
  Py_XINCREF(self->obj);
  self->cls = (PyTypeObject*)owner;
  Py_XINCREF(self->cls);


  if (self->obj == NULL) {
    if (self->func_get != NULL) {
      if (self->has_get) {
        // printf("`self->has_get`: %d\n", self->has_get);
        return PyObject_CallFunctionObjArgs(
            self->func_get, Py_None, self->cls, NULL);
      }
      return self->func_get;
    }
    PyErr_SetString(
        PyExc_TypeError,
        "`NewTypeMethod` object has no `func_get`; this is an internal C-API "
        "error - please report this as an issue to the author on GitHub");
  }

  Py_XINCREF(self);
  return (PyObject*)self;
}

static PyObject* NewInit_call(NewInitObject* self,
                              PyObject* args,
                              PyObject* kwds)
{

  PyObject* result; // return this
  PyObject* func;

  if (self->has_get) {
    if (self->obj == NULL && self->cls == NULL) {
      // free standing function
      PyErr_SetString(PyExc_TypeError,
                      "`NewInit` object has no `obj` (internal attribute) or `cls` (internal attribute)," 
                      "it cannot be used to wrap a free standing function");
      return NULL; // allocated nothing so no need to free
    } else if (self->obj == NULL) {
      func = PyObject_CallFunctionObjArgs(
          self->func_get, Py_None, self->cls, NULL);
    } else {
      func = PyObject_CallFunctionObjArgs(
          self->func_get, self->obj, self->cls, NULL);
    }
  } else {
    func = self->func_get;
  }

  if (func == NULL) {
    result = NULL;
    goto done;
  }


  if (self->obj
      && (PyObject_HasAttrString(self->obj, NEWTYPE_INIT_ARGS_STR) != 1))
  {
    PyObject* args_slice;
    if (PyTuple_Size(args) > 1) {
      args_slice = PyTuple_GetSlice(args, 1, PyTuple_Size(args));
      if (args_slice == NULL) {
        // Py_DECREF(args_tuple);
        return NULL;
      }
    } else {
      args_slice = PyTuple_New(0);
    }
    if (PyObject_SetAttrString(self->obj, NEWTYPE_INIT_ARGS_STR, args_slice)
          < 0) {
        Py_DECREF(args_slice);
        // Py_DECREF(args_tuple);
        result = NULL;
        goto done;
      }
      Py_XDECREF(args_slice);
  }

  if (self->obj
      && (PyObject_HasAttrString(self->obj, NEWTYPE_INIT_KWARGS_STR) != 1))
  {
    if (kwds == NULL) {
      kwds = PyDict_New();
    }
    if (PyObject_SetAttrString(self->obj, NEWTYPE_INIT_KWARGS_STR, kwds) < 0) {
      result = NULL;
      goto done;
    }
  }


  // Ensure `self->cls` is a valid type object
  if (self->cls && PyType_Check(self->cls)) {
    result = PyObject_Call(func, args, kwds);
  } else {
    PyErr_SetString(PyExc_TypeError, "Invalid type object in descriptor");
    result = NULL;
  }

  if (PyErr_Occurred()) {
    // Py_DECREF(args_tuple);
    goto done;
  }

  done:
    Py_XDECREF(func);
    return result;
}

static void NewInit_dealloc(NewInitObject* self)
{
  Py_XDECREF(self->cls);
  Py_XDECREF(self->obj);
  Py_XDECREF(self->func_get);

  Py_TYPE(self)->tp_free((PyObject*)self);
}

static PyMethodDef NewInit_methods[] = {{NULL, NULL, 0, NULL}};

static PyTypeObject NewInitType = {
    PyVarObject_HEAD_INIT(NULL, 0).tp_name = "newinit.NewInit",
    .tp_doc = "Descriptor class that wraps methods for instantiating subtypes.",
    .tp_basicsize = sizeof(NewInitObject),
    .tp_itemsize = 0,
    .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE,
    .tp_new = PyType_GenericNew,
    .tp_init = (initproc)NewInit_init,
    .tp_dealloc = (destructor)NewInit_dealloc,
    .tp_call = (ternaryfunc)NewInit_call,
    .tp_getattro = PyObject_GenericGetAttr,
    .tp_setattro = NULL,
    .tp_methods = NewInit_methods,
    .tp_descr_get = (descrgetfunc)NewInit_get,
};

static struct PyModuleDef newinitmodule = {
    PyModuleDef_HEAD_INIT,
    .m_name = "newinit",
    .m_doc = "A module containing `NewInit` descriptor class.",
    .m_size = -1,
};

PyMODINIT_FUNC PyInit_newtypeinit(void)
{
  PyObject* m;
  if (PyType_Ready(&NewInitType) < 0)
    return NULL;

  m = PyModule_Create(&newinitmodule);
  if (m == NULL)
    return NULL;

  PyObject* PY_NEWTYPE_INIT_KWARGS_STR =
      PyUnicode_FromString(NEWTYPE_INIT_KWARGS_STR);
  if (PY_NEWTYPE_INIT_KWARGS_STR == NULL) {
    Py_DECREF(m);
    return NULL;
  }
  PyModule_AddObject(m, "NEWTYPE_INIT_KWARGS_STR", PY_NEWTYPE_INIT_KWARGS_STR);

  PyObject* PY_NEWTYPE_INIT_ARGS_STR =
      PyUnicode_FromString(NEWTYPE_INIT_ARGS_STR);
  if (PY_NEWTYPE_INIT_ARGS_STR == NULL) {
    Py_DECREF(m);
    return NULL;
  }
  PyModule_AddObject(m, "NEWTYPE_INIT_ARGS_STR", PY_NEWTYPE_INIT_ARGS_STR);

  Py_INCREF(&NewInitType);
  if (PyModule_AddObject(m, "NewInit", (PyObject*)&NewInitType) < 0) {
    Py_DECREF(&NewInitType);
    Py_DECREF(m);
    return NULL;
  }

  return m;
}

And this is the test file.

import pytest

from newtypeinit import NEWTYPE_INIT_ARGS_STR, NEWTYPE_INIT_KWARGS_STR, NewInit
from newtype import NewType

from conftest import limit_leaks, LEAK_LIMIT


class G(NewType(str)):
    def __init__(self, val, a, b, c):
        self.val = val

    def add_one(self):
        self.val += 1

    __init__ = NewInit(__init__)


@limit_leaks(LEAK_LIMIT)
def test_new_init_method():
    g = G(5, 1, 2, 3)

    assert getattr(g, NEWTYPE_INIT_ARGS_STR) == (1, 2, 3)
    assert getattr(g, NEWTYPE_INIT_KWARGS_STR) == {}

To my shock, when running the test functions for 10,000 iterations with a leak limit of 50 kB, the test reveals that the extension leaks 622 kB, which is unacceptable but when I look at my codes, I cannot see where the leaks are occurring.

I have not read your code in detail.

But the usual issue is that you have an python object returned to you by one of the functions you are calling and you did not call the Py_DECREF once you had finished using it.

You can use a tool like valgrind on a linux system to have it report what objects you are leaking.

2 Likes

Does the amount of leaks increase with the number of iterations of the test body? A fair amount of the time when using pytest-memray (which I’m assuming you’re using), things like caching or imported modules get marked as leaked, since they aren’t given back to the allocator at the end of the test.

If the number of leaks goes up when the number of iterations goes up, it’s a leak! Otherwise, it’s probably just caching and whatnot — increase the leak limit threshold.

2 Likes

Thank you for your reply.

If you don’t mind, can you share how can I increase the number of iterations? And observe that the leak is increasing proportionally?

And yes, I copied your conftest.py and the limit_leaks decorator, lol.

I figured :smile:

In conftest.py, there should be an ITERATIONS constant. I think I originally set it to 1000?

1 Like

Assuming a real leak, I would try to remove or comment out code until it goes away or at least reduces significantly.

2 Likes

Very sad to announce that it indeed leaks using @ZeroIntensity’s method.

At ITERATIONS=1000, this is the test output:

Test was allowed to leak 50.0KiB per location but at least one location leaked more
---------------------------------------------------------------------------------------- memray-leaked-memory ----------------------------------------------------------------------------------------
List of leaked allocations:
    - 61.6KiB allocated here:
        _PyObject_GC_Alloc:Modules/gcmodule.c:1975
        _PyObject_GC_Malloc:Modules/gcmodule.c:1998
        _PyObject_GC_New:Modules/gcmodule.c:2010
        new_dict:Objects/dictobject.c:609
        PyDict_New:Objects/dictobject.c:702
        ...

At 10,000, this is the output:

________________________________________________________________________________________ test_new_init_method ________________________________________________________________________________________
Test was allowed to leak 50.0KiB per location but at least one location leaked more
---------------------------------------------------------------------------------------- memray-leaked-memory ----------------------------------------------------------------------------------------
List of leaked allocations:
    - 624.1KiB allocated here:
        _PyObject_GC_Alloc:Modules/gcmodule.c:1975
        _PyObject_GC_Malloc:Modules/gcmodule.c:1998
        _PyObject_GC_New:Modules/gcmodule.c:2010
        new_dict:Objects/dictobject.c:609
        PyDict_New:Objects/dictobject.c:702
        ...
====================================================================================== short test summary info =======================================================================================
MEMORY PROBLEMS test_newtype_init.py::test_new_init_method

You can increase Memray’s stack trace with the --stacks flag, IIRC. You’ll be able to see where the object is created, and hence what object it is, which should help you figure out where the leak is.

@limit_leaks(LEAK_LIMIT)
def test_new_init_method():
    g = G(5, 1, 2, 3)

I guess I know why it’s leaking…

Here the test test_new_init_method() is ran many times.

Each instantiation calls NewInit_call, which in turns execute this:

if (self->obj
      && (PyObject_HasAttrString(self->obj, NEWTYPE_INIT_KWARGS_STR) != 1))
  {
    if (kwds == NULL) {
      kwds = PyDict_New();
    }
    if (PyObject_SetAttrString(self->obj, NEWTYPE_INIT_KWARGS_STR, kwds) < 0) {
      result = NULL;
      goto done;
    }
  }

Where it sets the python dictionary onto self->obj.

I am just curious why after each test function execution, the python dictionary isn’t deallocated?

At a quick glance, I’m guessing the leak is from here:

kwds is a borrowed reference when it’s non-NULL (and should not be Py_DECREF’d), but it’s a strong reference when returned from PyDict_New, and hence leaked when not Py_DECREF’d.

Thank you for your reply.

if (PyObject_SetAttrString(self->obj, NEWTYPE_INIT_KWARGS_STR, kwds) < 0) {
      // Py_DECREF(args_tuple);
      result = NULL;
      goto done;
    }

Does PyObject_SetAttrString steals my kwds reference? If it does, then at the label done, I should PyXDECREF(kwds) then.

Nope, PyObject_SetAttrString does not steal references. (In general, things in the C API do not steal references, there’s only a few outliers, which have big red notes on them in the docs.)

Also, solely adding Py_XDECREF would be incorrect here. You need to Py_INCREF the borrowed reference if kwds is non-NULL (in which case, that makes Py_XDECREF obsolete – you can just use Py_DECREF instead.)

You need to Py_INCREF the borrowed reference if kwds is non-NULL

Why would I need to do that? Since I’m directly passing it to PyObject_Call(self->cls, args, kwds) directly if it is not NULL.

My question remains but I am happy to say it passes the test after I added Py_XDECREF(kwds); into the done label and check if kwds != NULL and if so, PyINCREF(kwds);, because we got a borrowed reference so we increase its reference count to make Py_XDECREF(kwds); work safely for both kwds == NULL and kwds != NULL.

PyObject_Call does not affect your reference, you need to Py_INCREF to turn it into a strong reference.

Great to hear :smile:

1 Like