Skip to content

Commit 66dd533

Browse files
bpo-42398: Fix "make regen-all" race condition (GH-23362) (GH-23367)
Fix a race condition in "make regen-all" when make -jN option is used to run jobs in parallel. The clinic.py script now only use atomic write to write files. Moveover, generated files are now left unchanged if the content does not change, to not change the file modification time. The "make regen-all" command runs "make clinic" and "make regen-importlib" targets: * "make regen-importlib" builds object files (ex: Modules/_weakref.o) from source files (ex: Modules/_weakref.c) and clinic files (ex: Modules/clinic/_weakref.c.h) * "make clinic" always rewrites all clinic files (ex: Modules/clinic/_weakref.c.h) Since there is no dependency between "clinic" and "regen-importlib" Makefile targets, these two targets can be run in parallel. Moreover, half of clinic.py file writes are not atomic and so there is a race condition when "make regen-all" runs jobs in parallel using make -jN option (which can be passed in MAKEFLAGS environment variable). Fix clinic.py to make all file writes atomic: * Add write_file() function to ensure that all file writes are atomic: write into a temporary file and then use os.replace(). * Moreover, write_file() doesn't recreate or modify the file if the content does not change to avoid modifying the file modification file. * Update test_clinic to verify these assertions with a functional test. * Remove Clinic.force attribute which was no longer used, whereas Clinic.verify remains useful. (cherry picked from commit 8fba952) (cherry picked from commit c53c3f4) Co-authored-by: Victor Stinner <vstinner@python.org>
1 parent 73e02ff commit 66dd533

File tree

3 files changed

+58
-26
lines changed

3 files changed

+58
-26
lines changed

Lib/test/test_clinic.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -801,17 +801,29 @@ class ClinicExternalTest(TestCase):
801801
maxDiff = None
802802

803803
def test_external(self):
804+
# bpo-42398: Test that the destination file is left unchanged if the
805+
# content does not change. Moreover, check also that the file
806+
# modification time does not change in this case.
804807
source = support.findfile('clinic.test')
805808
with open(source, 'r', encoding='utf-8') as f:
806-
original = f.read()
807-
with support.temp_dir() as testdir:
808-
testfile = os.path.join(testdir, 'clinic.test.c')
809+
orig_contents = f.read()
810+
811+
with support.temp_dir() as tmp_dir:
812+
testfile = os.path.join(tmp_dir, 'clinic.test.c')
809813
with open(testfile, 'w', encoding='utf-8') as f:
810-
f.write(original)
811-
clinic.parse_file(testfile, force=True)
814+
f.write(orig_contents)
815+
old_mtime_ns = os.stat(testfile).st_mtime_ns
816+
817+
clinic.parse_file(testfile)
818+
812819
with open(testfile, 'r', encoding='utf-8') as f:
813-
result = f.read()
814-
self.assertEqual(result, original)
820+
new_contents = f.read()
821+
new_mtime_ns = os.stat(testfile).st_mtime_ns
822+
823+
self.assertEqual(new_contents, orig_contents)
824+
# Don't change the file modification time
825+
# if the content does not change
826+
self.assertEqual(new_mtime_ns, old_mtime_ns)
815827

816828

817829
if __name__ == "__main__":
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Fix a race condition in "make regen-all" when make -jN option is used to run
2+
jobs in parallel. The clinic.py script now only use atomic write to write
3+
files. Moveover, generated files are now left unchanged if the content does not
4+
change, to not change the file modification time.

Tools/clinic/clinic.py

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1756,6 +1756,30 @@ def dump(self):
17561756
# The callable should not call builtins.print.
17571757
return_converters = {}
17581758

1759+
1760+
def write_file(filename, new_contents):
1761+
try:
1762+
with open(filename, 'r', encoding="utf-8") as fp:
1763+
old_contents = fp.read()
1764+
1765+
if old_contents == new_contents:
1766+
# no change: avoid modifying the file modification time
1767+
return
1768+
except FileNotFoundError:
1769+
pass
1770+
1771+
# Atomic write using a temporary file and os.replace()
1772+
filename_new = f"{filename}.new"
1773+
with open(filename_new, "w", encoding="utf-8") as fp:
1774+
fp.write(new_contents)
1775+
1776+
try:
1777+
os.replace(filename_new, filename)
1778+
except:
1779+
os.unlink(filename_new)
1780+
raise
1781+
1782+
17591783
clinic = None
17601784
class Clinic:
17611785

