Childhood memories in the countryside

This morning, I got this email from the buildbot-status mailing list:

The Buildbot has detected a new failure on builder PPC64LE Fedora Rawhide Refleaks 3.x while building Python.

I get many of buildbot failures per month (by email), but I like to debug reference leaks: they are more challenging :-) I decided to write this article to document and explain my work on maintaining Python (buildbots).

I truncated most the output of most commands in this article to make it easier to read.

Drawing: Childhood memories in the countryside by Djamila Knopf.

Reproduce the bug

I look into buildbot logs:

test_int leaked [1, 1, 1] references, sum=3

Aha, interesting: the test_int test leaks Python strong references, each test iteration leaks exactly one reference. Well, in short, it leaks memory.

I build Python to check if the refleak is still there:

git switch main
make clean
./configure --with-pydebug
make

The main branch is currently at this commit:

$ git show main
commit 2844aa6a8eb1d486b5c432f0ed33a2082998f41e
(...)

I run the test with -R 3:3 to check for reference leaks:

$ ./python -m test -R 3:3 test_int
(...)
test_int leaked [1, 1, 1] references, sum=3
(...)
Total duration: 4.8 sec

Great! It's still there, it's real regression. I told you, I love this kind of bugs :-)

Identify which test leaks (test.bisect_cmd)

$ ./python -m test test_int --list-cases|wc -l
42
$ wc -l Lib/test/test_int.py
885 Lib/test/test_int.py

test_int has only 42 methods and takes 4.8 seconds to run (with -R 3:3). That's small, but the file is made of 885 lines of Python code. I'm lazy, I don't want to read so many lines. I will use python -m test.bisect_cmd to identify which test method leaks so I have less test code to read and reproducing the test will be even faster.

I run python -m test.bisect_cmd:

$ ./python -m test.bisect_cmd -R 3:3 test_int
(...)
[+] Iteration 17: run 1 tests/2
(...)
test_int leaked [1, 1, 1] references, sum=3
(...)
* test.test_int.PyLongModuleTests.test_pylong_misbehavior_error_path_from_str

I love watching this tool doing my job, I don't have anything to do! :-)

I confirm that the test_pylong_misbehavior_error_path_from_str() test leaks:

$ ./python -m test -R 3:3 test_int -m test_pylong_misbehavior_error_path_from_str
test_int leaked [1, 1, 1] references, sum=3
Total duration: 445 ms

The test_pylong_misbehavior_error_path_from_str() method is only 17 lines of code, it's way better than 885 lines of code (52x less code to read). And reproducing the bug now only takes 445 ms instead of 4.8 seconds (10x faster).

At this point, there is the brave method of looking into the C code: Python is made of 500 000 lines of C code. Good luck! Or maybe there is another way?

Git bisection

Again, I'm lazy. I always begin with the "divide to conquer" method. A Git bisection is an efficient method for that.

I start git bisect:

git bisect reset
git bisect start --term-bad=leak --term-good=noleak
git bisect leak  # we just saw that current commit leaks

Defining "good" and "bad" terms helps me a lot to prevent mistakes: it's a nice Git bisect feature! In the past, I always picked the wrong one at some point which messed up the whole bisection.

Ok, now how can I know when the leak was introduced? Well, I like to move in the past step by step: one day, two days, one week, one month, one year, etc.

I pick a random commit merged yesterday:

$ date
Fri Nov  4 11:55:12 CET 2022

$ git log
(...)
commit 016c7d37b6acfe2203542a2655080c6402b3be1f
Date:   Thu Nov 3 23:21:01 2022 +0000
(...)
commit 4c4b5ce2e529a1279cd287e2d2d73ffcb6cf2ead
Date:   Thu Nov 3 16:18:38 2022 -0700
(...)

I'm not lucky at my first bet, the code already leaked yesterday:

$ git checkout 4c4b5ce2e529a1279cd287e2d2d73ffcb6cf2ead^C
$ make && ./python -m test -R 3:3 test_int -m test_pylong_misbehavior_error_path_from_str
test_int leaked [1, 1, 1] references, sum=3

