diff --git a/.riot/requirements/1127dcb.txt b/.riot/requirements/17bc84a.txt similarity index 57% rename from .riot/requirements/1127dcb.txt rename to .riot/requirements/17bc84a.txt index 81163aa9f71..138609f6f5d 100644 --- a/.riot/requirements/1127dcb.txt +++ b/.riot/requirements/17bc84a.txt @@ -1,9 +1,4 @@ -# -# This file is autogenerated by pip-compile with Python 3.13 -# by the following command: -# -# pip-compile --allow-unsafe --no-annotate .riot/requirements/1127dcb.in -# +asgiref==3.12.1 attrs==26.1.0 coverage[toml]==7.13.5 hypothesis==6.45.0 diff --git a/.riot/requirements/18da66a.txt b/.riot/requirements/1bab40e.txt similarity index 62% rename from .riot/requirements/18da66a.txt rename to .riot/requirements/1bab40e.txt index 0ba5d1c4f4d..bde50691d1e 100644 --- a/.riot/requirements/18da66a.txt +++ b/.riot/requirements/1bab40e.txt @@ -1,9 +1,4 @@ -# -# This file is autogenerated by pip-compile with Python 3.10 -# by the following command: -# -# pip-compile --allow-unsafe --no-annotate .riot/requirements/18da66a.in -# +asgiref==3.12.1 attrs==25.3.0 coverage[toml]==7.8.2 exceptiongroup==1.3.0 diff --git a/.riot/requirements/175a6ba.txt b/.riot/requirements/1c13793.txt similarity index 59% rename from .riot/requirements/175a6ba.txt rename to .riot/requirements/1c13793.txt index 100ecdb0d4a..2730ba7ee9f 100644 --- a/.riot/requirements/175a6ba.txt +++ b/.riot/requirements/1c13793.txt @@ -1,9 +1,4 @@ -# -# This file is autogenerated by pip-compile with Python 3.9 -# by the following command: -# -# pip-compile --allow-unsafe --no-annotate --resolver=backtracking .riot/requirements/175a6ba.in -# +asgiref==3.11.1 attrs==25.3.0 coverage[toml]==7.8.2 exceptiongroup==1.3.0 diff --git a/.riot/requirements/6dcdfb3.txt b/.riot/requirements/4ef3ac0.txt similarity index 56% rename from .riot/requirements/6dcdfb3.txt rename to .riot/requirements/4ef3ac0.txt index 75d1884f637..2076c94bb53 100644 --- a/.riot/requirements/6dcdfb3.txt +++ b/.riot/requirements/4ef3ac0.txt @@ -1,9 +1,4 @@ -# -# This file is autogenerated by pip-compile with Python 3.12 -# by the following command: -# -# pip-compile --allow-unsafe --no-annotate .riot/requirements/6dcdfb3.in -# +asgiref==3.12.1 attrs==25.3.0 coverage[toml]==7.8.2 hypothesis==6.45.0 @@ -17,3 +12,4 @@ pytest==8.4.0 pytest-cov==6.1.1 pytest-mock==3.14.1 sortedcontainers==2.4.0 +tomli==2.4.1 diff --git a/.riot/requirements/ae7e800.txt b/.riot/requirements/78c2bdf.txt similarity index 56% rename from .riot/requirements/ae7e800.txt rename to .riot/requirements/78c2bdf.txt index 03903bc8f3d..9d2ebc923f3 100644 --- a/.riot/requirements/ae7e800.txt +++ b/.riot/requirements/78c2bdf.txt @@ -1,9 +1,4 @@ -# -# This file is autogenerated by pip-compile with Python 3.11 -# by the following command: -# -# pip-compile --allow-unsafe --no-annotate .riot/requirements/ae7e800.in -# +asgiref==3.12.1 attrs==25.3.0 coverage[toml]==7.8.2 hypothesis==6.45.0 diff --git a/.riot/requirements/1edb5f0.txt b/.riot/requirements/ce0db6f.txt similarity index 57% rename from .riot/requirements/1edb5f0.txt rename to .riot/requirements/ce0db6f.txt index 9034f604daa..138609f6f5d 100644 --- a/.riot/requirements/1edb5f0.txt +++ b/.riot/requirements/ce0db6f.txt @@ -1,9 +1,4 @@ -# -# This file is autogenerated by pip-compile with Python 3.14 -# by the following command: -# -# pip-compile --allow-unsafe --no-annotate .riot/requirements/1edb5f0.in -# +asgiref==3.12.1 attrs==26.1.0 coverage[toml]==7.13.5 hypothesis==6.45.0 diff --git a/ddtrace/internal/README.md b/ddtrace/internal/README.md index 3de3b928640..9cb1ac73dbc 100644 --- a/ddtrace/internal/README.md +++ b/ddtrace/internal/README.md @@ -6,6 +6,20 @@ These modules are not intended to be used outside of `ddtrace`. The APIs found within `ddtrace.internal` are subject to breaking changes at any time and do not follow the semver versioning scheme of the `ddtrace` package. +## Coverage collection contexts + +`ModuleCodeCollector.CollectInContext` owns the line and file coverage for one +collection scope, such as a test or an import. A `ContextVar` holds an immutable +tuple of these collectors. Copying an execution context shares the collectors +and their data, while entering or exiting a scope replaces only that context's +stack. Collectors compare by identity so context restoration can distinguish +scopes even when both have empty coverage. Exiting a collector marks it closed +in all inherited stacks; writes resolve to the nearest collector still active. + +On Python 3.14+, monitoring callbacks can observe a snapshot that does not see +current `ContextVar` values. Thread-local storage (TLS) provides a fallback stack +for these callbacks, using the same rules to skip completed collectors. + ## The Product Protocol diff --git a/ddtrace/internal/coverage/code.py b/ddtrace/internal/coverage/code.py index 496cb5dd5c5..c9eba2bdeb4 100644 --- a/ddtrace/internal/coverage/code.py +++ b/ddtrace/internal/coverage/code.py @@ -49,9 +49,10 @@ def _is_site_packages_path(path: Path) -> bool: # NOTE: A mutable ContextVar default would be shared across threads until set() is called. -# Keep None so CollectInContext initializes a separate coverage stack in each context. -ctx_covered: ContextVar[t.Optional[list[defaultdict[str, CoverageLines]]]] = ContextVar("ctx_covered", default=None) -ctx_covered_files: ContextVar[t.Optional[list[set[str]]]] = ContextVar("ctx_covered_files", default=None) +# Use an immutable tuple so contexts can share collectors without sharing stack mutations. +ctx_collectors: ContextVar[tuple["ModuleCodeCollector.CollectInContext", ...]] = ContextVar( + "ctx_collectors", default=() +) ctx_is_import_coverage = ContextVar("ctx_is_import_coverage", default=False) ctx_coverage_enabled = ContextVar("ctx_coverage_enabled", default=False) @@ -60,32 +61,36 @@ def _is_site_packages_path(path: Path) -> bool: _tls_coverage = _threading.local() -def _get_ctx_covered_lines() -> defaultdict[str, CoverageLines]: +def _get_active_collector( + stack: tuple["ModuleCodeCollector.CollectInContext", ...], +) -> t.Optional["ModuleCodeCollector.CollectInContext"]: + # Contexts copied during an import may still contain its completed collector. + for collector in reversed(stack): + if not collector.closed: + return collector + return None + + +def _get_ctx_collector() -> t.Optional["ModuleCodeCollector.CollectInContext"]: if ctx_coverage_enabled.get(): - if context_stack := ctx_covered.get(): - return context_stack[-1] - log.debug("_get_ctx_covered_lines() called but ctx_covered stack is empty") + if collector := _get_active_collector(ctx_collectors.get()): + return collector - # Fallback for Python 3.14+ where sys.monitoring callbacks can't see ContextVars + # The same lifetime rules apply when monitoring callbacks need the TLS fallback. if _PY_GE_314: - tls_covered = getattr(_tls_coverage, "covered", None) - if tls_covered is not None: - return tls_covered + return _get_active_collector(getattr(_tls_coverage, "stack", ())) + return None + +def _get_ctx_covered_lines() -> defaultdict[str, CoverageLines]: + if collector := _get_ctx_collector(): + return collector._covered_lines return defaultdict(CoverageLines) def _get_ctx_covered_files() -> set[str]: - if ctx_coverage_enabled.get(): - if context_stack := ctx_covered_files.get(): - return context_stack[-1] - log.debug("_get_ctx_covered_files() called but ctx_covered_files stack is empty") - - if _PY_GE_314: - tls_covered_files = getattr(_tls_coverage, "covered_files", None) - if tls_covered_files is not None: - return tls_covered_files - + if collector := _get_ctx_collector(): + return collector._covered_files return set() @@ -174,7 +179,7 @@ def hook_file(self, path: str) -> None: self._covered_files.add(path) self.covered[path].add(0) - if ctx_coverage_enabled.get() or (_PY_GE_314 and getattr(_tls_coverage, "covered", None) is not None): + if ctx_coverage_enabled.get() or (_PY_GE_314 and bool(getattr(_tls_coverage, "stack", ()))): ctx_covered_file_paths = _get_ctx_covered_files() if path not in ctx_covered_file_paths: ctx_covered_file_paths.add(path) @@ -185,7 +190,7 @@ def hook_line(self, path: str, line: int) -> None: lines = self.covered[path] lines.add(line) - if ctx_coverage_enabled.get() or (_PY_GE_314 and getattr(_tls_coverage, "covered", None) is not None): + if ctx_coverage_enabled.get() or (_PY_GE_314 and bool(getattr(_tls_coverage, "stack", ()))): # Import-time contexts store their lines in a non-context variable to be aggregated on request when # reporting coverage ctx_lines = _get_ctx_covered_lines()[path] @@ -371,16 +376,23 @@ def _get_covered_file_paths_with_imports(self, covered_file_paths: set[str]) -> return paths class CollectInContext: + """Own coverage data for one collection scope. + + Context copies share collector objects but have independent immutable stacks. + Object identity distinguishes scopes during value-based context restoration; + the shared closed flag prevents inherited contexts from writing to finished scopes. + """ + def __init__(self, is_import_coverage: bool = False): self.is_import_coverage = is_import_coverage - if ctx_covered.get() is None: - ctx_covered.set([]) - if ctx_covered_files.get() is None: - ctx_covered_files.set([]) def __enter__(self): - ctx_covered.get().append(defaultdict(CoverageLines)) - ctx_covered_files.get().append(set()) + # Collector objects compare by identity, so value-based context restores + # distinguish different collectors even when their coverage data is empty. + self._covered_lines: defaultdict[str, CoverageLines] = defaultdict(CoverageLines) + self._covered_files: set[str] = set() + self.closed = False + ctx_collectors.set(ctx_collectors.get() + (self,)) ctx_coverage_enabled.set(True) if self.is_import_coverage: @@ -389,8 +401,7 @@ def __enter__(self): # Python 3.14+ sys.monitoring callbacks can't see ContextVar changes, # so also store in thread-local as a fallback for the hook. if _PY_GE_314: - _tls_coverage.covered = ctx_covered.get()[-1] - _tls_coverage.covered_files = ctx_covered_files.get()[-1] + _tls_coverage.stack = ctx_collectors.get() # For Python 3.12+, dynamically detect whether other sys.monitoring tools are # active and update the DISABLE optimisation flag accordingly. Then re-enable @@ -412,23 +423,19 @@ def __enter__(self): return self def __exit__(self, *args, **kwargs): - covered_lines_stack = ctx_covered.get() - covered_files_stack = ctx_covered_files.get() - covered_lines_stack.pop() - covered_files_stack.pop() - - # Stop coverage if we're exiting the last context - if len(covered_lines_stack) == 0: - ctx_coverage_enabled.set(False) - if _PY_GE_314: - _tls_coverage.covered = None - _tls_coverage.covered_files = None - elif _PY_GE_314: - _tls_coverage.covered = covered_lines_stack[-1] - _tls_coverage.covered_files = covered_files_stack[-1] + # Closing the shared collector expires it in every inherited stack. + self.closed = True + stack = ctx_collectors.get() + if stack and stack[-1] is self: + stack = stack[:-1] + ctx_collectors.set(stack) + # An exit in a different context must preserve that context's collectors. + ctx_coverage_enabled.set(_get_active_collector(stack) is not None) + if _PY_GE_314: + _tls_coverage.stack = stack def get_covered_lines(self) -> dict[str, CoverageLines]: - covered_lines = _get_ctx_covered_lines() + covered_lines = self._covered_lines if global_instance := ModuleCodeCollector._instance: global_instance._add_import_time_lines(covered_lines) return covered_lines @@ -437,8 +444,8 @@ def get_covered_file_paths(self) -> t.AbstractSet[str]: # Python < 3.12 and injected child-process coverage may only update the line-oriented # context data. Merge those keys into the file set so file-level uploads still include # every file that would have been emitted by get_covered_lines(). - covered_file_paths = set(_get_ctx_covered_files()) - covered_file_paths.update(_get_ctx_covered_lines()) + covered_file_paths = set(self._covered_files) + covered_file_paths.update(self._covered_lines) if global_instance := ModuleCodeCollector._instance: return global_instance._get_covered_file_paths_with_imports(covered_file_paths) return covered_file_paths diff --git a/releasenotes/notes/fix-ci-visibility-copied-context-coverage-f8c173bd.yaml b/releasenotes/notes/fix-ci-visibility-copied-context-coverage-f8c173bd.yaml new file mode 100644 index 00000000000..74503a63a0d --- /dev/null +++ b/releasenotes/notes/fix-ci-visibility-copied-context-coverage-f8c173bd.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + CI Visibility: Fixes an issue where pytest runs using copied or restored execution + contexts (e.g. Django async tests via asgiref's ``async_to_sync``) could end with an + internal coverage error, or silently lose the coverage data of the tests that use + them. diff --git a/tests/ci_visibility/suitespec.yml b/tests/ci_visibility/suitespec.yml index 354ee94504d..fcec1dad070 100644 --- a/tests/ci_visibility/suitespec.yml +++ b/tests/ci_visibility/suitespec.yml @@ -74,7 +74,7 @@ suites: snapshot: true matrix: command: pytest --no-cov {cmdargs} tests/coverage -s - variants: [{name: dd_coverage}] + variants: [{name: dd_coverage, dependencies: [asgiref]}] pytest: venvs_per_job: 9 paths: diff --git a/tests/coverage/test_coverage.py b/tests/coverage/test_coverage.py index 638980d1bac..c29a4a643eb 100644 --- a/tests/coverage/test_coverage.py +++ b/tests/coverage/test_coverage.py @@ -12,6 +12,151 @@ import pytest +@pytest.mark.parametrize("mismatched_exit", [False, True]) +def test_tls_fallback_skips_completed_inherited_collectors(monkeypatch, mismatched_exit): + from contextvars import Context + from contextvars import copy_context + + import ddtrace.internal.coverage.code as coverage_code + + monkeypatch.setattr(coverage_code, "_PY_GE_314", True) + snapshot = Context() + collector = object.__new__(coverage_code.ModuleCodeCollector) + collector._coverage_enabled = False + with coverage_code.ModuleCodeCollector.CollectInContext() as test_collector: + with coverage_code.ModuleCodeCollector.CollectInContext() as import_collector: + task_context = copy_context() + + nested = coverage_code.ModuleCodeCollector.CollectInContext() + if mismatched_exit: + child_context = task_context.copy() + child_context.run(nested.__enter__) + # This inherited stack does not contain the nested collector and + # its top collector has already completed. + task_context.run(nested.__exit__) + else: + task_context.run(nested.__enter__) + task_context.run(nested.__exit__) + + # Monitoring callbacks see a snapshot without the task's ContextVars. + snapshot.run(collector.hook_line, "/repo/active.py", 42) + snapshot.run(collector.hook_file, "/repo/file.py") + assert 42 in test_collector.get_covered_lines()["/repo/active.py"].to_sorted_list() + assert "/repo/file.py" in test_collector._covered_files + assert "/repo/active.py" not in import_collector.get_covered_lines() + assert "/repo/active.py" not in nested.get_covered_lines() + assert "/repo/file.py" not in import_collector.get_covered_file_paths() + assert "/repo/file.py" not in nested.get_covered_file_paths() + + snapshot.run(collector.hook_line, "/repo/late.py", 7) + assert "/repo/late.py" not in test_collector.get_covered_lines() + + +def test_coverage_stacks_are_isolated_across_copied_contexts(): + from contextvars import copy_context + + from ddtrace.internal.coverage.code import ModuleCodeCollector + from ddtrace.internal.coverage.code import ctx_collectors + + with ModuleCodeCollector.CollectInContext(): + parent_stack = ctx_collectors.get() + parent_depth = len(parent_stack) + child_context = copy_context() + + def collect_in_child_context(): + with ModuleCodeCollector.CollectInContext() as child: + assert len(ctx_collectors.get()) == parent_depth + 1 + assert ctx_collectors.get()[-1] is child + assert ctx_collectors.get()[-2] is parent_stack[-1] + + child_context.run(collect_in_child_context) + assert ctx_collectors.get() is parent_stack + assert len(parent_stack) == parent_depth + + +def test_exiting_collector_in_another_context_preserves_active_coverage(): + from contextvars import copy_context + + from ddtrace.internal.coverage.code import ModuleCodeCollector + from ddtrace.internal.coverage.code import ctx_collectors + + with ModuleCodeCollector.CollectInContext(): + parent_stack = ctx_collectors.get() + child_context = copy_context() + child = ModuleCodeCollector.CollectInContext() + child_context.run(child.__enter__) + + child.__exit__() + assert ctx_collectors.get() is parent_stack + + child_context.run(child.__exit__) + assert ctx_collectors.get() is parent_stack + + +def test_completed_collector_entries_do_not_capture_inherited_context_coverage(): + """Contexts that inherit a stack still holding a completed entry must not write to it. + + A module imported inside a test may create an asyncio task before finishing its + import collector. The task inherits the test's context, whose stack still + references the (now completed) import entry. New coverage in the task must be + attributed to the enclosing live collector instead of the orphaned entry. + """ + from contextvars import copy_context + + import ddtrace.internal.coverage.code as coverage_code + from ddtrace.internal.coverage.code import ModuleCodeCollector + + with ModuleCodeCollector.CollectInContext() as test_collector: + with ModuleCodeCollector.CollectInContext() as import_collector: + task_context = copy_context() + + assert import_collector.closed + + # The task context still sees the completed import entry atop its stack. + task_stack = task_context.run(coverage_code.ctx_collectors.get) + assert task_stack[-1] is import_collector + + # Resolution inside the task context must skip the completed entry and + # attribute coverage to the still-active test collector. + assert task_context.run(coverage_code._get_ctx_covered_lines) is test_collector._covered_lines + assert task_context.run(coverage_code._get_ctx_covered_files) is test_collector._covered_files + + # A live collector entered in the task context takes precedence even though + # the completed import entry remains buried beneath it on the stack. + nested = ModuleCodeCollector.CollectInContext() + task_context.run(nested.__enter__) + assert task_context.run(coverage_code._get_ctx_covered_lines) is nested._covered_lines + task_context.run(nested.__exit__) + assert task_context.run(coverage_code._get_ctx_covered_lines) is test_collector._covered_lines + + # Once every collector the task inherited has completed, new coverage lands in + # a fresh container rather than in any of the completed entries. + stale = task_context.run(coverage_code._get_ctx_covered_lines) + assert stale is not import_collector._covered_lines + assert stale is not test_collector._covered_lines + + +def test_mismatched_exit_resyncs_tls_fallback(monkeypatch): + """A mismatched exit must leave snapshot callbacks recording in the active collector.""" + from contextvars import Context + from contextvars import copy_context + + import ddtrace.internal.coverage.code as coverage_code + from ddtrace.internal.coverage.code import ModuleCodeCollector + + monkeypatch.setattr(coverage_code, "_PY_GE_314", True) + snapshot = Context() + with ModuleCodeCollector.CollectInContext() as parent: + child_context = copy_context() + child = ModuleCodeCollector.CollectInContext() + child_context.run(child.__enter__) + assert snapshot.run(coverage_code._get_ctx_covered_lines) is child._covered_lines + + child.__exit__() + assert snapshot.run(coverage_code._get_ctx_covered_lines) is parent._covered_lines + assert snapshot.run(coverage_code._get_ctx_covered_files) is parent._covered_files + + @pytest.mark.skipif(sys.version_info < (3, 12), reason="Test specific to Python 3.12+ monitoring API") @pytest.mark.subprocess() def test_coverage_defaults_to_file_level_when_env_unset(): diff --git a/tests/coverage/test_coverage_asgiref_context_propagation.py b/tests/coverage/test_coverage_asgiref_context_propagation.py new file mode 100644 index 00000000000..6990c867ce9 --- /dev/null +++ b/tests/coverage/test_coverage_asgiref_context_propagation.py @@ -0,0 +1,152 @@ +"""Regression tests for per-context coverage stack corruption caused by +value-based context restoration. + +Django's async test support (asgiref async_to_sync/sync_to_async) +runs a new event loop in a worker thread, which ddtrace's threading +integration wraps in its own coverage context. The framework then restores +context variable values between the caller, the worker thread and the task +using value-based comparisons (cvar.get() != cvalue in asgiref's +_restore_context). + +Because the per-context coverage stacks used to be plain lists (compared by +value), such restores could replace one context's stack with another context's +stack object. That corrupted the push/pop pairing of CollectInContext, crashing +the caller with IndexError: pop from empty list and mis-attributing coverage +data between contexts. +""" + +import pytest + + +@pytest.mark.subprocess(parametrize={"_DD_COVERAGE_FILE_LEVEL": ["true", "false"]}) +def test_coverage_context_thread_value_based_context_restore(): + import contextvars + import os + from pathlib import Path + import sys + import threading + + from ddtrace.internal.coverage.code import ModuleCodeCollector + from ddtrace.internal.coverage.installer import install + from tests.coverage.utils import _get_relpath_dict + + cwd = os.getcwd() + + include_paths = [Path(cwd) / "tests/coverage/included_path/"] + install(include_paths=include_paths) + + # Import before entering the context so module-level lines are not included + from tests.coverage.included_path.callee import called_in_context_main + + def restore_context_values(context): + # Mirrors asgiref.sync._restore_context (and similar value-based + # ContextVar propagation helpers): a restore is skipped whenever the + # current and incoming values compare equal, and applied otherwise. + for cvar in context: + cvalue = context.get(cvar) + try: + if cvar.get() != cvalue: + cvar.set(cvalue) + except LookupError: + cvar.set(cvalue) + + context_collector = ModuleCodeCollector.CollectInContext() + context_collector.__enter__() + try: + # The caller's context, captured while the per-test coverage context + # is active (fresh empty coverage entries, as at the start of a test). + caller_context = contextvars.copy_context() + + task_context_holder = {} + + def thread_body(): + # ddtrace's threading integration wraps this thread in its own + # coverage context, entered in the thread's fresh base context. + # Simulate the framework task inheriting the thread's context and + # the caller's context values being restored into it. + task_context = contextvars.copy_context() + + def task_body(): + restore_context_values(caller_context) + called_in_context_main(1, 2) + + task_context.run(task_body) + task_context_holder["task"] = task_context + + thread = threading.Thread(target=thread_body) + thread.start() + thread.join() + + # Simulate the framework restoring the task context back into the + # caller (as asgiref's AsyncToSync does at the end of an async call). + restore_context_values(task_context_holder["task"]) + + context_covered = _get_relpath_dict(cwd, context_collector.get_covered_lines()) + finally: + # Regression: this used to raise IndexError: pop from empty list + # (or list index out of range) after the value-based restore + # replaced this context's stack with the worker thread's (already + # popped) stack. + context_collector.__exit__() + + expected_lines = { + "tests/coverage/included_path/callee.py": {10, 11, 13, 14}, + "tests/coverage/included_path/in_context_lib.py": {1, 2, 5}, + } + + if ModuleCodeCollector.file_level_coverage_enabled() and sys.version_info >= (3, 12): + expected_lines = {path: {0} for path in expected_lines} + + assert expected_lines == context_covered, f"Mismatched lines: {expected_lines} vs {context_covered}" + + +@pytest.mark.subprocess(parametrize={"_DD_COVERAGE_FILE_LEVEL": ["true", "false"]}) +def test_coverage_context_thread_async_to_sync(): + import os + from pathlib import Path + import sys + + from asgiref.sync import async_to_sync + from asgiref.sync import sync_to_async + + from ddtrace.internal.coverage.code import ModuleCodeCollector + from ddtrace.internal.coverage.installer import install + from tests.coverage.utils import _get_relpath_dict + + cwd = os.getcwd() + + include_paths = [Path(cwd) / "tests/coverage/included_path/"] + install(include_paths=include_paths) + + # Import before entering the context so module-level lines are not included + from tests.coverage.included_path.callee import called_in_context_main + + def sync_fn(a, b): + called_in_context_main(a, b) + + async def async_fn(a, b): + # Simulate a Django async test body performing sync work via + # sync_to_async (thread-sensitive mode uses the CurrentThreadExecutor + # in the calling thread). + await sync_to_async(sync_fn)(a, b) + + context_collector = ModuleCodeCollector.CollectInContext() + context_collector.__enter__() + try: + async_to_sync(async_fn)(1, 2) + context_covered = _get_relpath_dict(cwd, context_collector.get_covered_lines()) + finally: + # Regression: this used to raise IndexError: pop from empty list + # after asgiref restored a value-equal (but different) coverage stack + # into this context. + context_collector.__exit__() + + expected_lines = { + "tests/coverage/included_path/callee.py": {10, 11, 13, 14}, + "tests/coverage/included_path/in_context_lib.py": {1, 2, 5}, + } + + if ModuleCodeCollector.file_level_coverage_enabled() and sys.version_info >= (3, 12): + expected_lines = {path: {0} for path in expected_lines} + + assert expected_lines == context_covered, f"Mismatched lines: {expected_lines} vs {context_covered}" diff --git a/tests/coverage/test_coverage_threading.py b/tests/coverage/test_coverage_threading.py index 4ebb80c6c3b..2f147b1d9bf 100644 --- a/tests/coverage/test_coverage_threading.py +++ b/tests/coverage/test_coverage_threading.py @@ -120,8 +120,7 @@ def test_coverage_context_isolated_across_threads(): import threading from ddtrace.internal.coverage.code import ModuleCodeCollector - from ddtrace.internal.coverage.code import ctx_covered - from ddtrace.internal.coverage.code import ctx_covered_files + from ddtrace.internal.coverage.code import ctx_collectors from ddtrace.internal.coverage.installer import install cwd = os.getcwd() @@ -129,11 +128,8 @@ def test_coverage_context_isolated_across_threads(): thread_entered = threading.Event() thread_can_exit = threading.Event() - with ModuleCodeCollector.CollectInContext(): - main_lines_stack = ctx_covered.get() - main_files_stack = ctx_covered_files.get() - main_lines = main_lines_stack[-1] - main_files = main_files_stack[-1] + with ModuleCodeCollector.CollectInContext() as main_collector: + main_stack = ctx_collectors.get() def worker(): # The patched _bootstrap_inner enters a coverage context before calling the target. @@ -150,18 +146,15 @@ def worker(): # On Python 3.14 the target runs in a snapshot context, so inspect the # parent's stacks while the patched bootstrap's context is still active. assert hasattr(thread, "_coverage_context") - assert ctx_covered.get() is main_lines_stack - assert ctx_covered_files.get() is main_files_stack - assert len(main_lines_stack) == len(main_files_stack) == 1 - assert main_lines_stack[-1] is main_lines - assert main_files_stack[-1] is main_files + assert ctx_collectors.get() is main_stack + assert len(main_stack) == 1 + assert main_stack[-1] is main_collector finally: thread_can_exit.set() thread.join(timeout=5) assert not thread.is_alive(), "Worker did not exit its coverage context" - assert main_lines_stack[-1] is main_lines - assert main_files_stack[-1] is main_files + assert main_stack[-1] is main_collector @pytest.mark.subprocess(env={"_DD_COVERAGE_FILE_LEVEL": "false"})