https://github.com/python/cpython/commit/5bb3bbb9c6a7c9043a04d0cc2e82c83747040788
commit: 5bb3bbb9c6a7c9043a04d0cc2e82c83747040788
branch: main
author: Sam Gross <[email protected]>
committer: colesbury <[email protected]>
date: 2026-02-06T09:43:36-05:00
summary:

gh-144446: Fix some frame object thread-safety issues (gh-144479)

Fix thread-safety issues when accessing frame attributes while another
thread is executing the frame:

- Add critical section to frame_repr() to prevent races when accessing
  the frame's code object and line number

- Add _Py_NO_SANITIZE_THREAD to PyUnstable_InterpreterFrame_GetLasti()
  to allow intentional racy reads of instr_ptr.

- Fix take_ownership() to not write to the original frame's f_executable

files:
A Lib/test/test_free_threading/test_frame.py
A 
Misc/NEWS.d/next/Core_and_Builtins/2026-02-03-17-08-13.gh-issue-144446.db5619.rst
M Objects/frameobject.c
M Python/frame.c

diff --git a/Lib/test/test_free_threading/test_frame.py 
b/Lib/test/test_free_threading/test_frame.py
new file mode 100644
index 00000000000000..bea49df557aa2c
--- /dev/null
+++ b/Lib/test/test_free_threading/test_frame.py
@@ -0,0 +1,151 @@
+import functools
+import sys
+import threading
+import unittest
+
+from test.support import threading_helper
+
+threading_helper.requires_working_threading(module=True)
+
+
+def run_with_frame(funcs, runner=None, iters=10):
+    """Run funcs with a frame from another thread that is currently executing.
+
+    Args:
+        funcs: A function or list of functions that take a frame argument
+        runner: Optional function to run in the executor thread. If provided,
+                it will be called and should return eventually. The frame
+                passed to funcs will be the runner's frame.
+        iters: Number of iterations each func should run
+    """
+    if not isinstance(funcs, list):
+        funcs = [funcs]
+
+    frame_var = None
+    e = threading.Event()
+    b = threading.Barrier(len(funcs) + 1)
+
+    if runner is None:
+        def runner():
+            j = 0
+            for i in range(100):
+                j += i
+
+    def executor():
+        nonlocal frame_var
+        frame_var = sys._getframe()
+        e.set()
+        b.wait()
+        runner()
+
+    def func_wrapper(func):
+        e.wait()
+        frame = frame_var
+        b.wait()
+        for _ in range(iters):
+            func(frame)
+
+    test_funcs = [functools.partial(func_wrapper, f) for f in funcs]
+    threading_helper.run_concurrently([executor] + test_funcs)
+
+
+class TestFrameRaces(unittest.TestCase):
+    def test_concurrent_f_lasti(self):
+        run_with_frame(lambda frame: frame.f_lasti)
+
+    def test_concurrent_f_lineno(self):
+        run_with_frame(lambda frame: frame.f_lineno)
+
+    def test_concurrent_f_code(self):
+        run_with_frame(lambda frame: frame.f_code)
+
+    def test_concurrent_f_back(self):
+        run_with_frame(lambda frame: frame.f_back)
+
+    def test_concurrent_f_globals(self):
+        run_with_frame(lambda frame: frame.f_globals)
+
+    def test_concurrent_f_builtins(self):
+        run_with_frame(lambda frame: frame.f_builtins)
+
+    def test_concurrent_f_locals(self):
+        run_with_frame(lambda frame: frame.f_locals)
+
+    def test_concurrent_f_trace_read(self):
+        run_with_frame(lambda frame: frame.f_trace)
+
+    def test_concurrent_f_trace_opcodes_read(self):
+        run_with_frame(lambda frame: frame.f_trace_opcodes)
+
+    def test_concurrent_repr(self):
+        run_with_frame(lambda frame: repr(frame))
+
+    def test_concurrent_f_trace_write(self):
+        def trace_func(frame, event, arg):
+            return trace_func
+
+        def writer(frame):
+            frame.f_trace = trace_func
+            frame.f_trace = None
+
+        run_with_frame(writer)
+
+    def test_concurrent_f_trace_read_write(self):
+        # Test concurrent reads and writes of f_trace on a live frame.
+        def trace_func(frame, event, arg):
+            return trace_func
+
+        def reader(frame):
+            _ = frame.f_trace
+
+        def writer(frame):
+            frame.f_trace = trace_func
+            frame.f_trace = None
+
+        run_with_frame([reader, writer, reader, writer])
+
+    def test_concurrent_f_trace_opcodes_write(self):
+        def writer(frame):
+            frame.f_trace_opcodes = True
+            frame.f_trace_opcodes = False
+
+        run_with_frame(writer)
+
+    def test_concurrent_f_trace_opcodes_read_write(self):
+        # Test concurrent reads and writes of f_trace_opcodes on a live frame.
+        def reader(frame):
+            _ = frame.f_trace_opcodes
+
+        def writer(frame):
+            frame.f_trace_opcodes = True
+            frame.f_trace_opcodes = False
+
+        run_with_frame([reader, writer, reader, writer])
+
+    def test_concurrent_frame_clear(self):
+        # Test race between frame.clear() and attribute reads.
+        def create_frame():
+            x = 1
+            y = 2
+            return sys._getframe()
+
+        frame = create_frame()
+
+        def reader():
+            for _ in range(10):
+                try:
+                    _ = frame.f_locals
+                    _ = frame.f_code
+                    _ = frame.f_lineno
+                except ValueError:
+                    # Frame may be cleared
+                    pass
+
+        def clearer():
+            frame.clear()
+
+        threading_helper.run_concurrently([reader, reader, clearer])
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git 
a/Misc/NEWS.d/next/Core_and_Builtins/2026-02-03-17-08-13.gh-issue-144446.db5619.rst
 
