Skip to content

Commit c53c3f4

Browse files
authored
bpo-42398: Fix "make regen-all" race condition (pythonGH-23362) (pythonGH-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)
1 parent 994c68f commit c53c3f4

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
@@ -794,17 +794,29 @@ class ClinicExternalTest(TestCase):
794794
maxDiff = None
795795

796796
def test_external(self):
797+
# bpo-42398: Test that the destination file is left unchanged if the
798+
# content does not change. Moreover, check also that the file
799+
# modification time does not change in this case.
797800
source = support.findfile('clinic.test')
798801
with open(source, 'r', encoding='utf-8') as f:
799-
original = f.read()
800-
with support.temp_dir() as testdir:
801-
testfile = os.path.join(testdir, 'clinic.test.c')
802+
orig_contents = f.read()
803+
804+
with support.temp_dir() as tmp_dir:
805+
testfile = os.path.join(tmp_dir, 'clinic.test.c')
802806
with open(testfile, 'w', encoding='utf-8') as f:
803-
f.write(original)
804-
clinic.parse_file(testfile, force=True)
807+
f.write(orig_contents)
808+
old_mtime_ns = os.stat(testfile).st_mtime_ns
809+
810+
clinic.parse_file(testfile)
811+
805812
with open(testfile, 'r', encoding='utf-8') as f:
806-
result = f.read()
807-
self.assertEqual(result, original)
813+
new_contents = f.read()
814+
new_mtime_ns = os.stat(testfile).st_mtime_ns
815+
816+
self.assertEqual(new_contents, orig_contents)
817+
# Don't change the file modification time
818+
# if the content does not change
819+
self.assertEqual(new_mtime_ns, old_mtime_ns)
808820

809821

810822
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
@@ -1777,6 +1777,30 @@ def dump(self):
17771777
# The callable should not call builtins.print.
17781778
return_converters = {}
17791779

1780+
1781+
def write_file(filename, new_contents):
1782+
try:
1783+
with open(filename, 'r', encoding="utf-8") as fp:
1784+
old_contents = fp.read()
1785+
1786+
if old_contents == new_contents:
1787+
# no change: avoid modifying the file modification time
1788+
return
1789+
except FileNotFoundError:
1790+
pass
1791+
1792+
# Atomic write using a temporary file and os.replace()
1793+
filename_new = f"{filename}.new"
1794+
with open(filename_new, "w", encoding="utf-8") as fp:
1795+
fp.write(new_contents)
1796+
1797+
try:
1798+
os.replace(filename_new, filename)
1799+
except:
1800+
os.unlink(filename_new)
1801+
raise
1802+
1803+
17801804
clinic = None
17811805
class Clinic:
17821806

@@ -1823,7 +1847,7 @@ class Clinic:
18231847
18241848
"""
18251849

1826-
def __init__(self, language, printer=None, *, force=False, verify=True, filename=None):
1850+
def __init__(self, language, printer=None, *, verify=True, filename=None):
18271851
# maps strings to Parser objects.
18281852
# (instantiated from the "parsers" global.)
18291853
self.parsers = {}
@@ -1832,7 +1856,6 @@ def __init__(self, language, printer=None, *, force=False, verify=True, filename
18321856
fail("Custom printers are broken right now")
18331857
self.printer = printer or BlockPrinter(language)
18341858
self.verify = verify
1835-
self.force = force
18361859
self.filename = filename
18371860
self.modules = collections.OrderedDict()
18381861
self.classes = collections.OrderedDict()
@@ -1965,8 +1988,7 @@ def parse(self, input):
19651988
block.input = 'preserve\n'
19661989
printer_2 = BlockPrinter(self.language)
19671990
printer_2.print_block(block)
1968-
with open(destination.filename, "wt") as f:
1969-
f.write(printer_2.f.getvalue())
1991+
write_file(destination.filename, printer_2.f.getvalue())
19701992
continue
19711993
text = printer.f.getvalue()
19721994

@@ -2018,7 +2040,10 @@ def _module_and_class(self, fields):
20182040
return module, cls
20192041

20202042

2021-
def parse_file(filename, *, force=False, verify=True, output=None, encoding='utf-8'):
2043+
def parse_file(filename, *, verify=True, output=None):
2044+
if not output:
2045+
output = filename
2046+
20222047
extension = os.path.splitext(filename)[1][1:]
20232048
if not extension:
20242049
fail("Can't extract file type for file " + repr(filename))
@@ -2028,27 +2053,18 @@ def parse_file(filename, *, force=False, verify=True, output=None, encoding='utf
20282053
except KeyError:
20292054
fail("Can't identify file type for file " + repr(filename))
20302055

2031-
with open(filename, 'r', encoding=encoding) as f:
2056+
with open(filename, 'r', encoding="utf-8") as f:
20322057
raw = f.read()
20332058

20342059
# exit quickly if there are no clinic markers in the file
20352060
find_start_re = BlockParser("", language).find_start_re
20362061
if not find_start_re.search(raw):
20372062
return
20382063

2039-
clinic = Clinic(language, force=force, verify=verify, filename=filename)
2064+
clinic = Clinic(language, verify=verify, filename=filename)
20402065
cooked = clinic.parse(raw)
2041-
if (cooked == raw) and not force:
2042-
return
2043-
2044-
directory = os.path.dirname(filename) or '.'
20452066

2046-
with tempfile.TemporaryDirectory(prefix="clinic", dir=directory) as tmpdir:
2047-
bytes = cooked.encode(encoding)
2048-
tmpfilename = os.path.join(tmpdir, os.path.basename(filename))
2049-
with open(tmpfilename, "wb") as f:
2050-
f.write(bytes)
2051-
os.replace(tmpfilename, output or filename)
2067+
write_file(output, cooked)
20522068

20532069

20542070
def compute_checksum(input, length=None):
@@ -5087,7 +5103,7 @@ def main(argv):
50875103
path = os.path.join(root, filename)
50885104
if ns.verbose:
50895105
print(path)
5090-
parse_file(path, force=ns.force, verify=not ns.force)
5106+
parse_file(path, verify=not ns.force)
50915107
return
50925108

50935109
if not ns.filename:
@@ -5103,7 +5119,7 @@ def main(argv):
51035119
for filename in ns.filename:
51045120
if ns.verbose:
51055121
print(filename)
5106-
parse_file(filename, output=ns.output, force=ns.force, verify=not ns.force)
5122+
parse_file(filename, output=ns.output, verify=not ns.force)
51075123

51085124

51095125
if __name__ == "__main__":

0 commit comments

Comments
 (0)