Skip to content

Commit 7d1aa5d

Browse files
Complete gh-157217 mappingproxy race fix on current main
2 parents fc73fce + 2073473 commit 7d1aa5d

4 files changed

Lines changed: 88 additions & 10 deletions

File tree

‎Lib/idlelib/idle_test/test_pyparse.py‎

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def char_in_string_false(index): return False
7878
' b=True):\n'
7979
' pass\n'
8080
)
81-
pos0, pos = 33, 42 # Start of 'class...', ' def' lines.
81+
pos0, pos, pos1 = 33, 42, 94 # Start of 'class', 'def', 'pass' lines.
8282

8383
# Passing no value or non-callable should fail (issue 32989).
8484
with self.assertRaises(TypeError):
@@ -91,18 +91,18 @@ def char_in_string_false(index): return False
9191
self.assertIsNone(start(is_char_in_string=lambda index: True))
9292

9393
# Make all text look like it's not in a string. This means that it
94-
# found a good start position.
95-
eq(start(char_in_string_false), pos)
94+
# found a good start position: the last statement.
95+
eq(start(char_in_string_false), pos1)
9696

9797
# If the beginning of the def line is not in a string, then it
9898
# returns that as the index.
9999
eq(start(is_char_in_string=lambda index: index > pos), pos)
100100
# If the beginning of the def line is in a string, then it
101101
# looks for a previous index.
102102
eq(start(is_char_in_string=lambda index: index >= pos), pos0)
103-
# If everything before the 'def' is in a string, then returns None.
104-
# The non-continuation def line returns 44 (see below).
105-
eq(start(is_char_in_string=lambda index: index < pos), None)
103+
# If everything before the 'def' is in a string, then returns
104+
# the start of the 'pass' line.
105+
eq(start(is_char_in_string=lambda index: index < pos), pos1)
106106

107107
# Code without extra line break in def line - mostly returns the same
108108
# values.
@@ -111,12 +111,41 @@ def char_in_string_false(index): return False
111111
' def __init__(self, a, b=True):\n'
112112
' pass\n'
113113
) # Does not affect class, def positions.
114-
eq(start(char_in_string_false), pos)
114+
pos1 = 77 # Start of 'pass' line.
115+
eq(start(char_in_string_false), pos1)
115116
eq(start(is_char_in_string=lambda index: index > pos), pos)
116117
eq(start(is_char_in_string=lambda index: index >= pos), pos0)
117118
# When the def line isn't split, this returns which doesn't match the
118119
# split line test.
119-
eq(start(is_char_in_string=lambda index: index < pos), pos)
120+
eq(start(is_char_in_string=lambda index: index < pos), pos1)
121+
122+
# gh-85560: 'else' of a conditional expression at the start of
123+
# a continuation line does not start a statement.
124+
setcode('def f():\n'
125+
' return (1 if x\n'
126+
' else 0)\n')
127+
eq(start(char_in_string_false), 9) # Start of 'return' line.
128+
setcode('if x:\n'
129+
' pass\n'
130+
'else:\n'
131+
' x = 1\n')
132+
eq(start(char_in_string_false), 15)
133+
setcode('if x:\n'
134+
' pass\n'
135+
'else : # comment\n'
136+
' x = 1\n')
137+
eq(start(char_in_string_false), 15)
138+
# A yield expression can start a continuation line too.
139+
setcode('def f():\n'
140+
' x = (\n'
141+
' yield y)\n')
142+
eq(start(char_in_string_false), 0)
143+
# Other statements which cannot start a continuation line.
144+
for stmt in ('with x:', 'del x', 'global x', 'nonlocal x', 'pass',
145+
'finally:'):
146+
with self.subTest(stmt=stmt):
147+
setcode(f'if x:\n pass\n{stmt}\n')
148+
eq(start(char_in_string_false), 15)
120149

121150
def test_set_lo(self):
122151
code = (

‎Lib/idlelib/pyparse.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
^
2323
[ \t]*
2424
(?: while
25-
| else
25+
| else (?= [ \t]* : ) # not "else" of a conditional expression
2626
| def
2727
| return
2828
| assert
@@ -34,7 +34,12 @@
3434
| except
3535
| raise
3636
| import
37-
| yield
37+
| with
38+
| del
39+
| global
40+
| nonlocal
41+
| pass
42+
| finally
3843
)
3944
\b
4045
""", re.VERBOSE | re.MULTILINE).search

‎Lib/test/test_free_threading/test_type.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ class ClassB(Base):
145145
# keep reference to __dict__
146146
d = obj.__dict__
147147
obj.__class__ = ClassB
148+
self.assertEqual(d['attr'], 123)
148149

149150

150151
def test_name_change(self):
@@ -402,6 +403,46 @@ class B(A):
402403
with threading_helper.start_threads(threads):
403404
pass
404405

406+
@unittest.skipUnless(Py_GIL_DISABLED,
407+
"race only occurs on the free-threaded build")
408+
def test_dir_racing_class_dict_insert(self):
409+
# gh-157217: dir() iterated a mappingproxy of the class dict without
410+
# holding that dict's critical section. A concurrent insert into the
411+
# class dict (for example a lazy __annotations_cache__) then raised
412+
# RuntimeError: dictionary changed size during iteration.
413+
errors = []
414+
415+
class C:
416+
x: int
417+
418+
for i in range(200):
419+
setattr(C, f'attr_{i}', i)
420+
421+
def reader():
422+
barrier.wait()
423+
for _ in range(400):
424+
try:
425+
dir(C)
426+
dict(vars(C))
427+
{**vars(C)}
428+
except RuntimeError as exc:
429+
errors.append(exc)
430+
431+
def writer():
432+
barrier.wait()
433+
# First access stores __annotations_cache__ on the class.
434+
C.__annotations__
435+
for i in range(200):
436+
setattr(C, f'extra_{i}', i)
437+
438+
n_readers = 4
439+
barrier = threading.Barrier(n_readers + 1)
440+
threads = [Thread(target=reader) for _ in range(n_readers)]
441+
threads.append(Thread(target=writer))
442+
with threading_helper.start_threads(threads):
443+
pass
444+
self.assertEqual(errors, [])
445+
405446

406447
if __name__ == "__main__":
407448
unittest.main()
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix IDLE failing to find the opening parenthesis when a continuation line
2+
inside the parentheses starts with ``else`` of a conditional expression or
3+
with ``yield``.

0 commit comments

Comments
 (0)