Skip to content

Detect list mutation during sort even when list length is unchanged#7432

Merged
youknowone merged 4 commits into
RustPython:mainfrom
rlaisqls:fix/sort-mutation-detection
Mar 20, 2026
Merged

Detect list mutation during sort even when list length is unchanged#7432
youknowone merged 4 commits into
RustPython:mainfrom
rlaisqls:fix/sort-mutation-detection

Conversation

@rlaisqls

@rlaisqls rlaisqls commented Mar 14, 2026

Copy link
Copy Markdown
Contributor

The previous check !elements.is_empty() only verified length, so it missed length-preserving mutations like L.append(3); L.pop().

Fix mutation detection in list.sort() to catch modifications that don't change the list length.

Detail

Before sorting, sort() replaces the list's internal Vec with an empty Vec (length=0, capacity=0) via core::mem::take. After sorting, it swaps back and checks whether the temporary Vec was touched.

Changed the condition to elements.capacity() > 0. Any write operation triggers allocation, increasing capacity — and capacity never shrinks back to 0 even after removing all elements. This also subsumes the old !is_empty() check.

  • Update: capacity() > 0 has a blind spot. L.append(x); L.clear() goes undetected because clear() uses mem::take, which resets capacity back to 0. Looking for a better approach.

For reference, CPython uses a similar approach: it sets allocated = -1 (an impossible value) as a sentinel before sorting, then checks if it changed afterward.

Related Tests

  • test_bug453523 — removed @unittest.skip, now passes
  • test_undetected_mutation — removed @unittest.expectedFailure, now passes
  • Full test_sort suite: all 21 tests pass

Summary by CodeRabbit

  • Bug Fixes
    • Improved detection of list modifications during sorting: mutations made while a sort is in progress are now reliably detected and raise an error to prevent incorrect results or corruption.
    • Mutation tracking is incremented on each mutable borrow, ensuring concurrent or in-flight changes are caught consistently.

@coderabbitai

coderabbitai Bot commented Mar 14, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: ebbe49dc-6cf0-4814-bdad-554f46e94457

📥 Commits

Reviewing files that changed from the base of the PR and between 46467dc and 5cd9a7e.

📒 Files selected for processing (1)
  • crates/vm/src/builtins/list.rs

📝 Walkthrough

Walkthrough

Added an atomic per-list mutation counter to PyList and switched mutation detection in borrow_vec_mut and sort to use that counter; sort snapshots the counter, sorts a temporary buffer, swaps results back under write lock, and errors if the counter changed during the operation.

Changes

Cohort / File(s) Summary
List builtin
crates/vm/src/builtins/list.rs
Add mutation_counter: AtomicU32 field and import atomics. Initialize counter in From<Vec<PyObjectRef>> for PyList. Increment counter when granting mutable borrows (borrow_vec_mut). Change sort to snapshot mutation_counter, take elements under a write lock into a temp buffer, run do_sort, then re-lock and detect concurrent mutations by comparing the counter before swapping sorted elements back; fail with "list modified during sort" if changed.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 I count each hop, each tiny tweak,
My counter ticks when lists grow meek,
I sort in secret, then check my art—
If changes came, I won't restart,
I nibble carrot, thump my cheek. 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: detecting list mutations during sort even when list length is unchanged, which is the core problem being solved.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Mar 14, 2026

Copy link
Copy Markdown
Contributor

📦 Library Dependencies

The following Lib/ modules were modified. Here are their dependencies:

[x] lib: cpython/Lib/opcode.py
[ ] lib: cpython/Lib/_opcode_metadata.py
[x] test: cpython/Lib/test/test__opcode.py (TODO: 2)
[x] test: cpython/Lib/test/test_opcodes.py

dependencies:

  • opcode (native: _opcode, builtins)
    • _opcode_metadata
    • _opcode_metadata

dependent tests: (44 tests)

  • opcode: test__opcode test_code test_compile test_dis test_peepholer
    • dis: test_ast test_compiler_assemble test_dtrace test_fstring test_inspect test_monitoring test_opcache test_patma test_positional_only_arg
      • bdb: test_bdb
      • inspect: test_abc test_argparse test_asyncgen test_buffer test_builtin test_collections test_coroutines test_decimal test_enum test_functools test_generators test_grammar test_ntpath test_operator test_posixpath test_pydoc test_signal test_sqlite3 test_traceback test_type_annotations test_types test_typing test_unittest test_yield_from test_zipimport test_zoneinfo
      • modulefinder: test_importlib test_modulefinder
      • trace: test_trace

