Raise where the fuzzer found aborts and stack overflows, and collect … · sheeeng/rustpython-rustpython@86d9407 · GitHub
Skip to content

Commit 86d9407

Browse files
authored
Raise where the fuzzer found aborts and stack overflows, and collect with the count in the object header (RustPython#8551)
* Correct what the non-unix frame stack is for The comment said non-unix threading builds have no stop-the-world. CollectStopTheWorld is gated on `feature = "threading"` alone, and sys._current_frames stops the world on both paths; the field is the fallback a reader uses when there is no `top_iframe` to materialize from. Assisted-by: Claude * Seek by the whole file position on Windows `SetFilePointer` answers with the low half of the new position and signals failure with INVALID_SET_FILE_POINTER, which is also that half of a position four gigabytes in; telling them apart takes the error code, which this did not read, so such a seek was reported as an error. Deciding seekability from it also called such a file unseekable. `SetFilePointerEx` returns the whole position and a success flag of its own, which also removes the transmute of the position into halves. Assisted-by: Claude * memoryview: tell a value of the wrong kind from one that does not fit Assigning an item reported every packing failure as a TypeError, so m[0] = 300 on a 'B' view said the value was the wrong type rather than out of range. Packing now says which of the two it was, and whether the value's own code raised, in which case that error is the answer as it is: m[0] = 300 ValueError: invalid value for format 'B' m[0] = "x" TypeError: invalid type for format 'B' m[0] = <__index__ that raises> the raised error `struct` reports both as `struct.error` and is unchanged; the kind travels beside the exception for the caller that tells them apart. Also: None was read as a deletion, so m[0] = None answered "cannot delete memory" instead of packing it; and deleting through the mapping protocol never reached the read-only check, which comes first. Assisted-by: Claude * Raise MemoryError for allocations sized by Python input os.read, _RawIOBase.read, int.to_bytes, struct.pack, ctypes array creation and the _ssl RAND functions sized a Vec from a Python-supplied length with vec![], which calls handle_alloc_error and, under panic = "abort", ends the process. They now allocate through vm.new_zeroed_bytes and raise MemoryError. itertools.product built its pools without checking that len(iterables) * repeat is representable; it now raises OverflowError "repeat argument too large" and reserves the pool and index vectors fallibly. repeat is read as isize, so a negative one raises ValueError "repeat argument cannot be negative" instead of the conversion's message. _ssl.RAND_bytes and RAND_pseudo_bytes read n as i32, matching the int they are declared with. Assisted-by: Claude * Collect with the count in the object header A collection kept the count it was working with in a table keyed by the object's address, and the objects it had proved reachable in a second one. Between them they were hashed once per candidate and twice per edge in the heap, which is where most of a collection over a live heap went. The count now lives in `PyInner::gc_refs`, with `GcBits::COLLECTING` saying it is meaningful, and reachability is `gc_refs == GC_REACHABLE` rather than membership in a set. Step 5 splits the candidates and clears the bit in one pass. gcbench, five interleaved pairs, median: a live heap of 423k objects goes from 0.101s to 0.055s and a dead one from 0.402s to 0.383s. The bits, generation, owner and count take eight bytes between them. A 64-bit header had those eight as the padding its alignment forces, so it is unchanged at 48 bytes; a 32-bit header grows from 24 to 28. Assisted-by: Claude * Take an explicit thread stack size as a floor in debug builds A debug build already started Python threads on 8 MiB rather than Rust's 2 MiB default, but an explicit threading.stack_size(N) went through verbatim. test_threading asks for 256 KiB, and starting a thread on that walked off the end of the stack: the guard page fault landed in the prologue of ExecutingFrame::run. Unoptimized, that prologue reserves 80,848 bytes where the optimized one reserves 656 -- execute_instruction is #[inline(always)] and LLVM only colors stack slots from opt-level 1, so the frame is the sum of all 200 instruction arms' temporaries rather than the largest. A Python call costs 88,672 bytes of native stack there, and threading's bootstrap is six frames deep, so 256 KiB holds less than half of what starting a thread takes. The floor reaches thread::Builder only; threading.stack_size() still answers with what was asked for, and release builds are unchanged. Assisted-by: Claude * Check the native stack on every frame entry The C-stack guard ran on one frame entry in eight. That asks the margin to cover eight frames rather than one, and it does not: an unoptimized frame entered through native code takes 88,672 bytes against a debug margin of 262,144. A recursion whose steps re-enter that way -- `__add__` calling itself, a sort key that sorts -- ran off the end of the stack instead of raising RecursionError. On a debug build `class Add: __add__ = lambda s, o: s + o; Add() + 1` segfaulted on the main thread; it now raises, as it does under CPython and in release builds. enter_iframe checked and then called enter_iframe_unchecked, which checked again; it now leaves the check to the one call. Measured on a call-dominated benchmark, five interleaved pairs: instructions retired go up 0.17%, about four per call, which is the stack pointer read and the compare. Assisted-by: Claude * Take an iterable's length hint when a list is filled from it `map_py_iter` read `__length_hint__` only to pass it to `PyIterIter`, and returned an empty vector when the hint was `isize::MAX` or more. Collecting through `PyResult` dropped the iterator's lower bound, so nothing reserved the room the hint asked for. `list()`, `list.extend()` and `list.__iadd__()` now reserve it and report a hint they cannot honour as `MemoryError`; a hint that leaves no room for the elements the list already holds is passed over, as `list_extend()` does. `tuple()` and the other callers keep filling up without reserving. `length_hint_opt` errors other than the `TypeError` it already turns into `None` now reach the caller instead of being dropped. `__iadd__` and `inplace_concat` went through `extract_cloned`, which reads `__len__` and not `__length_hint__`; both call `PyList::extend` now, the way `list_inplace_concat()` calls `list_extend()`. The tuple, list and dict fast paths of `extract_elements_inner` reserve their known length, which the `collect()` they used dropped. Assisted-by: Claude * Drop map.__length_hint__ `map_methods` has no `__length_hint__`, so `operator.length_hint()` on a map answers 0, not the length of what it draws from. The method walked into the length hint of every iterator it holds, and a chain of maps 10000 long overflowed the native stack answering for the outermost one. It also took the longest of its iterators, where a map stops at the shortest. Assisted-by: Claude * Ask for a length hint where each caller asks for it `map_py_iter` asked the iterable it was handed, for every caller, and reported what asking raised. Only some callers ask it: `list_extend()` and `_PyBytes_FromIterator()` ask the iterable, `PySequence_Tuple()` asks the iterator, and the bytearray constructor asks nothing. `tuple()`, `min()`, `max()`, `collections.deque()` and `f(*x)` raised for an iterable whose `__len__` or `__length_hint__` does, where they answer. Which object is asked is now the caller's to say. `sorted()` asks the iterable, being `PySequence_List()`. `bytes_from_object()` stood in for `PyBytes_FromObject()`, for `bytearray_extend()` and for the bytearray constructor, which do not agree on this: the first two ask, the last does not. It is split, and assigning to a bytearray slice takes the constructor's side with `PyByteArray_FromObject()`. `list.extend()` counted what it held before the iterable had been asked, where `list_extend()` reads `Py_SIZE(self)` after. A `__length_hint__` that adds to the list made the overflow guard read a count too small and raise `MemoryError` where nothing is wrong; one that empties it made the guard skip a reservation that cannot be served. Assisted-by: Claude * Settle product's pool count before it reads its arguments `product_new()` checks `repeat` and works out `npools` before it calls `PySequence_Tuple()` on any argument, and fills the pools `npools` times. The pools were filled by repeating the arguments `repeat` times instead, which walks that many steps even with no arguments to repeat: `product(repeat=2**62)` counted up to it rather than answering `[()]`. The count was also worked out after the arguments had been read, so a repeat too large to serve ran their code first. Assisted-by: Claude * Let the bool format answer with the error its value raised `pack_single()` leaves `'?'` to `PyObject_IsTrue()` and returns what that raised. Packing classified the error instead, so a `ValueError` from a `__bool__` came back as "memoryview: invalid value for format '?'". Assisted-by: Claude * Release a cell's old value after the lock `PyCell::set` dropped what it replaced while still holding the mutex guarding the cell contents. A `__del__` running from that drop and reading the same cell waited on a lock its own caller held, so `del it` on a closure variable whose value has such a `__del__` deadlocked. The replaced value is now released once the guard is gone, as `Py_XSETREF` stores before it decrefs. Assisted-by: Claude * Have a set iterator hold the set it iterates The iterator kept only a reference to the inner hash table, so in `it = iter(A(*args))` the `A()` temporary was the last owner and died as the call returned, before `it` was bound. A `__del__` reading `it` there saw an unbound name and its error was printed and ignored. The iterator now holds the set object, as `si_set` does, and releases it once exhausted. That release happens after the lock is dropped, where `setiter_iternext()` puts its `Py_DECREF(so)` past `Py_END_CRITICAL_SECTION()`, so a `__del__` that iterates again does not wait on a lock the call holds. Assisted-by: Claude * Release an exhausted iterator's container after the lock `PositionIterInternal::_next` overwrote its `IterStatus::Active` while the caller still held the mutex around it. Dropping the container there ran any `__del__` under that lock, and a `__del__` that iterated the same object again blocked on it. `exhaust()` now hands the container back instead of dropping it, and `locked_step()` releases it after the guard. list, list_reverseiterator, tuple, str, dict and its views and reverse views, bytes, bytearray, memoryview, array, deque, and the enumerate and sequence iterators all go through it. Assisted-by: Claude * Unskip test_free_after_iterating Assisted-by: Claude * Stop listing test_set as an environment polluter `check_free_after_iterating` no longer leaves an ignored exception behind, and the job that reruns the listed tests ten times fails once one of them stops polluting. Assisted-by: Claude * Drop winsound imports left unused `TryFromBorrowedObject`, `exceptions`, and `ToWideString` have no reference in the module, which fails the Windows clippy line under `-Dwarnings`. Assisted-by: Claude * Ask nothing where the caller takes no room `map_py_iter` asked the iterator for a length hint on behalf of every caller that does not reserve, and reported what asking raised. Those callers ask nothing at all: `tuple()`, `f(*x)`, `bytearray(x)`, `min()`, `max()` and `collections.deque()` answer for an iterator whose `__length_hint__` raises, where they had been raising it. The answer was also never spent. It reached `PyIterIter` for a `size_hint()` the push loop does not read, so the lookup and any call it made were work thrown away: `tuple()` over a generator drops 22% of its instructions, and 17% over an iterator with a `__length_hint__` written in Python. Assisted-by: Claude * Stop asking an iterator how long it is to walk it `PyIter::iter` and `PyIter::into_iter` asked for a length hint and reported what asking raised. Nothing spent the answer: it reached `PyIterIter` for a `size_hint()` that every caller either loops past or drops, since collecting into a `Result` reports no lower bound. 23 operations answered for an iterator whose `__length_hint__` raises, where they had been raising it: `set`, `frozenset` and the nine `set` methods that take an iterable, `dict.fromkeys` and the dict view operators, `array` and `array.extend`, `all`, `any`, `sum`, `io.writelines`, `math.fsum`, `math.prod`, and `csv.writerow` and `writerows`. Over a generator, `set()` drops 16% of its instructions and `all()` 23%. `str.join` and `bytes.join` do ask, reaching their elements through `PySequence_Fast()`, which fills a list from the iterator. They take `iter_sized()`, which is now the only way to ask. `iter_without_hint` is gone, its callers being what `iter` already does. Assisted-by: Claude * Name the matrix jobs rather than let the matrix name them A generated job name lists every value in the matrix entry, so `cargo check` carried the booleans its dependencies and `skip_ssl` keys expand to, and the snippets job carried its test arguments and timeout. Adding or removing a key renames the check, which drops it from the required list until that list is edited to match. Emptying `env_polluting_tests` renamed three checks this way. `Run rust tests` and `clippy` keep the names they had. `cargo check` drops the booleans from six of its nine, and the snippets job drops its arguments and timeout from all three. Assisted-by: Claude * Keep a sequence iterator active when an element raises `PositionIterInternal::_next` exhausted the iterator for any non-`Return` result, so an error from `__getitem__` ended the walk. `iter_iternext()` lets go of its sequence for `IndexError` and `StopIteration` alone, and `PyIterReturn::from_getitem_result` has already turned the first of those into the second, so only `StopIteration` exhausts now. Both deque iterators keep exhausting on their own mutation guard, which `deque_iternext()` does by zeroing the counter before it raises; they share one step function for it, and the message it raises is lowercased to match the three other sites in the module. Assisted-by: Claude * Name the caller in the bytes conversion TypeError An object with no iteration protocol reached `PyObject_GetIter`'s "not iterable" message, and `bytearray.extend()` reported bytes. Each entry point now checks for the protocol first and names itself: bytes(object()) cannot convert 'object' object to bytes bytearray(object()) cannot convert 'object' object to bytearray bytearray().extend(object()) can't extend bytearray with object Assisted-by: Claude * Take a strong count too large to hold as reachable `start_gc_refs` clipped a count at `GC_REACHABLE - 1`, a number the per-reference subtraction could still walk down to zero and collect a live object. It now stores `GC_REACHABLE`, and `subtract_gc_ref` leaves that value alone. Assisted-by: Claude * Raise AssertionError where a snippet asserts False `assert False` is removed under `-O`, which the snippet suite may run. Assisted-by: Claude * Keep raising for a collection that moved under its iterator A deque, set or dict iterator raised once and then read as spent. The guard is sticky: `deque_iternext()` looks at the deque's state before the count it keeps, and `dictiter_iternextkey()` and `setiter_iternext()` write a size no collection can have, so every later call finds the same thing and raises again. `dequereviter_next()` is the one exception, looking at its count first, so it runs out after the first raise. Both deque iterators now carry `dequeiterobject.counter` rather than reading a length back from the deque, and the dict and set iterators compare the size they captured against the collection's own every time they are asked how much is left, which is what makes that answer nothing from the moment the collection changes rather than only once the iterator has raised. The set's message is capitalized to match `setiter_iternext()`. Measured against 3.14.6, for each of deque, reversed deque, set, dict and a reversed dict view: the hint after the change, the error, the hint after the error, and what a later call answers. Assisted-by: Claude * Give apt-get update a deadline to fail on The step waits on `apt-get update`, which has no deadline of its own, so a source that takes the connection and then stops answering holds the job until the workflow's own timeout. The retry that disables the Microsoft and azure-cli sources runs only when the update exits non-zero, which a held connection never does; three jobs on this branch sat on this step for 20 minutes to five and a half hours. Each attempt is now bounded, and the transports are given a timeout, so a source that stops answering reaches the retry. Assisted-by: Claude
1 parent ca907d1 commit 86d9407

56 files changed

Lines changed: 1548 additions & 415 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.cspell.dict/cpython.txt

Lines changed: 2 additions & 0 deletions

.github/actions/install-linux-deps/action.yml

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,26 @@ runs:
5050
GCC_AARCH64_LINUX_GNU: ${{ inputs.gcc-aarch64-linux-gnu }}
5151
GCC_MINGW_W64_X86_64: ${{ inputs.gcc-mingw-w64-x86-64 }}
5252
run: |
53-
if ! sudo apt-get update; then
54-
echo "::warning::apt-get update failed; disabling nonessential Microsoft apt sources and retrying"
53+
# `apt-get update` has no deadline of its own, so a source that takes
54+
# the connection and then stops answering holds the job rather than
55+
# failing it, and the retry below never runs. Bound each attempt and
56+
# give the transports a timeout to fail on.
57+
apt_update() {
58+
sudo timeout 300 apt-get \
59+
-o Acquire::Retries=3 \
60+
-o Acquire::http::Timeout=20 \
61+
-o Acquire::https::Timeout=20 \
62+
update
63+
}
64+
65+
if ! apt_update; then
66+
echo "::warning::apt-get update did not finish; disabling nonessential Microsoft apt sources and retrying"
5567
for source in /etc/apt/sources.list.d/*microsoft* /etc/apt/sources.list.d/*azure-cli*; do
5668
if [ -e "$source" ]; then
5769
sudo mv "$source" "$source.disabled"
5870
fi
5971
done
60-
sudo apt-get update
72+
apt_update
6173
fi
6274
6375
packages=()

.github/workflows/ci.yaml

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,10 @@ jobs:
8080
if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:ci') }}
8181
env:
8282
RUST_BACKTRACE: full
83-
name: Run rust tests
83+
# Named after the matrix entry rather than left to be named for it: a
84+
# generated name lists every value in the entry, so adding or removing one
85+
# renames the check and drops it from the required list.
86+
name: Run rust tests (${{ matrix.os }})
8487
runs-on: ${{ matrix.os }}
8588
timeout-minutes: 45
8689
strategy:
@@ -165,7 +168,10 @@ jobs:
165168
if: runner.os == 'Linux'
166169

167170
cargo_check:
168-
name: cargo check
171+
# Named after the matrix entry rather than left to be named for it: a
172+
# generated name lists every value in the entry, so adding or removing one
173+
# renames the check and drops it from the required list.
174+
name: cargo check (${{ matrix.os }}, ${{ matrix.target }})
169175
runs-on: ${{ matrix.os }}
170176
needs:
171177
- determine_changes
@@ -292,7 +298,10 @@ jobs:
292298
test_multiprocessing_fork
293299
test_multiprocessing_forkserver
294300
test_multiprocessing_spawn
295-
name: Run snippets and cpython tests
301+
# Named after the matrix entry rather than left to be named for it: a
302+
# generated name lists every value in the entry, so adding or removing one
303+
# renames the check and drops it from the required list.
304+
name: Run snippets and cpython tests (${{ matrix.os }})
296305
runs-on: ${{ matrix.os }}
297306
strategy:
298307
matrix:
@@ -302,26 +311,23 @@ jobs:
302311
- '-u all'
303312
- '--timeout 600'
304313
- '--dont-add-python-opts'
305-
env_polluting_tests:
306-
- test_set
314+
env_polluting_tests: []
307315
skips: []
308316
timeout: 50
309317
- os: ubuntu-latest
310318
extra_test_args:
311319
- '-u all'
312320
- '--timeout 600'
313321
- '--dont-add-python-opts'
314-
env_polluting_tests:
315-
- test_set
322+
env_polluting_tests: []
316323
skips: []
317324
timeout: 60
318325
- os: windows-2025
319326
extra_test_args:
320327
- '-u all'
321328
- '--timeout 600'
322329
- '--dont-add-python-opts'
323-
env_polluting_tests:
324-
- test_set
330+
env_polluting_tests: []
325331
skips: []
326332
timeout: 50
327333
fail-fast: false
@@ -465,7 +471,10 @@ jobs:
465471
run: python -I scripts/whats_left.py ${{ env.CARGO_ARGS }} --features jit
466472

467473
clippy:
468-
name: clippy
474+
# Named after the matrix entry rather than left to be named for it: a
475+
# generated name lists every value in the entry, so adding or removing one
476+
# renames the check and drops it from the required list.
477+
name: clippy (${{ matrix.os }})
469478
runs-on: ${{ matrix.os }}
470479
needs:
471480
- determine_changes

Lib/test/seq_tests.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,6 @@ def test_pickle(self):
439439
self.assertEqual(lst2, lst)
440440
self.assertNotEqual(id(lst2), id(lst))
441441

442-
@unittest.skip("TODO: RUSTPYTHON; hangs")
443442
def test_free_after_iterating(self):
444443
support.check_free_after_iterating(self, iter, self.type2test)
445444
support.check_free_after_iterating(self, reversed, self.type2test)

Lib/test/test_array.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1198,7 +1198,6 @@ def test_obsolete_write_lock(self):
11981198
a = array.array('B', b"")
11991199
self.assertRaises(BufferError, _testcapi.getbuffer_with_null_view, a)
12001200

1201-
@unittest.skip("TODO: RUSTPYTHON; hangs")
12021201
def test_free_after_iterating(self):
12031202
support.check_free_after_iterating(self, iter, array.array,
12041203
(self.typecode,))

Lib/test/test_bytes.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1041,7 +1041,6 @@ def test_find_etc_raise_correct_error_messages(self):
10411041
self.assertRaisesRegex(TypeError, r'\bendswith\b', b.endswith,
10421042
x, None, None, None)
10431043

1044-
@unittest.skip("TODO: RUSTPYTHON; hangs")
10451044
def test_free_after_iterating(self):
10461045
test.support.check_free_after_iterating(self, iter, self.type2test)
10471046
test.support.check_free_after_iterating(self, reversed, self.type2test)

Lib/test/test_dict.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1258,7 +1258,6 @@ def __eq__(self, o):
12581258
d = {X(): 0, 1: 1}
12591259
self.assertRaises(RuntimeError, d.update, other)
12601260

1261-
@unittest.skip("TODO: RUSTPYTHON; hangs")
12621261
def test_free_after_iterating(self):
12631262
support.check_free_after_iterating(self, iter, dict)
12641263
support.check_free_after_iterating(self, lambda d: iter(d.keys()), dict)

Lib/test/test_iter.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1137,7 +1137,6 @@ def test_iter_neg_setstate(self):
11371137
self.assertEqual(next(it), 0)
11381138
self.assertEqual(next(it), 1)
11391139

1140-
@unittest.skip("TODO: RUSTPYTHON; hangs")
11411140
def test_free_after_iterating(self):
11421141
check_free_after_iterating(self, iter, SequenceClass, (0,))
11431142

Lib/test/test_set.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,6 @@ class C(object):
362362
gc.collect()
363363
self.assertTrue(ref() is None, "Cycle was not collected")
364364

365-
@unittest.skipIf("RUSTPYTHON_SKIP_ENV_POLLUTERS" in __import__("os").environ, "TODO: RUSTPYTHON")
366365
def test_free_after_iterating(self):
367366
support.check_free_after_iterating(self, iter, self.thetype)
368367

Lib/test/test_str.py

Lines changed: 0 additions & 1 deletion

0 commit comments

Comments
 (0)