Skip to content

Commit 0bb1cff

Browse files
committed
Make use_original thread-local to fix cross-thread races
The use_original flag on FakeOsModule was a process-global class attribute toggled by the use_original_os() context manager. When one thread entered the context manager while another thread was in the middle of a faked filesystem operation, the faked call could observe use_original=True and dispatch to the real os module, failing against paths that only exist in the fake filesystem. Store use_original in a threading.local and have use_original_os() save and restore the previous thread-local value so nested uses continue to work. The class attribute becomes an instance property, preserving the existing instance.use_original get/set API. A regression test in fake_filesystem_unittest_test exercises the concurrent-worker pattern with explicit hammer threads entering use_original_os() alongside asyncio.to_thread workers; without the fix it fails reliably with PermissionError against the fake root. Fixes #1317.
1 parent b907b2b commit 0bb1cff

3 files changed

Lines changed: 121 additions & 5 deletions

File tree

CHANGES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ The released versions correspond to PyPI releases.
77
### Fixes
88
* fixed a crash if the stack limit was set to a low value
99
(see [#1313](https://github.com/pytest-dev/pyfakefs/issues/1313))
10+
* made the `use_original` flag on `FakeOsModule` thread-local to fix a race
11+
condition with async code (see
12+
[#1317](https://github.com/pytest-dev/pyfakefs/issues/1317))
1013

1114
## [Version 6.2.0](https://pypi.python.org/pypi/pyfakefs/6.2.0) (2026-04-12)
1215
Changes the MRO for file wrappers.

pyfakefs/fake_os.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import inspect
2424
import os
2525
import sys
26+
import threading
2627
import uuid
2728
from contextlib import contextmanager
2829
from stat import (
@@ -74,6 +75,15 @@
7475
NR_STD_STREAMS = 3
7576

7677

78+
# Thread-local storage for the `use_original` flag.
79+
_thread_state = threading.local()
80+
81+
82+
def _use_original() -> bool:
83+
"""Return the thread-local `use_original` flag, defaulting to False."""
84+
return getattr(_thread_state, "use_original", False)
85+
86+
7787
class FakeOsModule:
7888
"""Uses FakeFilesystem to provide a fake os module replacement.
7989
@@ -88,7 +98,18 @@ class FakeOsModule:
8898
my_os_module = fake_os.FakeOsModule(filesystem)
8999
"""
90100

91-
use_original = False
101+
@property
102+
def use_original(self) -> bool:
103+
"""Whether faked operations should dispatch to the real `os` module.
104+
105+
Stored in thread-local state so that `use_original_os()` in one thread
106+
does not affect faked operations running in another thread.
107+
"""
108+
return _use_original()
109+
110+
@use_original.setter
111+
def use_original(self, value: bool) -> None:
112+
_thread_state.use_original = value
92113

93114
@staticmethod
94115
def dir() -> list[str]:
@@ -1468,7 +1489,7 @@ def handle_original_call(f: Callable) -> Callable:
14681489

14691490
@functools.wraps(f)
14701491
def wrapped(*args, **kwargs):
1471-
should_use_original = FakeOsModule.use_original
1492+
should_use_original = _use_original()
14721493

14731494
if not should_use_original and args:
14741495
self = args[0]
@@ -1500,10 +1521,16 @@ def wrapped(*args, **kwargs):
15001521
@contextmanager
15011522
def use_original_os():
15021523
"""Temporarily use original os functions instead of faked ones.
1503-
Used to ensure that skipped modules do not use faked calls.
1524+
1525+
Used to ensure that skipped modules do not use faked calls. The flag
1526+
is stored in thread-local state so that concurrent calls from
1527+
multiple threads do not interfere with each other; the previous
1528+
thread-local value is restored on exit so nested uses work
1529+
correctly.
15041530
"""
1531+
prev = getattr(_thread_state, "use_original", False)
15051532
try:
1506-
FakeOsModule.use_original = True
1533+
_thread_state.use_original = True
15071534
yield
15081535
finally:
1509-
FakeOsModule.use_original = False
1536+
_thread_state.use_original = prev

pyfakefs/tests/fake_filesystem_unittest_test.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
Test the :py:class`pyfakefs.fake_filesystem_unittest.TestCase` base class.
1919
"""
2020

21+
import asyncio
2122
import glob
2223
import importlib.util
2324
import io
@@ -28,6 +29,7 @@
2829
import shutil
2930
import sys
3031
import tempfile
32+
import threading
3133
import unittest
3234
import warnings
3335
from contextlib import redirect_stdout
@@ -45,6 +47,7 @@
4547
patchfs,
4648
PatchMode,
4749
)
50+
from pyfakefs.fake_os import use_original_os
4851
from pyfakefs.helpers import IS_PYPY
4952
from pyfakefs.tests.fixtures import module_with_attributes
5053

@@ -1065,5 +1068,88 @@ def test_fake_relative_import(self):
10651068
assert module.number == 42
10661069

10671070

1071+
class UseOriginalThreadSafetyTest(TestCase):
1072+
"""Regression test: the ``use_original`` flag on :class:`FakeOsModule`
1073+
used to be a process-global class attribute flipped by the
1074+
:func:`use_original_os` context manager. When one thread entered the
1075+
context manager while another thread was in the middle of a faked
1076+
filesystem operation, the faked call could observe ``use_original=True``
1077+
from the first thread and dispatch to the real ``os`` module, failing
1078+
against a path that only exists in the fake filesystem.
1079+
1080+
The flag is now stored in :class:`threading.local` so that concurrent
1081+
``use_original_os()`` calls do not leak state across threads.
1082+
"""
1083+
1084+
def test_use_original_os_is_thread_local(self):
1085+
"""Concurrent ``use_original_os()`` in a sibling thread must not
1086+
cause faked filesystem operations in a worker thread to dispatch
1087+
to the real OS.
1088+
1089+
This mirrors the behaviour of pyfakefs-internal call sites such as
1090+
``linecache`` during traceback formatting, which enter
1091+
``use_original_os()`` on arbitrary threads while user code runs
1092+
faked filesystem calls via ``asyncio.to_thread``.
1093+
"""
1094+
stop_flag = threading.Event()
1095+
1096+
def hammer_use_original():
1097+
"""Enter and exit ``use_original_os()`` in a tight loop to
1098+
race against the worker threads below."""
1099+
while not stop_flag.is_set():
1100+
with use_original_os():
1101+
pass
1102+
1103+
def _write(p):
1104+
# Each worker mkdirs and writes inside its own pre-isolated
1105+
# subtree so that no two workers mutate the same fake-directory
1106+
# dict.
1107+
p.parent.mkdir(parents=True, exist_ok=True)
1108+
with p.open("w") as cf:
1109+
cf.write("x")
1110+
1111+
async def _dispatch(p):
1112+
await asyncio.to_thread(_write, p)
1113+
1114+
async def many(root, n):
1115+
await asyncio.gather(
1116+
*[
1117+
_dispatch(pathlib.Path(f"{root}/w{i}/a/b/c/file"))
1118+
for i in range(n)
1119+
]
1120+
)
1121+
1122+
with Patcher() as p:
1123+
# Start the hammer threads inside the Patcher context so they do
1124+
# not contend with Patcher's cold module-walk during init.
1125+
hammers = [
1126+
threading.Thread(target=hammer_use_original, daemon=True)
1127+
for _ in range(4)
1128+
]
1129+
for h in hammers:
1130+
h.start()
1131+
try:
1132+
# Run several rounds so the cumulative failure probability
1133+
# approaches 1; each round uses a distinct root so any
1134+
# real-OS fallback reliably fails against a non-existent
1135+
# directory.
1136+
for round_idx in range(3):
1137+
root = f"/fake-env-{round_idx}"
1138+
# Pre-create each worker's top-level w{i} directory so the
1139+
# workers only mutate dicts inside their own subtree.
1140+
for i in range(64):
1141+
p.fs.create_dir(f"{root}/w{i}")
1142+
asyncio.run(many(root, 64))
1143+
for i in range(64):
1144+
self.assertTrue(
1145+
p.fs.exists(f"{root}/w{i}/a/b/c/file"),
1146+
f"expected fake file {root}/w{i}/a/b/c/file to exist",
1147+
)
1148+
finally:
1149+
stop_flag.set()
1150+
for h in hammers:
1151+
h.join(timeout=1)
1152+
1153+
10681154
if __name__ == "__main__":
10691155
unittest.main()

0 commit comments

Comments
 (0)