Skip to content

Reference leaks in scaled_font_text_to_glyphs() argument tuples #439

Description

@K-ANOY

I found reference leaks in scaled_font_text_to_glyphs(). On the successful path it leaks one argument tuple per converted glyph and, when cluster output is enabled, one additional tuple per converted cluster. The leak is deterministic and scales with the amount of converted text, I measured ~414 MB of growth in a simple loop (details at the bottom).

File: cairo/font.c

Function: scaled_font_text_to_glyphs

Relevant code (glyph loop):

for(i=0; i < num_glyphs; i++) {
  cairo_glyph_t *glyph = &glyphs[i];
  glyph_args = Py_BuildValue(
    "(kdd)", glyph->index, glyph->x, glyph->y);
  if (glyph_args == NULL)
    goto error;
  pyglyph = PyObject_Call(
    (PyObject *)&PycairoGlyph_Type, glyph_args, NULL);
  if (pyglyph == NULL) {
    Py_DECREF (glyph_args);
    goto error;
  }
  PyList_SET_ITEM (glyph_list, i, pyglyph);
}

Py_BuildValue() returns a new owned reference. PyObject_Call() borrows its argument tuple and does not steal it. The failure branch decrefs glyph_args, but the successful branch inserts only pyglyph into the list and never releases glyph_args.

The cluster loop has the same bug:

cluster_args = Py_BuildValue(
  "(ii)", cluster->num_bytes, cluster->num_glyphs);
if (cluster_args == NULL)
  goto error;
pycluster = PyObject_Call(
  (PyObject *)&PycairoTextCluster_Type, cluster_args, NULL);
if (pycluster == NULL) {
  Py_DECREF (cluster_args);
  goto error;
}
PyList_SET_ITEM (cluster_list, i, pycluster);

Note that the leak is larger than one empty tuple per glyph: the leaked tuple also owns the boxed int and the two float objects built for it, so nothing in it is ever reclaimed. The measurement below works out to roughly 190 bytes per converted glyph.

Suggested fix

Decref the args tuple right after the call, at both sites:

pyglyph = PyObject_Call(
  (PyObject *)&PycairoGlyph_Type, glyph_args, NULL);
Py_DECREF (glyph_args);
if (pyglyph == NULL)
  goto error;
PyList_SET_ITEM (glyph_list, i, pyglyph);

Apply the equivalent ordering to cluster_args.

Reproducer

import cairo, gc, resource

def rss():
    gc.collect()
    return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss  # KiB

surf = cairo.ImageSurface(cairo.FORMAT_ARGB32, 10, 10)
sf = cairo.Context(surf).get_scaled_font()
text = "hello world" * 10   # 110 glyphs

for _ in range(100):
    sf.text_to_glyphs(0, 0, text)    # warm up allocators
before = rss()
for _ in range(20000):
    sf.text_to_glyphs(0, 0, text)
after = rss()
print(f"RSS {before} -> {after} KiB (+{after - before})")

Output on pycairo 1.29.1 (built from current master), Python 3.13.5,
cairo 1.16.0:

RSS 18916 -> 433192 KiB (+414276)

That is 20000 × 110 = 2,200,000 leaked tuples. RSS returns to normal with the
fix applied.

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