b/Misc/NEWS.d/next/Core_and_Builtins/2026-02-03-17-08-13.gh-issue-144446.db5619.rst
new file mode 100644
index 00000000000000..71cf49366287ae
--- /dev/null
+++ 
b/Misc/NEWS.d/next/Core_and_Builtins/2026-02-03-17-08-13.gh-issue-144446.db5619.rst
@@ -0,0 +1,2 @@
+Fix data races in the free-threaded build when reading frame object attributes
+while another thread is executing the frame.
diff --git a/Objects/frameobject.c b/Objects/frameobject.c
index 1d4c0f6785c4b8..9d774a71edb797 100644
--- a/Objects/frameobject.c
+++ b/Objects/frameobject.c
@@ -1049,11 +1049,11 @@ static PyObject *
 frame_lasti_get_impl(PyFrameObject *self)
 /*[clinic end generated code: output=03275b4f0327d1a2 input=0225ed49cb1fbeeb]*/
 {
-    int lasti = _PyInterpreterFrame_LASTI(self->f_frame);
+    int lasti = PyUnstable_InterpreterFrame_GetLasti(self->f_frame);
     if (lasti < 0) {
         return PyLong_FromLong(-1);
     }
-    return PyLong_FromLong(lasti * sizeof(_Py_CODEUNIT));
+    return PyLong_FromLong(lasti);
 }
 
 /*[clinic input]
@@ -2053,11 +2053,15 @@ static PyObject *
 frame_repr(PyObject *op)
 {
     PyFrameObject *f = PyFrameObject_CAST(op);
+    PyObject *result;
+    Py_BEGIN_CRITICAL_SECTION(f);
     int lineno = PyFrame_GetLineNumber(f);
     PyCodeObject *code = _PyFrame_GetCode(f->f_frame);
-    return PyUnicode_FromFormat(
+    result = PyUnicode_FromFormat(
         "<frame at %p, file %R, line %d, code %S>",
         f, code->co_filename, lineno, code->co_name);
+    Py_END_CRITICAL_SECTION();
+    return result;
 }
 
 static PyMethodDef frame_methods[] = {
diff --git a/Python/frame.c b/Python/frame.c
index da8f9037e8287a..ff81eb0b3020c7 100644
--- a/Python/frame.c
+++ b/Python/frame.c
@@ -54,7 +54,7 @@ take_ownership(PyFrameObject *f, _PyInterpreterFrame *frame)
     _PyFrame_Copy(frame, new_frame);
     // _PyFrame_Copy takes the reference to the executable,
     // so we need to restore it.
-    frame->f_executable = PyStackRef_DUP(new_frame->f_executable);
+    new_frame->f_executable = PyStackRef_DUP(new_frame->f_executable);
     f->f_frame = new_frame;
     new_frame->owner = FRAME_OWNED_BY_FRAME_OBJECT;
     if (_PyFrame_IsIncomplete(new_frame)) {
@@ -135,14 +135,14 @@ PyUnstable_InterpreterFrame_GetCode(struct 
_PyInterpreterFrame *frame)
     return PyStackRef_AsPyObjectNew(frame->f_executable);
 }
 
-int
+// NOTE: We allow racy accesses to the instruction pointer from other threads
+// for sys._current_frames() and similar APIs.
+int _Py_NO_SANITIZE_THREAD
 PyUnstable_InterpreterFrame_GetLasti(struct _PyInterpreterFrame *frame)
 {
     return _PyInterpreterFrame_LASTI(frame) * sizeof(_Py_CODEUNIT);
 }
 
-// NOTE: We allow racy accesses to the instruction pointer from other threads
-// for sys._current_frames() and similar APIs.
 int _Py_NO_SANITIZE_THREAD
 PyUnstable_InterpreterFrame_GetLine(_PyInterpreterFrame *frame)
 {

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to