@@ -1802,7 +1826,7 @@ class Clinic:
18021826
18031827
"""
18041828

1805-
def __init__(self, language, printer=None, *, force=False, verify=True, filename=None):
1829+
def __init__(self, language, printer=None, *, verify=True, filename=None):
18061830
# maps strings to Parser objects.
18071831
# (instantiated from the "parsers" global.)
18081832
self.parsers = {}
@@ -1811,7 +1835,6 @@ def __init__(self, language, printer=None, *, force=False, verify=True, filename
18111835
fail("Custom printers are broken right now")
18121836
self.printer = printer or BlockPrinter(language)
18131837
self.verify = verify
1814-
self.force = force
18151838
self.filename = filename
18161839
self.modules = collections.OrderedDict()
18171840
self.classes = collections.OrderedDict()
@@ -1944,8 +1967,7 @@ def parse(self, input):
19441967
block.input = 'preserve\n'
19451968
printer_2 = BlockPrinter(self.language)
19461969
printer_2.print_block(block)
1947-
with open(destination.filename, "wt") as f:
1948-
f.write(printer_2.f.getvalue())
1970+
write_file(destination.filename, printer_2.f.getvalue())
19491971
continue
19501972
text = printer.f.getvalue()
19511973

@@ -1997,7 +2019,10 @@ def _module_and_class(self, fields):
19972019
return module, cls
19982020

19992021

2000-
def parse_file(filename, *, force=False, verify=True, output=None, encoding='utf-8'):
2022+
def parse_file(filename, *, verify=True, output=None):
2023+
if not output:
2024+
output = filename
2025+
20012026
extension = os.path.splitext(filename)[1][1:]
20022027
if not extension:
20032028
fail("Can't extract file type for file " + repr(filename))
@@ -2007,27 +2032,18 @@ def parse_file(filename, *, force=False, verify=True, output=None, encoding='utf
20072032
except KeyError:
20082033
fail("Can't identify file type for file " + repr(filename))
20092034

2010-
with open(filename, 'r', encoding=encoding) as f:
2035+
with open(filename, 'r', encoding="utf-8") as f:
20112036
raw = f.read()
20122037

20132038
# exit quickly if there are no clinic markers in the file
20142039
find_start_re = BlockParser("", language).find_start_re
20152040
if not find_start_re.search(raw):
20162041
return
20172042

2018-
clinic = Clinic(language, force=force, verify=verify, filename=filename)
2043+
clinic = Clinic(language, verify=verify, filename=filename)
20192044
cooked = clinic.parse(raw)
2020-
if (cooked == raw) and not force:
2021-
return
2022-
2023-
directory = os.path.dirname(filename) or '.'
20242045

2025-
with tempfile.TemporaryDirectory(prefix="clinic", dir=directory) as tmpdir:
2026-
bytes = cooked.encode(encoding)
2027-
tmpfilename = os.path.join(tmpdir, os.path.basename(filename))
2028-
with open(tmpfilename, "wb") as f:
2029-
f.write(bytes)
2030-
os.replace(tmpfilename, output or filename)
2046+
write_file(output, cooked)
20312047

20322048

20332049
def compute_checksum(input, length=None):
@@ -5033,7 +5049,7 @@ def main(argv):
50335049
path = os.path.join(root, filename)
50345050
if ns.verbose:
50355051
print(path)
5036-
parse_file(path, force=ns.force, verify=not ns.force)
5052+
parse_file(path, verify=not ns.force)
50375053
return
50385054

50395055
if not ns.filename:
@@ -5049,7 +5065,7 @@ def main(argv):
50495065
for filename in ns.filename:
50505066
if ns.verbose:
50515067
print(filename)
5052-
parse_file(filename, output=ns.output, force=ns.force, verify=not ns.force)
5068+
parse_file(filename, output=ns.output, verify=not ns.force)
50535069

50545070

50555071
if __name__ == "__main__":

0 commit comments

Comments
 (0)