[ ] test: cpython/Lib/test/test_class.py (TODO: 15)
[x] test: cpython/Lib/test/test_genericclass.py
[x] test: cpython/Lib/test/test_subclassinit.py

dependencies:

dependent tests: (no tests depend on class)

[x] lib: cpython/Lib/dis.py
[ ] test: cpython/Lib/test/test_dis.py (TODO: 37)

dependencies:

  • dis

dependent tests: (70 tests)

  • dis: test__opcode test_ast test_code test_compile test_compiler_assemble test_dis test_dtrace test_fstring test_inspect test_monitoring test_opcache test_patma test_peepholer test_positional_only_arg
    • bdb: test_bdb
    • inspect: test_abc test_argparse test_asyncgen test_buffer test_builtin test_collections test_coroutines test_decimal test_enum test_functools test_generators test_grammar test_ntpath test_operator test_posixpath test_pydoc test_signal test_sqlite3 test_traceback test_type_annotations test_types test_typing test_unittest test_yield_from test_zipimport test_zoneinfo
      • ast: test_compiler_codegen test_future_stmt test_site test_ssl test_type_comments test_ucn test_unparse
      • asyncio: test_asyncio test_contextlib_async test_logging test_os test_sys_settrace test_unittest
      • cmd: test_cmd
      • dataclasses: test__colorize test_copy test_ctypes test_genericalias test_pprint test_regrtest
      • importlib.metadata: test_importlib
      • pkgutil: test_pkgutil test_runpy
      • rlcompleter: test_rlcompleter
      • trace: test_trace
      • xmlrpc.server: test_docxmlrpc test_xmlrpc
    • modulefinder: test_importlib test_modulefinder

[x] test: cpython/Lib/test/test_format.py (TODO: 6)

dependencies:

dependent tests: (no tests depend on format)

[x] test: cpython/Lib/test/test_math.py
[x] test: cpython/Lib/test/test_math_property.py

dependencies:

dependent tests: (229 tests)

  • math: test_abstract_numbers test_asyncio test_builtin test_cmath test_compile test_complex test_ctypes test_decimal test_descr test_float test_fractions test_json test_long test_math test_math_property test_monitoring test_numeric_tower test_pow test_random test_socket test_statistics test_struct test_time test_zipfile
    • fractions: test_buffer test_compare test_itertools test_operator test_os test_string
      • statistics: test_signal
    • random: test_asyncio test_bisect test_bz2 test_collections test_context test_dbm_dumb test_deque test_devpoll test_dict test_dummy_thread test_email test_functools test_heapq test_hmac test_importlib test_int test_io test_logging test_lzma test_mmap test_ordered_dict test_poll test_posixpath test_pprint test_queue test_regrtest test_richcmp test_selectors test_set test_shutil test_sort test_strtod test_sys test_tarfile test_thread test_threading test_tokenize test_traceback test_unparse test_uuid test_weakref test_zipfile test_zlib test_zstd
      • email.generator: test_email
      • email.utils: test_httpservers test_smtplib test_urllib2
      • imaplib: test_imaplib
      • secrets: test_secrets
      • tempfile: test_argparse test_ast test_asyncio test_bytes test_cmd_line test_compileall test_concurrent_futures test_contextlib test_csv test_ctypes test_dis test_doctest test_ensurepip test_faulthandler test_filecmp test_fileinput test_genericalias test_hashlib test_importlib test_inspect test_launcher test_linecache test_mailbox test_modulefinder test_ntpath test_pathlib test_pickle test_pkg test_pkgutil test_posix test_py_compile test_pydoc test_runpy test_site test_string_literals test_subprocess test_support test_tabnanny test_tempfile test_termios test_threadedtempfile test_tomllib test_urllib test_urllib_response test_venv test_winconsoleio test_zipapp test_zipfile64 test_zoneinfo
    • reprlib: test_reprlib
      • bdb: test_bdb
      • collections: test_annotationlib test_array test_asyncio test_c_locale_coercion test_call test_configparser test_contains test_copy test_ctypes test_defaultdict test_dictviews test_enum test_exception_group test_file test_fileio test_frame test_funcattrs test_hash test_ipaddress test_iter test_iterlen test_json test_pathlib test_patma test_plistlib test_shelve test_sqlite3 test_tuple test_types test_typing test_unittest test_userdict test_userlist test_userstring test_weakset test_with
      • dataclasses: test__colorize test_ctypes
    • selectors: test_asyncio
      • socket: test_asyncio test_epoll test_exception_hierarchy test_ftplib test_httplib test_kqueue test_largefile test_poplib test_pty test_smtpnet test_socketserver test_ssl test_stat test_timeout test_urllib2net test_urllibnet test_xmlrpc
      • socketserver: test_wsgiref
      • subprocess: test_android test_asyncio test_atexit test_audit test_cmd_line_script test_ctypes test_dtrace test_file_eintr test_gc test_gzip test_json test_msvcrt test_osx_env test_platform test_quopri test_repl test_script_helper test_select test_sysconfig test_unittest test_utf8_mode test_wait3 test_webbrowser
    • urllib.parse: test_urllib2_localnet test_urlparse
      • http.client: test_docxmlrpc test_ucn test_unicodedata
      • http.cookiejar: test_http_cookiejar
      • http.server: test_robotparser
      • logging.handlers: test_concurrent_futures
      • mimetypes: test_mimetypes
      • pathlib: test_dbm_sqlite3 test_importlib test_pathlib test_tomllib test_tools test_winapi test_zipfile
      • xml.sax.saxutils: test_sax

[ ] test: cpython/Lib/test/test_sort.py

dependencies:

dependent tests: (no tests depend on sort)

[x] lib: cpython/Lib/types.py
[ ] test: cpython/Lib/test/test_types.py (TODO: 6)

dependencies:

  • types

dependent tests: (52 tests)

  • types: test_annotationlib test_ast test_asyncgen test_asyncio test_builtin test_call test_code test_collections test_compile test_compiler_assemble test_coroutines test_decorators test_descr test_dis test_doctest test_dtrace test_dynamicclassattribute test_email test_enum test_exception_group test_fstring test_funcattrs test_generators test_genericalias test_hmac test_importlib test_inspect test_listcomps test_marshal test_monitoring test_opcache test_os test_positional_only_arg test_pprint test_pyclbr test_pydoc test_raise test_string test_subclassinit test_subprocess test_tempfile test_threading test_trace test_traceback test_type_aliases test_type_annotations test_type_params test_types test_typing test_unittest test_xml_etree test_xml_etree_c

Legend:

  • [+] path exists in CPython
  • [x] up-to-date, [ ] outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/vm/src/builtins/list.rs`:
- Around line 399-401: The current post-sort check using elements.capacity() is
unreliable; replace it with an explicit mutation sentinel/version flag on the
list that is set when entering the sort window and cleared on exit, update that
sentinel in every mutating API (e.g., append, clear, extend, insert, pop,
remove, etc.), and after invoking the comparison callback verify the
sentinel/version value still matches the original—if it changed call
vm.new_value_error("list modified during sort"); ensure the sentinel is robust
across mem::take-style operations so any mutation during the sort is always
detected.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: cb62b91d-f14d-458c-b20c-30c0f3a94d69

📥 Commits

Reviewing files that changed from the base of the PR and between 005860c and baac056.

⛔ Files ignored due to path filters (1)
  • Lib/test/test_sort.py is excluded by !Lib/**
📒 Files selected for processing (1)
  • crates/vm/src/builtins/list.rs

Comment thread crates/vm/src/builtins/list.rs Outdated
Comment on lines 399 to 401
if elements.capacity() > 0 {
return Err(vm.new_value_error("list modified during sort"));
}

@coderabbitai coderabbitai Bot Mar 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

capacity() > 0 still allows mutation false negatives.

This condition can miss real mutations when callback code mutates and then restores capacity to zero (for example L.append(1); L.clear()), because clear() uses mem::take at Line 235 and resets capacity back to 0. In that case, sort can incorrectly return Ok(()) instead of raising ValueError.

Please replace the capacity heuristic with an explicit mutation sentinel/version flag during the sort window so any mutating API call is detected regardless of final Vec capacity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/vm/src/builtins/list.rs` around lines 399 - 401, The current post-sort
check using elements.capacity() is unreliable; replace it with an explicit
mutation sentinel/version flag on the list that is set when entering the sort
window and cleared on exit, update that sentinel in every mutating API (e.g.,
append, clear, extend, insert, pop, remove, etc.), and after invoking the
comparison callback verify the sentinel/version value still matches the
original—if it changed call vm.new_value_error("list modified during sort");
ensure the sentinel is robust across mem::take-style operations so any mutation
during the sort is always detected.

✅ Addressed in commit ce3dd25

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh.. I missed that case.

Some possible alternatives I can think of:

  • Add a mutation flag to PyList and set it via borrow_vec_mut()
  • Change clear() to use Vec::clear() instead of mem::take (preserves capacity, but delays memory deallocation. However... it affects memory usage, so it is not a good method.)
  • etc

Any suggestions on how best to handle this?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rlaisqls, great question — the mutation counter approach is the closest Rust equivalent to CPython's sentinel strategy and is the most robust. Here's a concrete plan:


Recommended: per-list mutation counter

Add an AtomicUsize field to PyList, increment it in every mutating method, and compare the snapshot taken before sorting to the value after. This is a direct parallel to CPython's allocated = -1 sentinel, and it handles all mutation shapes (append+clear, append+pop, setitem, delitem, etc.).

// 1. Extend the struct
#[pyclass(module = false, name = "list", unhashable = true, traverse = "manual")]
#[derive(Default)]
pub struct PyList {
    elements: PyRwLock<Vec<PyObjectRef>>,
    mutation_count: core::sync::atomic::AtomicUsize,
}
// 2. Add a private helper
impl PyList {
    #[inline]
    fn notify_mutation(&self) {
        self.mutation_count.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
    }
}
// 3. Call notify_mutation() in every mutating method
#[pymethod]
pub(crate) fn append(&self, x: PyObjectRef) {
    self.notify_mutation();
    self.borrow_vec_mut().push(x);
}

#[pymethod]
fn clear(&self) {
    self.notify_mutation();
    let _removed = core::mem::take(self.borrow_vec_mut().deref_mut());
}

#[pymethod]
pub(crate) fn extend(&self, x: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> {
    let mut new_elements = x.try_to_value(vm)?;
    self.notify_mutation();
    self.borrow_vec_mut().append(&mut new_elements);
    Ok(())
}

// … similarly for insert, pop, remove, reverse,
// _setitem, _delitem, __iadd__, irepeat, etc.
// 4. Update sort() to use the counter
#[pymethod]
pub(crate) fn sort(&self, options: SortOptions, vm: &VirtualMachine) -> PyResult<()> {
    let count_before = self.mutation_count.load(core::sync::atomic::Ordering::Relaxed);

    let mut elements = core::mem::take(self.borrow_vec_mut().deref_mut());
    let res = do_sort(vm, &mut elements, options.key, options.reverse);
    core::mem::swap(self.borrow_vec_mut().deref_mut(), &mut elements);
    res?;

    if self.mutation_count.load(core::sync::atomic::Ordering::Relaxed) != count_before {
        return Err(vm.new_value_error("list modified during sort"));
    }

    Ok(())
}

Why this is better than the alternatives

Approach Handles append+clear? Memory cost Correctness risk
capacity() > 0 (current) None Misses mem::take-based clears
Vec::clear() in clear() Higher (keeps allocation) Breaks GC / freelist behaviour
Flag via borrow_vec_mut() Minimal Sort's own lock calls trigger it → needs suppression logic
Mutation counter (recommended) One usize (~8 bytes) None — sort's internal take/swap don't call any mutating #[pymethod]

The counter approach also naturally handles concurrent GIL-released scenarios and nested sorts on the same list, and it keeps borrow_vec_mut() unchanged (no need to thread extra context through the write guard).

Would you like me to generate the full diff as a `` block?


🧠 Learnings used
Learnt from: jackoconnordev
Repo: RustPython/RustPython PR: 6086
File: benches/microbenchmarks/sort.py:7-7
Timestamp: 2025-08-09T22:55:10.512Z
Learning: In RustPython benchmarks, when testing sorting performance, benchmarking only `sorted()` is sufficient since it internally uses the same sorting logic as `list.sort()`. The only difference is that `sorted()` creates a copy first, so testing both would be redundant for evaluating the sorting algorithm's performance.

Learnt from: ShaharNaveh
Repo: RustPython/RustPython PR: 7181
File: crates/stdlib/src/csv.rs:20-20
Timestamp: 2026-02-17T07:54:36.200Z
Learning: In RustPython, ensure that modules using Python sequence-like types bring in extension methods by importing the trait (for example, 'use rustpython_vm::sliceable::SliceableSequenceOp;'). Even if the trait name isn't directly referenced in code, its methods (such as len()) become in-scope on types like PyStr. Add this import in modules that rely on those extension methods to avoid compilation errors.

Failed to handle agent chat message. Please try again.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai tell me how CPython 3.14.3 handles this problem

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@youknowone, here is a precise breakdown of how CPython (from 2.x through the current 3.14 main branch) handles this — the mechanism has not changed across versions.


How CPython detects list mutation during sort

Before sorting — establish a sentinel

CPython saves the current ob_size, ob_item, and allocated fields, then zeroes out ob_size, sets ob_item = NULL, and sets allocated = -1. The comment reads: /* any operation will reset it to >= 0 */

The allocated field has a strict invariant in normal use: 0 <= ob_size <= allocated. list.sort() deliberately violates this by temporarily setting allocated to -1 to detect mutations.

The list is thus left in a publicly-visible empty state (from Python code len(L) == 0 during the sort). The real elements are tucked away in the local saved_ob_item pointer, invisible to Python callbacks.

After sorting — check the sentinel

After the sort completes, CPython checks: if (self->allocated != -1 && result != NULL) — this is the point where it detects that "the user mucked with the list during the sort".

Because every single mutating operation on a CPython list — append, extend, insert, pop, remove, clear, __setitem__, __delitem__, __iadd__, __imul__, etc. — funnels through list_resize(), which unconditionally sets allocated to a non-negative value, the -1 sentinel is impossible to encounter unless no mutation occurred. Even list.clear() goes through list_resize(0), which sets allocated = 0, not -1.


Why the current RustPython approach breaks down

RustPython's elements.capacity() > 0 check mirrors the CPython intent, but fails because Rust's Vec memory model is different:

  • Vec::clear() retains the allocation → capacity() stays > 0detects mutation correctly
  • core::mem::take() (used in RustPython's own clear() method at Line 235) produces a brand-new empty Vec with capacity 0 — indistinguishable from the sort's own internal sentinel state → mutation silently missed

The CPython sentinel works because -1 is structurally impossible for allocated under any normal operation. RustPython's capacity() == 0 is not similarly exclusive — it can be achieved by user-initiated mem::take-style clears.


Recommended fix (recap)

The Rust equivalent of CPython's -1 sentinel is an explicit mutation counter (AtomicUsize) on PyList, incremented in every #[pymethod] that mutates the list. A snapshot taken before the sort is compared to the value after — any difference means mutation occurred. This is exactly what I outlined in my earlier comment, and it's the closest structural analogue to CPython's approach.


🧠 Learnings used
Learnt from: jackoconnordev
Repo: RustPython/RustPython PR: 6086
File: benches/microbenchmarks/sort.py:7-7
Timestamp: 2025-08-09T22:55:10.512Z
Learning: In RustPython benchmarks, when testing sorting performance, benchmarking only `sorted()` is sufficient since it internally uses the same sorting logic as `list.sort()`. The only difference is that `sorted()` creates a copy first, so testing both would be redundant for evaluating the sorting algorithm's performance.

Learnt from: ShaharNaveh
Repo: RustPython/RustPython PR: 7181
File: crates/stdlib/src/csv.rs:20-20
Timestamp: 2026-02-17T07:54:36.200Z
Learning: In RustPython, ensure that modules using Python sequence-like types bring in extension methods by importing the trait (for example, 'use rustpython_vm::sliceable::SliceableSequenceOp;'). Even if the trait name isn't directly referenced in code, its methods (such as len()) become in-scope on types like PyStr. Add this import in modules that rely on those extension methods to avoid compilation errors.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

@youknowone youknowone Mar 15, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about adding a counter of atomicu32?

struct PyList {
   elements: ...
   mutation_state: AtomicU32 (or AtomicU8)
}

Mutating APIs can check if the mutation_state is changed.

Because the underlying layout of Vec is [usize; 3], the changed layout will fit in [usize; 4]. Not bad, it will be aligned well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By "Mutating APIs can check if the mutation_state is changed", do you mean something like this?

pub struct PyList {
     elements: PyRwLock<Vec<PyObjectRef>>,
+    mutation_state: AtomicU32,
}

 pub fn borrow_vec_mut(&self) -> PyRwLockWriteGuard<'_, Vec<PyObjectRef>> {
+    self.mutation_state.fetch_add(1, Ordering::Relaxed);
     self.elements.write()
}

 pub(crate) fn sort(&self, options: SortOptions, vm: &VirtualMachine) -> PyResult<()> {
-    let mut elements = core::mem::take(self.borrow_vec_mut().deref_mut());
+    let mut elements = core::mem::take(self.elements.write().deref_mut());
+    let version_before = self.mutation_state.load(Ordering::Relaxed);

     let res = do_sort(vm, &mut elements, options.key, options.reverse);
-    core::mem::swap(self.borrow_vec_mut().deref_mut(), &mut elements);
+    let mutated = self.mutation_state.load(Ordering::Relaxed) != version_before;
+    core::mem::swap(self.elements.write().deref_mut(), &mut elements);
     res?;

-    if elements.capacity() > 0 {
+    if mutated {
         return Err(vm.new_value_error("list modified during sort"));
     }
     Ok(())
}

All mutating APIs go through borrow_vec_mut(), so incrementing the counter there catches every mutation including append + clear where the final Vec state looks untouched. sort() bypasses borrow_vec_mut() for its own take/swap to avoid false positives.

One concern is that sort() calls self.elements.write() directly to skip the counter, which means it would bypass any future logic added to borrow_vec_mut().

Also, since AtomicU32::fetch_add wraps around, exactly 2^32 mutations during a single sort could theoretically cause a false negative. Is this safe to ignore?

Does this look right to you?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought the opposite way. borrow_vec_mut just check the mutation. And sort increase state. But I just realized it doesn't make sense because borrow_vec_mut return lock.

Yes, let's try your way and check how things go

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate it, I've pushed the update.
Let me know if anything needs adjustment.

…ction

The capacity heuristic missed mutations when `clear()` reset capacity to
0 via `mem::take`. An AtomicU32 counter on PyList, incremented in
`borrow_vec_mut()`, reliably detects all mutations during sort.
@rlaisqls rlaisqls force-pushed the fix/sort-mutation-detection branch 2 times, most recently from d1715a6 to 17351d4 Compare March 18, 2026 13:44

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/vm/src/builtins/list.rs`:
- Around line 398-402: Move the mutation counter snapshot to while the same
write guard that extracts the temporary buffer is still held, and compute the
"mutated" flag only after reacquiring that same write guard and comparing the
current counter to the earlier snapshot; specifically, in the sort
implementation around do_sort, take self.elements.write(), call
core::mem::take(...) and then immediately read version_before =
self.mutation_counter.load(... ) while still holding that guard, drop the guard
to call do_sort, then reacquire self.elements.write() and set mutated =
self.mutation_counter.load(...) != version_before before calling
core::mem::swap(...) so no concurrent append can be lost between unlock and
swap.
- Around line 140-142: The mutation counter is incremented unconditionally in
borrow_vec_mut(), so every mutable borrow (even if no actual write occurs) bumps
the version; move the increment out of borrow_vec_mut() and only increment when
a real mutation happens. Either increment mutation_counter.fetch_add(1,
Ordering::Relaxed) at each concrete mutator site that changes elements (e.g.,
methods implementing pop/append/insert/reverse/sort) or replace borrow_vec_mut()
with/return a custom write-guard wrapper (wrap the PyRwLockWriteGuard from
elements.write() in a new guard type) that records whether a write occurred and
increments mutation_counter once on the first actual modification before drop;
reference borrow_vec_mut, mutation_counter, elements.write, and the mutator
methods (sort/reverse/pop) to find all spots to adjust.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: f2dabd08-ff23-49b3-a0bc-54da0468af2b

📥 Commits

Reviewing files that changed from the base of the PR and between ce3dd25 and 17351d4.

⛔ Files ignored due to path filters (1)
  • Lib/test/test_sort.py is excluded by !Lib/**
📒 Files selected for processing (1)
  • crates/vm/src/builtins/list.rs

Comment thread crates/vm/src/builtins/list.rs Outdated
Comment thread crates/vm/src/builtins/list.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
crates/vm/src/builtins/list.rs (1)

140-142: ⚠️ Potential issue | 🔴 Critical

borrow_vec_mut() currently over-counts and can still miss a real sort-window mutation.

At Line 141, the counter increments before acquiring the write lock. A thread can increment, block on elements.write(), then mutate during sort() while version_before already includes that increment, so the compare at Line 406 can miss that real mutation. It also counts failed/no-op mutator attempts as mutations.

Suggested direction
 pub fn borrow_vec_mut(&self) -> PyRwLockWriteGuard<'_, Vec<PyObjectRef>> {
-    self.mutation_counter.fetch_add(1, Ordering::Relaxed);
-    self.elements.write()
+    // Do not bump here; this function is also used by paths that may fail/no-op.
+    self.elements.write()
 }

Then increment mutation_counter only on confirmed writes (or via a dedicated mutation guard that marks dirty on first actual modification).

#!/bin/bash
set -euo pipefail

# Verify counter bump placement and call sites that can error/no-op after borrow.
rg -n -C2 'fn borrow_vec_mut|fetch_add|self\.elements\.write\(\)|fn pop\(|fn _setitem|fn _delitem|setitem_by_index|delitem_by_index' crates/vm/src/builtins/list.rs

Expected verification outcome: fetch_add appears before lock acquisition in borrow_vec_mut, and there are mutator paths that borrow mutably but can return errors without committing a write.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/vm/src/builtins/list.rs` around lines 140 - 142, The mutation counter
is incremented too early in borrow_vec_mut (calling
self.mutation_counter.fetch_add before self.elements.write()), which over-counts
and can miss real mutations during sort; change borrow_vec_mut to acquire the
write lock first (self.elements.write()) and only increment mutation_counter
after a confirmed mutation, either by moving fetch_add to after successful
mutation or by returning/using a dedicated mutation guard from borrow_vec_mut
that marks/increments the counter on first actual modification (or on Drop if
mutated), and update all mutator paths (pop, _setitem, _delitem,
setitem_by_index, delitem_by_index) to ensure they either perform the increment
when they succeed or use the new mutation guard so no failed/no-op attempts are
counted.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@crates/vm/src/builtins/list.rs`:
- Around line 140-142: The mutation counter is incremented too early in
borrow_vec_mut (calling self.mutation_counter.fetch_add before
self.elements.write()), which over-counts and can miss real mutations during
sort; change borrow_vec_mut to acquire the write lock first
(self.elements.write()) and only increment mutation_counter after a confirmed
mutation, either by moving fetch_add to after successful mutation or by
returning/using a dedicated mutation guard from borrow_vec_mut that
marks/increments the counter on first actual modification (or on Drop if
mutated), and update all mutator paths (pop, _setitem, _delitem,
setitem_by_index, delitem_by_index) to ensure they either perform the increment
when they succeed or use the new mutation guard so no failed/no-op attempts are
counted.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: f83cff0f-7c5b-479c-bb5e-12b27f34817d

📥 Commits

Reviewing files that changed from the base of the PR and between 17351d4 and 46467dc.

📒 Files selected for processing (1)
  • crates/vm/src/builtins/list.rs

@youknowone

Copy link
Copy Markdown
Member

@rlaisqls Could you confirm if the latest coderabbitai comment is correct or not?

@rlaisqls

Copy link
Copy Markdown
Contributor Author

About coderabbitai comment, the first (false positive) is intentionally kept as-is; it's conservative and matches CPython's behavior.

The second issue is addressed, counter now increments after lock acquisition, so the snapshot/compare happens under the same lock guard, closing the race gap.

@youknowone youknowone left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Thank you for contributing!

@youknowone youknowone merged commit 1b9dc4e into RustPython:main Mar 20, 2026
16 checks passed
youknowone pushed a commit to youknowone/RustPython that referenced this pull request Mar 22, 2026
…ustPython#7432)

* Detect list mutation during sort even when list length is unchanged

* Use mutation counter instead of capacity check for sort mutation detection

The capacity heuristic missed mutations when `clear()` reset capacity to
0 via `mem::take`. An AtomicU32 counter on PyList, incremented in
`borrow_vec_mut()`, reliably detects all mutations during sort.

* Hold write guard during sort mutation counter reads

* Fix list mutation counter race in `borrow_vec_mut`
Copilot AI pushed a commit that referenced this pull request Mar 25, 2026
…7432)

* Detect list mutation during sort even when list length is unchanged

* Use mutation counter instead of capacity check for sort mutation detection

The capacity heuristic missed mutations when `clear()` reset capacity to
0 via `mem::take`. An AtomicU32 counter on PyList, incremented in
`borrow_vec_mut()`, reliably detects all mutations during sort.

* Hold write guard during sort mutation counter reads

* Fix list mutation counter race in `borrow_vec_mut`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants