From 9b336d0d882db1c2911735e01ea9e14124040db3 Mon Sep 17 00:00:00 2001 From: Uri Kasher Hitin Date: Fri, 16 Oct 2026 11:38:05 +0300 Subject: [PATCH 1/2] Fix memory leak in str() of fmpz_mpoly, fmpq_mpoly, fmpz_mod_mpoly, nmod_mpoly Fixes #435 --- README.md | 6 +++++ src/flint/test/test_all.py | 40 ++++++++++++++++++++++++++++++ src/flint/types/fmpq_mpoly.pyx | 9 +++++-- src/flint/types/fmpz_mod_mpoly.pyx | 9 +++++-- src/flint/types/fmpz_mpoly.pyx | 9 +++++-- src/flint/types/nmod_mpoly.pyx | 9 +++++-- 6 files changed, 74 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index bc8a2fd8..ecf9dc8b 100644 --- a/README.md +++ b/README.md @@ -170,9 +170,15 @@ Contributors (0.10.0): - Vitaly Magerya (VM) - Oscar Benjamin (OB) - Ilia Gradina (IG) +- Uri Kasher Hitin (UKH) Changes (0.10.0): +- [gh-435](https://github.com/flintlib/python-flint/issues/435), + Fix a memory leak in `str()` (and so in `repr()`, which uses it unless + `flint.ctx.pretty` is `False`) of `fmpz_mpoly`, `fmpq_mpoly`, + `fmpz_mod_mpoly` and `nmod_mpoly`: the string returned by FLINT was + copied into a Python `bytes` object but never freed. (UKH) - [gh-433](https://github.com/flintlib/python-flint/pull/433), Define the input type aliases in the type stubs (such as `iarb` and `ifmpz`) after the classes they reference, so that the ty type checker diff --git a/src/flint/test/test_all.py b/src/flint/test/test_all.py index e9e124e6..341e8b8d 100644 --- a/src/flint/test/test_all.py +++ b/src/flint/test/test_all.py @@ -3966,6 +3966,46 @@ def test_fmpq_mpoly_zcontent_zpoly(): assert f.zpoly() == 0 +def _peak_rss_mb() -> float: + """Peak resident set size of this process in MB (``resource`` is Unix only).""" + import resource + peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + return peak / (2**20 if platform.system() == "Darwin" else 1024) + + +def test_mpoly_str_does_not_leak(): + # gh-435: str() of the multivariate types copied the C string returned by + # *_mpoly_get_str_pretty into a bytes object and never freed the original. + # repr() goes through str() too (flint.ctx.pretty is True by default). + try: + import resource # noqa: F401 + except ImportError: + return # no way to measure memory portably (Windows) + + names = [f"x{i}" for i in range(8)] + contexts = [ + flint.fmpz_mpoly_ctx.get(names), + flint.fmpq_mpoly_ctx.get(names), + flint.fmpz_mod_mpoly_ctx.get(names, 2**127 - 1), + flint.nmod_mpoly_ctx.get(names, 1000003), + ] + for ctx in contexts: + # (sum of generators + 5)^4: 495 terms, so the string is ~10 KB and an + # unfreed copy costs ~25 KB per call (4000 calls: ~100 MB unfixed). + p = (sum(ctx.gens()) + 5) ** 4 + assert len(p) == 495 + expected = str(p) + assert expected == p.str() == repr(p) + for _ in range(200): # warm up allocator pools before measuring + p.str() + before = _peak_rss_mb() + for _ in range(4000): + p.str() + assert str(p) == expected + grown = _peak_rss_mb() - before + assert grown < 20, f"{type(p).__name__}.str() leaked {grown:.0f} MB in 4000 calls" + + def _all_mpoly_vecs(): return [(flint.fmpz_mpoly_ctx, flint.fmpz_mpoly_vec), (flint.fmpq_mpoly_ctx, flint.fmpq_mpoly_vec)] diff --git a/src/flint/types/fmpq_mpoly.pyx b/src/flint/types/fmpq_mpoly.pyx index cbc0a9d7..66be65f3 100644 --- a/src/flint/types/fmpq_mpoly.pyx +++ b/src/flint/types/fmpq_mpoly.pyx @@ -10,6 +10,7 @@ from flint.flint_base.flint_base cimport ( ) from flint.utils.typecheck cimport typecheck +from flint.flintlib.functions.flint cimport flint_free from flint.utils.flint_exceptions import DomainError, IncompatibleContextError from flint.types.fmpq cimport fmpq, any_as_fmpq @@ -760,9 +761,13 @@ cdef class fmpq_mpoly(flint_mpoly): return f"{self.ctx}.from_dict({self.to_dict()})" def str(self): - cdef bytes s = fmpq_mpoly_get_str_pretty(self.val, self.ctx.c_names, self.ctx.val) + cdef char * cs = fmpq_mpoly_get_str_pretty(self.val, self.ctx.c_names, self.ctx.val) + try: + s = ( cs).decode() + finally: + flint_free(cs) # FLINT malloc'ed the string; the bytes cast copies it # res = s.decode().replace("+", " + ").replace("-", " - ") - res = s.decode() + res = s if res.startswith("- "): res = "-" + res[3:] return res diff --git a/src/flint/types/fmpz_mod_mpoly.pyx b/src/flint/types/fmpz_mod_mpoly.pyx index 354ed380..41d5ae6b 100644 --- a/src/flint/types/fmpz_mod_mpoly.pyx +++ b/src/flint/types/fmpz_mod_mpoly.pyx @@ -10,6 +10,7 @@ from flint.flint_base.flint_base cimport ( ) from flint.utils.typecheck cimport typecheck +from flint.flintlib.functions.flint cimport flint_free from flint.utils.flint_exceptions import DomainError, IncompatibleContextError from flint.types.fmpz cimport any_as_fmpz, fmpz @@ -746,8 +747,12 @@ cdef class fmpz_mod_mpoly(flint_mpoly): return f"{self.ctx}.from_dict({self.to_dict()})" def str(self): - cdef bytes s = fmpz_mod_mpoly_get_str_pretty(self.val, self.ctx.c_names, self.ctx.val) - res = s.decode().replace("+", " + ").replace("-", " - ") + cdef char * cs = fmpz_mod_mpoly_get_str_pretty(self.val, self.ctx.c_names, self.ctx.val) + try: + s = ( cs).decode() + finally: + flint_free(cs) # FLINT malloc'ed the string; the bytes cast copies it + res = s.replace("+", " + ").replace("-", " - ") if res.startswith(" - "): res = "-" + res[3:] return res diff --git a/src/flint/types/fmpz_mpoly.pyx b/src/flint/types/fmpz_mpoly.pyx index 4e797fa8..b45a7957 100644 --- a/src/flint/types/fmpz_mpoly.pyx +++ b/src/flint/types/fmpz_mpoly.pyx @@ -10,6 +10,7 @@ from flint.flint_base.flint_base cimport ( ) from flint.utils.typecheck cimport typecheck +from flint.flintlib.functions.flint cimport flint_free from flint.utils.flint_exceptions import DomainError, IncompatibleContextError from flint.types.fmpz cimport any_as_fmpz, fmpz @@ -734,8 +735,12 @@ cdef class fmpz_mpoly(flint_mpoly): return f"{self.ctx}.from_dict({self.to_dict()})" def str(self): - cdef bytes s = fmpz_mpoly_get_str_pretty(self.val, self.ctx.c_names, self.ctx.val) - res = s.decode().replace("+", " + ").replace("-", " - ") + cdef char * cs = fmpz_mpoly_get_str_pretty(self.val, self.ctx.c_names, self.ctx.val) + try: + s = ( cs).decode() + finally: + flint_free(cs) # FLINT malloc'ed the string; the bytes cast copies it + res = s.replace("+", " + ").replace("-", " - ") if res.startswith(" - "): res = "-" + res[3:] return res diff --git a/src/flint/types/nmod_mpoly.pyx b/src/flint/types/nmod_mpoly.pyx index 9857d7a5..37aab7d4 100644 --- a/src/flint/types/nmod_mpoly.pyx +++ b/src/flint/types/nmod_mpoly.pyx @@ -8,6 +8,7 @@ from flint.flint_base.flint_base cimport ( ) from flint.utils.typecheck cimport typecheck +from flint.flintlib.functions.flint cimport flint_free from flint.utils.flint_exceptions import DomainError, IncompatibleContextError from flint.types.fmpz cimport fmpz @@ -720,8 +721,12 @@ cdef class nmod_mpoly(flint_mpoly): return f"{self.ctx}.from_dict({self.to_dict()})" def str(self): - cdef bytes s = nmod_mpoly_get_str_pretty(self.val, self.ctx.c_names, self.ctx.val) - res = s.decode().replace("+", " + ").replace("-", " - ") + cdef char * cs = nmod_mpoly_get_str_pretty(self.val, self.ctx.c_names, self.ctx.val) + try: + s = ( cs).decode() + finally: + flint_free(cs) # FLINT malloc'ed the string; the bytes cast copies it + res = s.replace("+", " + ").replace("-", " - ") if res.startswith(" - "): res = "-" + res[3:] return res From e80f63a87a84c0aeae3caed90b5cb6155225b301 Mon Sep 17 00:00:00 2001 From: Uri Kasher Hitin Date: Fri, 16 Oct 2026 11:50:11 +0300 Subject: [PATCH 2/2] Fix initials --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ecf9dc8b..58d08f47 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ Contributors (0.10.0): - Vitaly Magerya (VM) - Oscar Benjamin (OB) - Ilia Gradina (IG) -- Uri Kasher Hitin (UKH) +- Uri K.H. (UKH) Changes (0.10.0):