I repeat the process, I pick a random commit the day before:

$ git log
(...)
commit f3007ac3702ea22c7dd0abf8692b1504ea3c9f63
Author: Victor Stinner <vstinner@python.org>
Date:   Wed Nov 2 20:45:58 2022 +0100
(...)

For my greatest pleasure, I pick a commit made by myself. Maybe I'm lucky and I'm the one who introduced the leak :-D

$ git checkout f3007ac3702ea22c7dd0abf8692b1504ea3c9f63
$ make && ./python -m test -R 3:3 test_int -m test_pylong_misbehavior_error_path_from_str
(...)
Tests result: NO TESTS RAN

"NO TESTS RAN" means that the test doesn't exist. Oh wait, the test didn't exist 2 days ago? So the test itself is new? Well, no tests ran also means... "no leak".

I will make the assumption that "NO TESTS RAN" means "no leak" and see what's going on:

$ git bisect noleak
Bisecting: 13 revisions left to test after this (roughly 4 steps)

$ make && ./python -m test -R 3:3 test_int -m test_pylong_misbehavior_error_path_from_str
Tests result: NO TESTS RAN
$ git bisect noleak
Bisecting: 6 revisions left to test after this (roughly 3 steps)

$ make && ./python -m test -R 3:3 test_int -m test_pylong_misbehavior_error_path_from_str
Tests result: NO TESTS RAN
$ git bisect noleak
Bisecting: 3 revisions left to test after this (roughly 2 steps)

$ make && ./python -m test -R 3:3 test_int -m test_pylong_misbehavior_error_path_from_str
Tests result: NO TESTS RAN
$ git bisect noleak
Bisecting: 1 revision left to test after this (roughly 1 step)

$ make && ./python -m test -R 3:3 test_int -m test_pylong_misbehavior_error_path_from_str
test_int leaked [1, 1, 1] references, sum=3
$ git bisect leak
Bisecting: 0 revisions left to test after this (roughly 0 steps)

$ make && ./python -m test -R 3:3 test_int -m test_pylong_misbehavior_error_path_from_str
test_int leaked [1, 1, 1] references, sum=3

vstinner@mona$ git bisect leak
4c4b5ce2e529a1279cd287e2d2d73ffcb6cf2ead is the first leak commit

