Skip to content

mypyc leaks owned left operand for binary bytes concatenation #1192

Closed
python/mypy
#21469

Description

@colinxu2020

Summary

mypyc appears to generate IR/C code that leaks the owned left operand for binary bytes concatenation.

This reproduces with a minimal example using only two operands:

class A:
    def f(self) -> bytes:
        return (1).to_bytes(4, "big") + (2).to_bytes(4, "big")

The generated IR calls CPyBytes_Concat(left, right) and decrefs only the right operand. The owned left operand is not decrefed on either the success path or the error path.

Generated IR

For the example above, mypyc generates:

def A.f(self):
    self :: mypyc_repro.A
    ...
L0:
    ...
    r6 = PyObject_VectorcallMethod(r1, r5, 9223372036854775811, 0)
    if is_error(r6) goto L6 (error at f:3) else goto L1
L1:
    r7 = cast(bytes, r6)
    if is_error(r7) goto L6 (error at f:3) else goto L2
L2:
    ...
    r14 = PyObject_VectorcallMethod(r9, r13, 9223372036854775811, 0)
    if is_error(r14) goto L7 (error at f:3) else goto L3
L3:
    r15 = cast(bytes, r14)
    if is_error(r15) goto L7 (error at f:3) else goto L4
L4:
    r16 = CPyBytes_Concat(r7, r15)
    dec_ref r15
    if is_error(r16) goto L6 (error at f:3) else goto L5
L5:
    return r16
L6:
    r17 = <error> :: bytes
    return r17
L7:
    dec_ref r7
    goto L6

Here:

r7  = (1).to_bytes(4, "big")   # owned bytes
r15 = (2).to_bytes(4, "big")   # owned bytes
r16 = r7 + r15                 # new owned bytes

After calling CPyBytes_Concat(r7, r15), the generated IR decrefs r15, but does not decref r7.

I would expect something like:

r16 = CPyBytes_Concat(r7, r15)
dec_ref r7
dec_ref r15
if is_error(r16) ...

or equivalent cleanup on both success and error paths.

CPyBytes_Concat does not steal references

CPyBytes_Concat appears to create and return a new bytes object without stealing or decrefing either argument:

PyObject *CPyBytes_Concat(PyObject *a, PyObject *b) {
    Py_ssize_t a_len = ((PyVarObject *)a)->ob_size;
    Py_ssize_t b_len = ((PyVarObject *)b)->ob_size;
    PyBytesObject *ret = (PyBytesObject *)PyBytes_FromStringAndSize(NULL, a_len + b_len);
    if (ret != NULL) {
        memcpy(ret->ob_sval, ((PyBytesObject *)a)->ob_sval, a_len);
        memcpy(ret->ob_sval + a_len, ((PyBytesObject *)b)->ob_sval, b_len);
    }
    return (PyObject *)ret;
}

So the caller should still be responsible for releasing any owned references passed as arguments.

Impact

This causes memory growth in mypyc-compiled code, while the same code does not leak when run as pure Python.

The issue is especially visible in code that performs high-frequency bytes concatenation. In my original workload, address serialization did something similar to:

return self.layer.to_bytes(4, "big") + self.tree.to_bytes(12, "big") + self.typ.to_bytes(4, "big")

and subclasses used:

return super().to_bytes() + self.keypair.to_bytes(4, "big") + ...

This made the leak large enough to show up clearly in Memray.

Profiling result

Using Memray with Python allocator tracking enabled:

python -m memray run --native --trace-python-allocators -o repro.bin repro.py
python -m memray flamegraph --leaks repro.bin

The leaked allocations are attributed to:

CPyBytes_Concat
  -> PyBytes_FromStringAndSize

With chained concatenation, the problem is amplified. For example:

class A:
    def f(self) -> bytes:
        return (1).to_bytes(4, "big") + (2).to_bytes(4, "big") + (3).to_bytes(4, "big")

generates IR like:

r16 = CPyBytes_Concat(r7, r15)
dec_ref r15
# missing: dec_ref r7

r25 = CPyBytes_Concat(r16, r24)
dec_ref r24
# missing: dec_ref r16

Expected behavior

The generated IR/C code should decref both owned operands after CPyBytes_Concat, since CPyBytes_Concat returns a new reference and does not steal references from its arguments.

Actual behavior

The generated IR decrefs only the right operand. The owned left operand remains alive and leaks.

Environment

Already reproduced in Linux-GNU Python 3.12 and Windows Python 3.13.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions