Skip to content

gh-157841: Synchronize access to functools.partial's mutable state - #157979

Closed
Adarsh-Me wants to merge 2 commits into
python:mainfrom
Adarsh-Me:gh-157841-partial-setstate
Closed

Adarsh-Me wants to merge 2 commits into
python:mainfrom
Adarsh-Me:gh-157841-partial-setstate

Conversation

@Adarsh-Me

@Adarsh-Me Adarsh-Me commented Sep 23, 2026 •

Copy link
Copy Markdown

Bug report

  • Issue: 157841
  • Type: crash / data race in a free-threaded build

functools.partial mutates its own state from __setstate__() with no
synchronisation against the readers of that state. Under --disable-gil the
fields (func, args, keywords, phcount) and the vectorcall slot are
published as independent stores, so a reader can combine two different states,
and the displaced values' decrefs can run arbitrary code while the object is
half-updated.

Coordinating with @espressolee on #157841, who pointed out that the readers
(partial_repr, partial_call, partial_vectorcall) take no critical section
either at the current head — so making only the writer atomic would leave a
reader sampling each field independently. Two separate invariants had to hold:

  • lifetime — Py_NewRef(pto->args) loads then increments, so a
    concurrent replacement can drop the last reference in between; the
    acquisition itself has to be inside the critical section, not just the use;
  • coherence — partial_vectorcall samples phcount and args
    separately and combines them into tot_nargs, so both have to come from
    one state.

How to fix the bug

Every access path takes a critical section on the partial and reads or writes
the whole state from it:

  • partial_setstate() builds the replacement values first, then installs
    func, args, keywords, phcount and the vectorcall pointer as one
    unit, and only drops the displaced references after the new state is fully
    published (their __del__/finalisers can re-enter and read the partial);
  • partial_repr(), partial_call() and partial_vectorcall() take the same
    section, copy out the strong references and phcount, then do the
    formatting or the call after leaving it;
  • partial_new()'s flatten path reaches inside its argument to copy
    func/args/keywords, which is a reader like any other, so it goes
    through the same protocol and owns the references it takes.

PR feature rule

This is a bugfix, not a new feature.

Core Dev guide

  • Devguide: add tests for bugs (not applied to cosmetic changes)

Docs & Tools

  • Documentation changes: not needed, this is a concurrency fix in an
    existing method's behaviour.

Verification

Free-threaded amd64t Release build of this branch (MSC v.1929,
3.16.0a0), Windows. The two new TestPartial cases were run against two
builds that differ only in Modules/_functoolsmodule.c:

build python -m test test_free_threading.test_functools -v
main's _functoolsmodule.c, new tests present test_concurrent_setstate_and_reads → Fatal Python error: PyMutex_Unlock: unlocking mutex that is not locked
this branch Ran 7 tests ... OK, Result: SUCCESS

The tests are 10 threads × 1000 operations with sys.setswitchinterval(1e-6),
covering repr + __reduce__ + call + partial(p, 3) flattening against a
concurrent __setstate__, and they check a torn read rather than only a
crash: every state written carries the same value twice, once positionally and
once as a keyword, so a mixture of two states fails an assertion instead of
happening to survive.

What I did not measure: a writer-only patch (critical section in
partial_setstate() but not in the readers), which is the shape @espressolee
rightly says would not give readers a coherent snapshot. Happy to add that data
point if it is useful, and to run anything on a Linux build — the numbers above
are Windows-only.

partialobject's fn, args, kw, phcount, dict and vectorcall fields were read
without synchronization by partial_repr(), partial_vectorcall(),
partial_call(), partial_reduce() and partial_new() while partial_setstate()
republished them, so a reader could observe a mixture of two states, or index
a tuple that a concurrent __setstate__() had just dropped the last reference
to.

Every write now happens under the object's critical section, with the
displaced values released after it because their finalizers can run arbitrary
code. Each reader takes one coherent snapshot of the mutable fields as owned
references and works from that. partial_new()'s flatten path takes owned
references from the inner partial under that partial's lock, which is how
pythongh-157099 reaches the same crash without any shared mutation of the object
being built.
Race __setstate__() against repr(), a call through both tp_call and
vectorcall, __reduce__() and a nested partial(), and assert that a reader
never combines the positional arguments of one state with the keywords of
another.  Every state the test installs carries the same value twice, once
reachable as a positional argument and once as a keyword, so a torn read
fails as a mismatch instead of only as a crash.

test_concurrent_setstate_writers passes without the fix; it is there to
guard the ordering of the displaced-value decrefs, not to reproduce a
failure.
@python-cla-bot

Copy link
Copy Markdown

The following commit authors need to sign the Contributor License Agreement:

CLA not signed

@StanFromIreland

Copy link
Copy Markdown
Member

You need to sign the CLA before we can review.

@StanFromIreland
StanFromIreland marked this pull request as draft September 23, 2026 08:32
@Adarsh-Me

Copy link
Copy Markdown
Author

The Sanitizers / TSan (free-threading) job is red, and the report is worth reading rather than working around: it found a race this patch does not fix, and did not introduce.

WARNING: ThreadSanitizer: data race
  Read of size 8 by thread T893:
    #0 _PyVectorcall_FunctionInline  ./Include/internal/pycore_call.h:105:5
    #1 _PyObject_VectorcallTstate / PyObject_Vectorcall / _Py_VectorCall...StackRefSteal (ceval.c:770)
  Previous write of size 8 by thread T887:
    #0 partial_setvectorcall  Modules/_functoolsmodule.c
    #1 partial_setstate       Modules/_functoolsmodule.c:924
SUMMARY: ThreadSanitizer: data race ./Include/internal/pycore_call.h:105:5 in _PyVectorcall_FunctionInline

So the racing pair is not any of the accesses I synchronised. The writer is partial_setstate publishing pto->vectorcall; the reader is the interpreter's own dispatch, which loads the per-object vectorcall slot to decide how to call the partial — before partial_vectorcall runs, so no critical section on the partial can cover it. The load at pycore_call.h:105 is a memcpy into a local, which keeps the read well-defined but is not an atomic acquire.

I checked this is not something my change brought in: main already calls partial_setvectorcall() from partial_setstate() (:861 in git show main:Modules/_functoolsmodule.c), so the same race exists today. What the new test does is exercise __setstate__ concurrently with calls, which is what makes TSan see it. The four TestPartial cases and the rest of test_functools pass in that same job — this is the only report.

Two ways out, and I would rather get a steer than guess, because both reach outside _functoolsmodule.c:

  1. Stop mutating the slot after construction. partial_setvectorcall only encodes "is func vectorcall-able", choosing between partial_vectorcall and NULL (falling back to tp_call → partial_call). If partial_vectorcall handled the not-vectorcall-able func itself, the slot could be set once in partial_new and never written again, and the race disappears for every object rather than being papered over with atomics. This looks like the more correct fix to me, but it duplicates dispatch logic and changes a hot path.
  2. Make the slot a real atomic, ATOMIC_LOAD at pycore_call.h:105 and ATOMIC_STORE in every per-object writer. That is a core-wide convention change, not something a functools bugfix should smuggle in.

Until one of those lands, this PR fixes the state tearing and the lifetime hazard (the crash the issue reported — PyMutex_Unlock: unlocking mutex that is not locked, reproduced against main in a debug-free build) while a separate, pre-existing race on the vectorcall slot stays visible only to TSan. If you would rather see that split, I will narrow this to the fields the issue covers and file the slot race on its own.

Also: the CLA Signing check is red for me, which is mine to sort out and not something about the patch.

@espressolee

Copy link
Copy Markdown

Thanks for opening the PR and following up. Sorry for the delayed reply. I haven’t run the four-mode validation I offered, so I don’t have independent test results to report.

@espressolee

Copy link
Copy Markdown

Additional bounded validation at 51351e7:

  • Native macOS 26.6.2 / arm64, CPython 3.16.0a0, Apple Clang 21.0.0. Out-of-tree build with --disable-gil --without-ensurepip --with-openssl=/opt/homebrew/opt/openssl@3; make -j4 completed successfully. Optional _gdbm was unavailable.
  • A selected ordinary functional-regression subset from test.test_functools: 26 TestPartialC cases, 25 TestPartialCSubclass cases and all 12 TestPartialMethod cases. 63/63 passed with -X gil=0, and 63/63 with -X gil=1 on the same binary, with zero failures, errors or skips. The actual GIL state was checked before and after each suite.
  • No source changes; the checkout was clean before and after testing. The version string contained -dirty, which I traced to the out-of-tree Makefile's git --git-dir invocation without a work-tree argument: it reproduces from the build directory, while git describe from the clean source directory does not. An initial harness identity assertion failed before any test ran because the test module loads a fresh _functools; that harness check was corrected without changing the selected tests. The failed attempt was retained.

