From cae669663f03f37bc80982f984e1a0bc29a2440e Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Thu, 14 Aug 2025 12:58:37 +0200 Subject: [PATCH] Fix a race condition in TexManager.make_dvi & make_png. Previously, a race condition could occur if, while a process had called make_tex (generating the tex file in the global cache) and was going to call the latex subprocess (to generate the dvi file), another process also called make_tex for the same tex string and started rewriting the tex source. In that case, the latex subprocess could see a partially written (invalid) tex source. Fix that by generating the tex source in a process-private temporary directory, where the latex process is already going to run anyways. (This is cheap compared to the latex subprocess invocation.) Apply a similar strategy for make_png as well. --- lib/matplotlib/texmanager.py | 53 ++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/lib/matplotlib/texmanager.py b/lib/matplotlib/texmanager.py index 020a26e31cbe..2202431b005d 100644 --- a/lib/matplotlib/texmanager.py +++ b/lib/matplotlib/texmanager.py @@ -281,11 +281,9 @@ def make_dvi(cls, tex, fontsize): Return the file name. """ - basefile = cls.get_basefile(tex, fontsize) - dvifile = '%s.dvi' % basefile - if not os.path.exists(dvifile): - texfile = Path(cls.make_tex(tex, fontsize)) - # Generate the dvi in a temporary directory to avoid race + dvifile = Path(cls.get_basefile(tex, fontsize)).with_suffix(".dvi") + if not dvifile.exists(): + # Generate the tex and dvi in a temporary directory to avoid race # conditions e.g. if multiple processes try to process the same tex # string at the same time. Having tmpdir be a subdirectory of the # final output dir ensures that they are on the same filesystem, @@ -294,15 +292,17 @@ def make_dvi(cls, tex, fontsize): # the absolute path may contain characters (e.g. ~) that TeX does # not support; n.b. relative paths cannot traverse parents, or it # will be blocked when `openin_any = p` in texmf.cnf). - cwd = Path(dvifile).parent - with TemporaryDirectory(dir=cwd) as tmpdir: - tmppath = Path(tmpdir) + with TemporaryDirectory(dir=dvifile.parent) as tmpdir: + Path(tmpdir, "file.tex").write_text( + cls._get_tex_source(tex, fontsize), encoding='utf-8') cls._run_checked_subprocess( ["latex", "-interaction=nonstopmode", "--halt-on-error", - f"--output-directory={tmppath.name}", - f"{texfile.name}"], tex, cwd=cwd) - (tmppath / Path(dvifile).name).replace(dvifile) - return dvifile + "file.tex"], tex, cwd=tmpdir) + Path(tmpdir, "file.dvi").replace(dvifile) + # Also move the tex source to the main cache directory, but + # only for backcompat. + Path(tmpdir, "file.tex").replace(dvifile.with_suffix(".tex")) + return str(dvifile) @classmethod def make_png(cls, tex, fontsize, dpi): @@ -311,22 +311,23 @@ def make_png(cls, tex, fontsize, dpi): Return the file name. """ - basefile = cls.get_basefile(tex, fontsize, dpi) - pngfile = '%s.png' % basefile + pngfile = Path(cls.get_basefile(tex, fontsize)).with_suffix(".png") # see get_rgba for a discussion of the background - if not os.path.exists(pngfile): + if not pngfile.exists(): dvifile = cls.make_dvi(tex, fontsize) - cmd = ["dvipng", "-bg", "Transparent", "-D", str(dpi), - "-T", "tight", "-o", pngfile, dvifile] - # When testing, disable FreeType rendering for reproducibility; but - # dvipng 1.16 has a bug (fixed in f3ff241) that breaks --freetype0 - # mode, so for it we keep FreeType enabled; the image will be - # slightly off. - if (getattr(mpl, "_called_from_pytest", False) and - mpl._get_executable_info("dvipng").raw_version != "1.16"): - cmd.insert(1, "--freetype0") - cls._run_checked_subprocess(cmd, tex) - return pngfile + with TemporaryDirectory(dir=pngfile.parent) as tmpdir: + cmd = ["dvipng", "-bg", "Transparent", "-D", str(dpi), + "-T", "tight", "-o", "file.png", dvifile] + # When testing, disable FreeType rendering for reproducibility; + # but dvipng 1.16 has a bug (fixed in f3ff241) that breaks + # --freetype0 mode, so for it we keep FreeType enabled; the + # image will be slightly off. + if (getattr(mpl, "_called_from_pytest", False) and + mpl._get_executable_info("dvipng").raw_version != "1.16"): + cmd.insert(1, "--freetype0") + cls._run_checked_subprocess(cmd, tex, cwd=tmpdir) + Path(tmpdir, "file.png").replace(pngfile) + return str(pngfile) @classmethod def get_grey(cls, tex, fontsize=None, dpi=None):