Repository navigation
Description
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.binThe 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.
Activity
[mypyc] Fix reference leak in mypyc bytes concatenation (#21469)