From 1f6e77f048b861fd030c5a1f492927471910fcda Mon Sep 17 00:00:00 2001 From: Sam Gross Date: Wed, 17 Sep 2025 11:56:03 -0400 Subject: [PATCH 1/3] gh-139001: Fix thread-safety issue in `pathlib.Path` Don't cache the joined path in `_raw_path` because the caching isn't thread safe. --- Lib/pathlib/__init__.py | 7 +------ .../Library/2025-09-17-12-07-21.gh-issue-139001.O6tseN.rst | 2 ++ 2 files changed, 3 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-09-17-12-07-21.gh-issue-139001.O6tseN.rst diff --git a/Lib/pathlib/__init__.py b/Lib/pathlib/__init__.py index bc39a30c6538ce..bd8fd8f0ce4681 100644 --- a/Lib/pathlib/__init__.py +++ b/Lib/pathlib/__init__.py @@ -335,13 +335,8 @@ def _raw_path(self): return paths[0] elif paths: # Join path segments from the initializer. - path = self.parser.join(*paths) - # Cache the joined path. - paths.clear() - paths.append(path) - return path + return self.parser.join(*paths) else: - paths.append('') return '' @property diff --git a/Misc/NEWS.d/next/Library/2025-09-17-12-07-21.gh-issue-139001.O6tseN.rst b/Misc/NEWS.d/next/Library/2025-09-17-12-07-21.gh-issue-139001.O6tseN.rst new file mode 100644 index 00000000000000..3ad5a1272df4af --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-09-17-12-07-21.gh-issue-139001.O6tseN.rst @@ -0,0 +1,2 @@ +Fix race condition in :class:`pathlib.Path` on the internal ``_raw_paths`` +field. From 6ea3be373dbae9d98d30c9ede98ad39039705558 Mon Sep 17 00:00:00 2001 From: Sam Gross Date: Mon, 22 Sep 2025 14:00:14 +0000 Subject: [PATCH 2/3] Add tests case --- Lib/test/test_pathlib/test_pathlib.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Lib/test/test_pathlib/test_pathlib.py b/Lib/test/test_pathlib/test_pathlib.py index a1105aae6351b6..47262fc8966608 100644 --- a/Lib/test/test_pathlib/test_pathlib.py +++ b/Lib/test/test_pathlib/test_pathlib.py @@ -11,6 +11,7 @@ import socket import stat import tempfile +import threading import unittest from unittest import mock from urllib.request import pathname2url @@ -20,6 +21,7 @@ from test.support import is_emscripten, is_wasi, is_wasm32 from test.support import infinite_recursion from test.support import os_helper +from test.support import threading_helper from test.support.os_helper import TESTFN, FS_NONASCII, FakePath try: import fcntl @@ -1098,6 +1100,27 @@ def test_is_relative_to_windows(self): self.assertFalse(p.is_relative_to(P('//z/Share/Foo'))) self.assertFalse(p.is_relative_to(P('//Server/z/Foo'))) + @threading_helper.requires_working_threading() + def test_raw_path_multithread(self): + P = self.cls + sep = self.sep + + NUM_THREADS = 10 + NUM_ITERS = 10 + + for _ in range(NUM_ITERS): + b = threading.Barrier(NUM_THREADS) + path = P('a') / 'b' / 'c' / 'd' / 'e' + expected = sep.join(['a', 'b', 'c', 'd', 'e']) + + def check_raw_path(): + b.wait() + self.assertEqual(path._raw_path, expected) + + threads = [threading.Thread(target=check_raw_path) for _ in range(NUM_THREADS)] + with threading_helper.start_threads(threads): + pass + class PurePosixPathTest(PurePathTest): cls = pathlib.PurePosixPath From f1ac623dd174752b4405db59f77a3ec1702f5d51 Mon Sep 17 00:00:00 2001 From: Sam Gross Date: Mon, 22 Sep 2025 14:11:00 +0000 Subject: [PATCH 3/3] Test Path.parts instead of private _raw_path property --- Lib/test/test_pathlib/test_join.py | 22 ++++++++++++++++++++++ Lib/test/test_pathlib/test_pathlib.py | 23 ----------------------- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/Lib/test/test_pathlib/test_join.py b/Lib/test/test_pathlib/test_join.py index f1a24204b4c30a..6b51a09e5ac168 100644 --- a/Lib/test/test_pathlib/test_join.py +++ b/Lib/test/test_pathlib/test_join.py @@ -3,6 +3,8 @@ """ import unittest +import threading +from test.support import threading_helper from .support import is_pypi from .support.lexical_path import LexicalPath @@ -158,6 +160,26 @@ def test_parts(self): parts = p.parts self.assertEqual(parts, (sep, 'a', 'b')) + @threading_helper.requires_working_threading() + def test_parts_multithreaded(self): + P = self.cls + + NUM_THREADS = 10 + NUM_ITERS = 10 + + for _ in range(NUM_ITERS): + b = threading.Barrier(NUM_THREADS) + path = P('a') / 'b' / 'c' / 'd' / 'e' + expected = ('a', 'b', 'c', 'd', 'e') + + def check_parts(): + b.wait() + self.assertEqual(path.parts, expected) + + threads = [threading.Thread(target=check_parts) for _ in range(NUM_THREADS)] + with threading_helper.start_threads(threads): + pass + def test_parent(self): # Relative P = self.cls diff --git a/Lib/test/test_pathlib/test_pathlib.py b/Lib/test/test_pathlib/test_pathlib.py index 47262fc8966608..a1105aae6351b6 100644 --- a/Lib/test/test_pathlib/test_pathlib.py +++ b/Lib/test/test_pathlib/test_pathlib.py @@ -11,7 +11,6 @@ import socket import stat import tempfile -import threading import unittest from unittest import mock from urllib.request import pathname2url @@ -21,7 +20,6 @@ from test.support import is_emscripten, is_wasi, is_wasm32 from test.support import infinite_recursion from test.support import os_helper -from test.support import threading_helper from test.support.os_helper import TESTFN, FS_NONASCII, FakePath try: import fcntl @@ -1100,27 +1098,6 @@ def test_is_relative_to_windows(self): self.assertFalse(p.is_relative_to(P('//z/Share/Foo'))) self.assertFalse(p.is_relative_to(P('//Server/z/Foo'))) - @threading_helper.requires_working_threading() - def test_raw_path_multithread(self): - P = self.cls - sep = self.sep - - NUM_THREADS = 10 - NUM_ITERS = 10 - - for _ in range(NUM_ITERS): - b = threading.Barrier(NUM_THREADS) - path = P('a') / 'b' / 'c' / 'd' / 'e' - expected = sep.join(['a', 'b', 'c', 'd', 'e']) - - def check_raw_path(): - b.wait() - self.assertEqual(path._raw_path, expected) - - threads = [threading.Thread(target=check_raw_path) for _ in range(NUM_THREADS)] - with threading_helper.start_threads(threads): - pass - class PurePosixPathTest(PurePathTest): cls = pathlib.PurePosixPath