This is ordinary regression evidence, not the four-mode concurrent validation I offered; that remains unrun. It does not establish race freedom.

Two details from the existing CI logs also seem relevant:

  1. Windows x64 free-threading initially timed out at test_type.TestType.test_race_type_attr_added (10 minutes), then passed on rerun. This identifies the reported timeout location, not its cause or independence from this PR.
  2. Free-threading TSan contains three data-race warning occurrences, including completed summaries at both the vectorcall and atomic-load sites. These are not automatically three distinct bugs, but I would not describe the log as containing only the vectorcall report. I have not independently established base attribution for the warnings.

Those CI jobs checked out merge commit 240bdf37156237190528dcd6adb40fb4dd2cd374, rather than the standalone head used locally.

Exact ordinary-test selection

The following methods in TestPartialC, also in TestPartialCSubclass except test_nested_optimization (which upstream disables for that subclass), plus all methods in TestPartialMethod:

test_basic_examples, test_attributes, test_argument_checking, test_protection_of_callers_dict_argument, test_kwargs_copy, test_arg_combinations, test_kw_combinations, test_positional, test_keyword, test_no_side_effects, test_error_propagation, test_with_bound_and_unbound_methods, test_nested_optimization, test_nested_optimization_bug, test_nested_partial_with_attribute, test_placeholders_trailing_raise, test_placeholders, test_placeholders_optimization, test_placeholders_kw_restriction, test_construct_placeholder_singleton, test_repr, test_pickle, test_copy, test_deepcopy, test_setstate, test_setstate_errors.

AI assistance: Codex performed the build, selected-test execution and CI-log inspection. No source changes were made, and no concurrency reproducers were run in this follow-up.

@espressolee

Copy link
Copy Markdown

Follow-up: the four modes I offered are now executed against PR head 51351e7e416f5402c8720807e7ee293f7e2cbb68.

Native macOS27.0.1/arm64, CPython3.16.0a0 built with --disable-gil, AppleClang21.0.0. I reused the September26 binary; its SHA-256 and the clean source's relevant files were checked against the exact revision. No source changes.

Using the unchanged program from gh-157841, five fresh processes per cell, four seconds per process:

Setting read-only repr repr + writer writer + writer call + writer
GIL disabled, default allocator 5/5 5/5 5/5 5/5
GIL disabled, PYTHONMALLOC=debug 5/5 5/5 5/5 5/5
GIL enabled, default allocator 5/5 5/5 5/5 5/5
GIL enabled, PYTHONMALLOC=debug 5/5 5/5 5/5 5/5

All80processes exited normally, with no signals,30-second timeouts or observed thread exceptions. A wrapper checked actual GIL state before/after and rejected otherwise-unhandled thread exceptions; separate negative fixtures confirmed exception rejection and timeout termination.

The existing test.test_free_threading.test_functools suite also passed7/7 in each configuration:28test executions, zero failures/errors/skips. That includes the PR's three partial concurrency/coherence tests. Raw stdout/stderr and source/binary hashes are retained locally.

This is bounded evidence for this PR head. I did not run a patched3.14backport or repeat the old vulnerable-main baseline, and macOS has changed since the original report. It does not establish race freedom or resolve the existing TSan warnings/base attribution.

AI assistance: Codex performed the execution, checks and result preparation. No source patch or sanitizer reproducer was run.

@espressolee

Copy link
Copy Markdown

@Adarsh-Me — I have completed a local TSan follow-up and put the evaluated implementation and a regression supplement below for review. @rhettinger, once the CLA prerequisite is cleared, could you advise whether this free-threading-only immutable-adapter approach is appropriate here?

The audit at head 51351e7e416f5402c8720807e7ee293f7e2cbb68 found five pointer-access conflicts: the vectorcall slot, the instance dict pointer, and the public func / args / keywords descriptors. The latter descriptors perform atomic reads before their lock fallback, while partial_setstate() currently uses ordinary stores.

I compared the standalone PR with its actual branching base 1596e58f45a26f2a694d26a2b856064640b06608, using matched native macOS 27.0.1 / arm64, Apple Clang 21.0.0, --with-pydebug --disable-gil --with-thread-sanitizer builds. The detector was first qualified with clean-atomic and deliberate-race controls; no suppressions were used. Both base and PR reported the five named conflicts in separate targeted workloads. This establishes their presence on that base locally; it does not reproduce the exact Linux CI merge 240bdf37156237190528dcd6adb40fb4dd2cd374 or its environment.

Access path Base PR First candidate Candidate below
vectorcall slot TSan exit 66 TSan exit 66 clean clean
dict pointer TSan exit 66 TSan exit 66 clean clean
public func/args/keywords (each) TSan exit 66 TSan exit 66 still races clean

The first candidate also passed the three existing TestPartial tests, so those tests alone did not cover the surviving member paths.

The candidate below keeps the partial's vectorcall slot immutable after construction in a free-threaded build. The existing generic vectorcall helper already handles a wrapped callable without vectorcall by falling back to its tp_call. For dict replacement, it uses the same _PyObject_XSetRefDelayed primitive as generic __dict__ assignment: atomic publication and QSBR retirement of the displaced dict. Atomic-store-only plus immediate decref would leave a lifetime obligation. The three public member stores follow the existing member-setter release-store protocol. Internal compound snapshots retain the partial lock.

Local results for this candidate:

  • All five named pointer workloads, the three existing partial concurrency tests, and additional fresh-dict retirement, finalizer reentrancy, callable switching, placeholder and keyword checks completed without TSan warnings.
  • Complete original free-threading functools module: 7/7, no skips. Complete test_functools under TSan: 339 executions, 333 passed and 6 existing sanitizer skips, no failures/errors.
  • A separately compiled ordinary GIL build passed test_functools 339/339 and test_call 189/189, no skips. Runtime -X gil=1 on the free-threaded binary was not substituted for this compile-time check.
  • The four added regression tests below each produced a TSan warning and exit 66 on the unmodified PR binary. They each passed on the candidate binary; the supplemented free-threading module completed 11/11, no warnings or skips.

Each targeted workload was a single fresh process; this is bounded engineering evidence, not a failure-rate estimate or a proof of race freedom. Performance of always using the adapter for free-threaded partials, current-main integration, Linux CI and other platforms remain unqualified.

I checked the current CI routing: reusable-san.yml runs python -m test --tsan, whose test set includes test_free_threading; that package discovers this module. Thus the supplement can use the existing Linux TSan job once incorporated. The new tests are not in the upstream PR yet. The current free-threaded TSan job and CLA check remain red on the unchanged head.

Could you review/incorporate the appropriate changes and then run the Linux free-threaded TSan job on the resulting head, including the supplemented module and existing broader test selection? I have not rerun CI on the unchanged revision or treated the local run as Linux qualification. The two patches apply independently to 51351e7e; the test comment is clarified because the wrapped callable lacks vectorcall even when the partial itself uses its adapter.

AI assistance: Codex performed the audit, builds, tests, candidate and regression preparation. No source commit or push has been made for this follow-up. Raw logs, program and binary hashes are retained locally. Patch SHA-256 values: implementation e68734137c91b851274f30856add961e5aa05d42bb5a1f2c9a46276032fdf864; regression supplement 5caff9d936e2aa21ba50bda3d4fd0b2777dace051ada4e3296513143208d8ae4.

Evaluated implementation delta against 51351e7 (one C file, +17/-7)
diff --git a/Modules/_functoolsmodule.c b/Modules/_functoolsmodule.c
index 79a0854..c97dc85 100644
--- a/Modules/_functoolsmodule.c
+++ b/Modules/_functoolsmodule.c
@@ -602,6 +602,12 @@ partial_vectorcall(PyObject *self, PyObject *const *args,
 static void
 partial_setvectorcall(partialobject *pto)
 {
+#ifdef Py_GIL_DISABLED
+    /* Dispatch reads this slot before partial_vectorcall acquires our lock.
+     * Keep it immutable after construction; the generic vectorcall helper
+     * also supports wrapped callables without their own vectorcall slot. */
+    pto->vectorcall = partial_vectorcall;
+#else
     if (PyVectorcall_Function(pto->fn) == NULL) {
         /* Don't use vectorcall if the underlying function doesn't support it */
         pto->vectorcall = NULL;
@@ -612,6 +618,7 @@ partial_setvectorcall(partialobject *pto)
     else {
         pto->vectorcall = partial_vectorcall;
     }
+#endif
 }
 
 
@@ -910,18 +917,22 @@ partial_setstate(PyObject *self, PyObject *state)
     else
         Py_INCREF(dict);
 
-    PyObject *old_fn, *old_args, *old_kw, *old_dict;
+    PyObject *old_fn, *old_args, *old_kw;
     Py_BEGIN_CRITICAL_SECTION(pto);
     old_fn = pto->fn;
     old_args = pto->args;
     old_kw = pto->kw;
-    old_dict = pto->dict;
-    pto->fn = Py_NewRef(fn);
-    pto->args = fnargs;
-    pto->kw = kw;
+    /* Public member descriptors use atomic loads before their lock fallback. */
+    FT_ATOMIC_STORE_PTR_RELEASE(pto->fn, Py_NewRef(fn));
+    FT_ATOMIC_STORE_PTR_RELEASE(pto->args, fnargs);
+    FT_ATOMIC_STORE_PTR_RELEASE(pto->kw, kw);
     pto->phcount = phcount;
-    pto->dict = dict;
+#ifndef Py_GIL_DISABLED
     partial_setvectorcall(pto);
+#endif
+    /* Generic attribute lookup reads this pointer without our lock. Match
+     * the generic __dict__ setter's atomic publication and QSBR retirement. */
+    _PyObject_XSetRefDelayed(&pto->dict, dict);
     Py_END_CRITICAL_SECTION();
 
     // The old values may have arbitrary finalizers, so they must be dropped
@@ -929,7 +940,6 @@ partial_setstate(PyObject *self, PyObject *state)
     Py_XDECREF(old_fn);
     Py_XDECREF(old_args);
     Py_XDECREF(old_kw);
-    Py_XDECREF(old_dict);
     Py_RETURN_NONE;
 }
 
Regression supplement: public member publication and dict lookup
diff --git a/Lib/test/test_free_threading/test_functools.py b/Lib/test/test_free_threading/test_functools.py
index 0c7000d..467b0ac 100644
--- a/Lib/test/test_free_threading/test_functools.py
+++ b/Lib/test/test_free_threading/test_functools.py
@@ -73,8 +73,8 @@ def test_reentrant_cache_clear_bounded(self):
 
 
 class Callable:
-    # Instances have no vectorcall slot, so partial() dispatches them through
-    # tp_call (partial_call) instead of partial_vectorcall.
+    # Instances have no vectorcall slot; generic dispatch to the wrapped
+    # callable therefore falls back to its tp_call.
     def __call__(self, x, y=0, *extra, z=0):
         return (x, y, z)
 
@@ -195,5 +195,55 @@ def thread_func(n):
         self.check_reduce_coherent(p)
 
 
+@threading_helper.requires_working_threading()
+class TestPartialPointerPublication(unittest.TestCase):
+    def _race_public_read(self, read):
+        args = (0, 0)
+        keywords = {"z": 0}
+        states = ((target, args, keywords, {"tag": 0}),
+                  (target, args, keywords, {"tag": 1}))
+        p = partial(target, *args, **keywords)
+        setstate = p.__setstate__
+        setstate(states[0])
+        barrier = Barrier(2)
+        errors = []
+
+        def reader():
+            barrier.wait()
+            try:
+                for _ in range(20000):
+                    read(p, args, keywords)
+            except Exception as exc:
+                errors.append(exc)
+
+        def writer():
+            barrier.wait()
+            try:
+                for i in range(20000):
+                    setstate(states[i % 2])
+            except Exception as exc:
+                errors.append(exc)
+
+        # Retain both states to isolate pointer-publication races from
+        # destruction of the displaced objects. TSan must observe the public
+        # lookup/descriptor path, rather than an internal locked snapshot.
+        with threading_helper.start_threads([Thread(target=reader),
+                                             Thread(target=writer)]):
+            pass
+        self.assertEqual(errors, [])
+
+    def test_concurrent_setstate_func_member(self):
+        self._race_public_read(lambda p, args, kw: self.assertIs(p.func, target))
+
+    def test_concurrent_setstate_args_member(self):
+        self._race_public_read(lambda p, args, kw: self.assertIs(p.args, args))
+
+    def test_concurrent_setstate_keywords_member(self):
+        self._race_public_read(lambda p, args, kw: self.assertIs(p.keywords, kw))
+
+    def test_concurrent_setstate_dict_lookup(self):
+        self._race_public_read(lambda p, args, kw: self.assertIn(p.tag, (0, 1)))
+
+
 if __name__ == "__main__":
     unittest.main()

@StanFromIreland

Copy link
Copy Markdown
Member

I'm closing as the CLA has still not been signed.

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.

3 participants