commit 4c4b5ce2e529a1279cd287e2d2d73ffcb6cf2ead
Author: Gregory P. Smith <greg@krypto.org>
Date:   Thu Nov 3 16:18:38 2022 -0700

    gh-90716: bugfixes and more tests for _pylong. (#99073)

    * Properly decref on _pylong import error.
    * Improve the error message on _pylong TypeError.
    * Fix the assertion error in pydebug builds to be a TypeError.
    * Tie the return value comments together.

    These are minor followups to issues not caught among the reviewers on
    https://github.com/python/cpython/pull/96673.

 Lib/test/test_int.py | 39 +++++++++++++++++++++++++++++++++++++++
 Objects/longobject.c | 15 +++++++++++----
 2 files changed, 50 insertions(+), 4 deletions(-)

In total, it took 7 git bisect steps to identify a single commit. That's quick! I also love this tool, I feel that it does my job!

Sometimes, I mess up with Git bisection. Here, the guilty commit seems like a good candidate since it changes Objects/longobject.c which is C code, so it can likely introduce a leak. Moreover, this C file is the implementation of the Python int type, so it is directly related to test_int (the test suite of the int type).

Just in case, I test manually the the leak before/after:

# after
$ git checkout 4c4b5ce2e529a1279cd287e2d2d73ffcb6cf2ead
$ make && ./python -m test -R 3:3 test_int -m test_pylong_misbehavior_error_path_from_str
test_int leaked [1, 1, 1] references, sum=3

# before
$ git checkout 4c4b5ce2e529a1279cd287e2d2d73ffcb6cf2ead^
$ make && ./python -m test -R 3:3 test_int -m test_pylong_misbehavior_error_path_from_str
Tests result: NO TESTS RAN

Ok, there is no doubt anymore: the commit introduced the leak. But since the commit also adds the leaking test, maybe the leak already existed, and it's just that nobody noticed the leak before.

Debug the leak

Since I identified the commit introducing the leak, I only have to review code changes by this single commit. But to debug the code, I prefer to come back to the main branch. To prepare a fix, I will have to start from the main branch anyway.

Go back to the main branch:

$ git bisect reset
$ git switch main

The second command is useless, I was already at the main branch. I did some many mistakes with Git in the past, that I took the habit of doing things very carefully. I don't care of doing things twice, just in case. It's cheaper than messing with the Git god! Trust me.

Just in case, I double check that the leak is still there in the main branch:

$ make && ./python -m test -R 3:3 test_int -m test_pylong_misbehavior_error_path_from_str
test_int leaked [1, 1, 1] references, sum=3

Ok, we are good to start debugging. Let me open Lib/test/test_int.py and look for the test_pylong_misbehavior_error_path_from_str() method:

@support.cpython_only  # tests implementation details of CPython.
@unittest.skipUnless(_pylong, "_pylong module required")
@mock.patch.object(_pylong, "int_from_string")
def test_pylong_misbehavior_error_path_from_str(
        self, mock_int_from_str):
    big_value = '7'*19_999
    with support.adjust_int_max_str_digits(20_000):
        mock_int_from_str.return_value = b'not an int'
        with self.assertRaises(TypeError) as ctx:
            int(big_value)
        self.assertIn('_pylong.int_from_string did not',
                      str(ctx.exception))

        mock_int_from_str.side_effect = RuntimeError("test123")
        with self.assertRaises(RuntimeError):
            int(big_value)

Always divide to conquer: let me try to make the code as short as possible (7 lines), I also make the "big_value" smaller:

@mock.patch.object(_pylong, "int_from_string")
def test_pylong_misbehavior_error_path_from_str(self, mock_int_from_str):
    big_value = '7' * 9999
    with support.adjust_int_max_str_digits(10_000):
        mock_int_from_str.return_value = b'not an int'
        with self.assertRaises(TypeError) as ctx:
            int(big_value)

Ok, so the test is about converting a long string (9999 decimal digits) to an integer using the new _pylong module which is implemented in pure Python (Lib/_pylong.py) and called from C code (Objects/longobject.c). Well, I followed recent developments, so I don't have to dig into the C code to know that. It helps!

If I search for _pylong in Objects/longobject.c, I find this interesting function:

/* asymptotically faster str-to-long conversion for base 10, using _pylong.py */
static int
pylong_int_from_string(const char *start, const char *end, PyLongObject **res)
{
    PyObject *mod = PyImport_ImportModule("_pylong");
    ...
}

With a quick look, I don't see any obvious reference leak in this code. I add printf() to make sure that I'm looking at the right function:

static int
pylong_int_from_string(const char *start, const char *end, PyLongObject **res)
{
    ...
    PyObject *s = PyUnicode_FromStringAndSize(start, end-start);
    if (s == NULL) {
        Py_DECREF(mod);
        goto error;
    }
printf("pylong_int_from_string()\n");
    PyObject *result = PyObject_CallMethod(mod, "int_from_string", "O", s);
    ...
}

I added the print before the int_from_string() call, since this function is overriden by the test.

I build Python and run the test:

$ make
$ ./python -m test -R 3:3 test_int -m test_pylong_misbehavior_error_path_from_str
(...)
beginning 6 repetitions
123456
pylong_int_from_string()
.pylong_int_from_string()
.pylong_int_from_string()
.pylong_int_from_string()
.pylong_int_from_string()
.pylong_int_from_string()
(...)

Ok, I'm looking at the right place. The print happens when the test runs. So which code path is taken? Let me add print calls after the function call:

static int
pylong_int_from_string(const char *start, const char *end, PyLongObject **res)
{
    ...
    PyObject *result = PyObject_CallMethod(mod, "int_from_string", "O", s);
    Py_DECREF(s);
    Py_DECREF(mod);
    if (result == NULL) {
printf("pylong_int_from_string() error\n");   // <====== ADD
        goto error;
    }
    if (!PyLong_Check(result)) {
printf("pylong_int_from_string() wrong type\n");   // <====== ADD
        PyErr_SetString(PyExc_TypeError,
                        "_pylong.int_from_string did not return an int");
        goto error;
    }
printf("pylong_int_from_string() ok\n");   // <====== ADD
    ...
}

Test output:

...
pylong_int_from_string() wrong type
.pylong_int_from_string() wrong type
.pylong_int_from_string() wrong type
...

Aha, the bug should be around the if (!PyLong_Check(result)) code path. Oh wait... result is a Python object, and in this code path, the function exits without returning result to the caller, nor removing the reference to result. That's our leak!

Write a fix

To write a fix, I start by reverting all local changes (remove debug traces, restore the original test code):

$ git checkout .

I write a fix:

$ git diff
diff --git a/Objects/longobject.c b/Objects/longobject.c
index a872938990..652fdb7974 100644
--- a/Objects/longobject.c
+++ b/Objects/longobject.c
@@ -2376,6 +2376,7 @@ pylong_int_from_string(const char *start, const char *end, PyLongObject **res)
         goto error;
     }
     if (!PyLong_Check(result)) {
+        Py_DECREF(result);
         PyErr_SetString(PyExc_TypeError,
                         "_pylong.int_from_string did not return an int");
         goto error;

I build and test my fix:

$ make && ./python -m test -R 3:3 test_int -m test_pylong_misbehavior_error_path_from_str
(...)
Tests result: SUCCESS

Ok, the leak is fixed! So it was a just a missing Py_DECREF() in code recently added to Python. It's a common mistake. By the way, when I looked at the code the first code, I also missed this "obvious" leak.

I prepare a PR:

$ git switch -c int_str
$ git commit -a
# Commit message:
# gh-90716: Fix pylong_int_from_string() refleak

Let me validate my work from the new clean commit:

$ make && ./python -m test -R 3:3 test_int
(...)
Tests result: SUCCESS

I complete the commit message using git commit --amend:

gh-90716: Fix pylong_int_from_string() refleak

Fix validated by:

    $ ./python -m test -R 3:3 test_int
    Tests result: SUCCESS

I run gh_pr.sh (my short shell script) to create a PR from the command line.

I add the skip news label on the PR, since this refleak is not part of any Python release, no user is impacted. It's not worth documenting it. I don't think that the change is part of Python 3.12 alpha 1. Moreover, only very few users test alpha 1 releases.

Here it is, my shiny PR fixing the leak! https://github.com/python/cpython/pull/99094

Since Gregory worked on longobject.c recently, I add him in copy of my PR. I just add the comment cc @gpshead to my PR.

I don't plan to wait for this review. The change is just one line, I'm confident that it does fix the issue, I don't need a review.

To finish, I reply by email to the buildbot-status failure email.

Conclusion

In total, it took me between one and two hours to reproduce, debug and fix this reference leak.

In the meanwhile, I also looked into other Python stuffs (and I discussed with friends!), while the bisection was running, or during the Python build. It's hard to estimate exactly how much time it takes me to fix a refleak.

I consider that I'm efficient on fixing such leak since I'm following the Python development: I was already aware of the on-going _pylong work. I also fixed many refleaks in the past.

By the way, I wrote the python -m test.bisect_cmd tool exactly to accelerate my work on debugging reference leaks. I'm now also used to Git bisection.

For me, the key of my whole methodology is to "divide to conquer":

  • Reproduce the issue
  • Get a reproducer
  • Make the reproducer as fast as possible and as short as possible
  • Use Git bisection to identify the change introducing the change
  • Add print calls to identify which parts of the code and the test are taken by the issue

Oh by the way, while I finished my article, my PR got reviewed and I merged it: my commit fixing the leak!