44This test suite validates context propagation behavior across various concurrency patterns.
55
66TEST ISOLATION STRATEGY:
7- - Tests use pytest-forked to run each test in an isolated process
8- - This ensures setup_threads() patches don't leak between tests
7+ - Tests that call setup_threads() run in isolated subprocesses (via subprocess.run)
8+ - This ensures setup_threads() monkey- patches don't leak between tests
99- Use unpatched(scenario) for xfail tests (documents context loss)
1010- Use patched(scenario) for tests that prove setup_threads() fixes it
1111
@@ -16,12 +16,12 @@ def _threadpool_scenario(test_logger, with_memory_logger):
1616 test_threadpool_loses_context = unpatched(_threadpool_scenario)
1717 test_threadpool_with_patch = patched(_threadpool_scenario)
1818
19- Run with: pytest --forked src/braintrust/test_context.py
19+ Run with: pytest src/braintrust/test_context.py
2020"""
2121
2222import asyncio
2323import concurrent .futures
24- import functools
24+ import subprocess
2525import sys
2626import threading
2727from typing import AsyncGenerator , Callable , Generator , TypeVar
@@ -30,44 +30,91 @@ def _threadpool_scenario(test_logger, with_memory_logger):
3030import pytest
3131from braintrust import current_span , start_span
3232from braintrust .test_helpers import init_test_logger , with_memory_logger # noqa: F401
33- from braintrust .wrappers .threads import setup_threads
3433
3534
3635F = TypeVar ("F" , bound = Callable )
3736
37+ # ---------------------------------------------------------------------------
38+ # Subprocess isolation helpers
39+ # ---------------------------------------------------------------------------
40+ # Tests that call setup_threads() must run in isolated subprocesses so that
41+ # the monkey-patches applied to threading.Thread / ThreadPoolExecutor do not
42+ # leak between tests. Each isolated test spawns a fresh ``python -c``
43+ # subprocess.
44+ # ---------------------------------------------------------------------------
45+
46+ # BRAINTRUST_DISABLE_ATEXIT_FLUSH is set because setup_threads() patches
47+ # ThreadPoolExecutor.submit globally. Without this flag the background
48+ # logger's atexit handler tries to flush via the patched executor during
49+ # Python shutdown, which crashes the subprocess (SIGABRT / 0xC0000409).
50+ _SCENARIO_TEMPLATE = """\
51+ import os, inspect, asyncio
52+ os.environ["BRAINTRUST_APP_URL"] = "https://www.braintrust.dev"
53+ os.environ["BRAINTRUST_DISABLE_ATEXIT_FLUSH"] = "true"
54+ os.environ.setdefault("OPENAI_API_KEY", "sk-test-dummy-api-key-for-vcr-tests")
55+ os.environ.setdefault("ANTHROPIC_API_KEY", "sk-ant-test-dummy-api-key-for-vcr-tests")
56+ os.environ.setdefault("MISTRAL_API_KEY", "mistral-test-dummy-api-key-for-vcr-tests")
57+ os.environ.setdefault("GOOGLE_API_KEY", os.environ.get("GEMINI_API_KEY", "your_google_api_key_here"))
58+ from braintrust import logger as _logger
59+ from braintrust.test_helpers import init_test_logger
60+ from braintrust.test_context import {fn_name} as _fn
61+ _logger._state.reset_parent_state()
62+ with _logger._internal_with_memory_background_logger() as _bgl:
63+ _tl = init_test_logger("test-context-project")
64+ if {instrument}:
65+ from braintrust.wrappers.threads import setup_threads
66+ setup_threads()
67+ if inspect.iscoroutinefunction(_fn):
68+ asyncio.run(_fn(_tl, _bgl))
69+ else:
70+ _fn(_tl, _bgl)
71+ _logger._state.reset_parent_state()
72+ """
73+
74+
75+ def _run_in_subprocess (code : str , label : str , timeout : int = 60 ) -> None :
76+ """Execute *code* in a fresh ``python -c`` subprocess and assert it exits cleanly."""
77+ try :
78+ result = subprocess .run (
79+ [sys .executable , "-c" , code ],
80+ capture_output = True ,
81+ text = True ,
82+ timeout = timeout ,
83+ )
84+ except subprocess .TimeoutExpired as exc :
85+ raise AssertionError (f"Isolated test { label } timed out after { timeout } s" ) from exc
86+ if result .returncode != 0 :
87+ raise AssertionError (
88+ f"Isolated test { label } failed (exit code { result .returncode } ):\n { result .stderr } \n { result .stdout } "
89+ )
90+
3891
3992def isolate (instrument : bool ) -> Callable [[F ], F ]:
4093 """
4194 Decorator for isolated context propagation tests.
4295
43- - Always runs in forked process (pytest-forked)
44- - If instrument=True: calls setup_threads() before test
96+ Runs each test in a separate subprocess for full process isolation,
97+ ensuring setup_threads() patches don't leak between tests.
98+
99+ - If instrument=True: calls setup_threads() before the test
45100 - If instrument=False: marks test as xfail (context loss expected)
46101 """
47102
48103 def decorator (fn : F ) -> F :
49- if asyncio .iscoroutinefunction (fn ):
50-
51- @functools .wraps (fn )
52- async def async_wrapper (* args , ** kwargs ):
53- if instrument :
54- setup_threads ()
55- return await fn (* args , ** kwargs )
104+ fn_name = fn .__name__
56105
57- wrapped = pytest .mark .forked (async_wrapper )
58- else :
106+ def isolated_test ():
107+ code = _SCENARIO_TEMPLATE .format (fn_name = fn_name , instrument = instrument )
108+ _run_in_subprocess (code , fn_name )
59109
60- @functools .wraps (fn )
61- def wrapper (* args , ** kwargs ):
62- if instrument :
63- setup_threads ()
64- return fn (* args , ** kwargs )
65-
66- wrapped = pytest .mark .forked (wrapper )
110+ isolated_test .__name__ = fn_name
111+ isolated_test .__qualname__ = fn_name
112+ isolated_test .__doc__ = fn .__doc__
67113
68114 if not instrument :
69- wrapped = pytest .mark .xfail (reason = "context lost without patch" )(wrapped )
70- return wrapped # type: ignore
115+ isolated_test = pytest .mark .xfail (reason = "context lost without patch" )(isolated_test )
116+
117+ return isolated_test # type: ignore
71118
72119 return decorator
73120
@@ -1266,21 +1313,39 @@ def failing_function():
12661313# ============================================================================
12671314
12681315
1269- @pytest .mark .forked
1270- def test_setup_threads_returns_true ():
1271- """setup_threads() returns True on success."""
1316+ def _setup_threads_returns_true_check ():
1317+ """Subprocess helper: verify setup_threads() returns True."""
1318+ from braintrust .wrappers .threads import setup_threads
1319+
12721320 result = setup_threads ()
12731321 assert result is True
12741322
12751323
1276- @pytest .mark .forked
1277- def test_setup_threads_idempotent ():
1278- """Calling setup_threads() multiple times is safe."""
1324+ def _setup_threads_idempotent_check ():
1325+ """Subprocess helper: verify setup_threads() is idempotent."""
1326+ from braintrust .wrappers .threads import setup_threads
1327+
12791328 result1 = setup_threads ()
12801329 result2 = setup_threads ()
12811330 assert result1 is True
12821331 assert result2 is True
12831332
12841333
1334+ def test_setup_threads_returns_true ():
1335+ """setup_threads() returns True on success."""
1336+ _run_in_subprocess (
1337+ "from braintrust.test_context import _setup_threads_returns_true_check; _setup_threads_returns_true_check()" ,
1338+ "test_setup_threads_returns_true" ,
1339+ )
1340+
1341+
1342+ def test_setup_threads_idempotent ():
1343+ """Calling setup_threads() multiple times is safe."""
1344+ _run_in_subprocess (
1345+ "from braintrust.test_context import _setup_threads_idempotent_check; _setup_threads_idempotent_check()" ,
1346+ "test_setup_threads_idempotent" ,
1347+ )
1348+
1349+
12851350if __name__ == "__main__" :
12861351 pytest .main ([__file__ , "-v" , "-s" ])
0 commit comments