Skip to content

fix(internal): release WrappingContext storage when a context raises - #20016

Merged
gh-worker-dd-mergequeue-cf854d[bot] merged 5 commits into
mainfrom
alberto.vara/APPSEC-69960-wrapping-context-storage-leak
Sep 3, 2026
Merged

fix(internal): release WrappingContext storage when a context raises#20016
gh-worker-dd-mergequeue-cf854d[bot] merged 5 commits into
mainfrom
alberto.vara/APPSEC-69960-wrapping-context-storage-leak

Conversation

@avara1986

@avara1986 avara1986 commented Sep 2, 2026

Copy link
Copy Markdown
Member

Description

A wrapping context that raises is skipped by the machinery that would have popped its per-call
ContextVar storage. The storage chains one dict per call through _STORAGE_PREV and is never
released, so on a long-lived worker thread it grows without bound, and on two of the three paths
the universal storage is among the retained dicts — so it keeps hold of __frame__.

Measured on Python 3.13, before the fix:

__enter__ raises:   after 1 call  concrete chain=1  universal chain=0  frame_retained=False
                    after 8 calls concrete chain=8  universal chain=0  frame_retained=False

__return__ raises:  after 1 call  concrete chain=1  universal chain=1  frame_retained=True
                    after 5 calls concrete chain=5  universal chain=5  frame_retained=True

__exit__ raises:    after 5 calls concrete chain=5  universal chain=5  frame_retained=True

Scope: only the __enter__ path is fixed here. __return__ / __exit__ and the 3.15
monitoring path leak the same way, but releasing the storage there is not a matter of adding a pop —
_exit and on_py_unwind read _SKIP_EXIT_KEY back off that storage, so popping first makes them
act on the enclosing call's storage. Documented in place and tracked in APPSEC-69961, with the two test parameters
covering them marked xfail(strict=True).

Two distinct causes, both on __enter__:

  • __enter___UniversalWrappingContext.__enter__ appends to entered only after a
    successful call, so a context that raises is absent from the list __exit__ later iterates. Its
    __enter__ has usually already pushed storage via super().__enter__(). From 3.11 the universal
    storage is fine here, because the injected exception handler still reaches _exit().
  • __enter__ below 3.11 — those versions enter through a real with statement, and Python does
    not call __exit__ when __enter__ raises. So the universal storage, which holds __frame__,
    and the storage of contexts that did enter are left behind as well. Gated on the version,
    because popping here on 3.11+ would double-pop and discard an outer re-entrant call's storage.

The fix releases storage on all three paths, comparing storage identity against a snapshot taken
before the call. That identity check is the load-bearing part: popping unconditionally would
discard an outer re-entrant call's storage, since a context that already popped now holds a
different object.

Existing semantics are deliberately unchanged: an Exception from __enter__ is still swallowed
and logged so a broken context cannot break the call, and a BaseException still propagates.

Impact

Latent today. No shipped WrappingContext subclass raises by design, so triggering it needs a bug
in a context's __enter__ / __return__ / __exit__ — which an Exception-derived failure does,
even though it is swallowed and logged. A debugger probe or code-origin context hitting an
unexpected error would leak.

It becomes reachable by design in #19989, where BlockingException is raised from __enter__ to
stop a blocked outgoing request, i.e. one leaked dict per blocked request.

Jira: https://datadoghq.atlassian.net/browse/APPSEC-69960
Blocks: #19989

Risks

Medium — this is shared instrumentation used by the debugger, code origin, selenium and the lazy
module loader.

  • The re-entrancy hazard is the thing to review closely. _release_storage pops only when the
    context still holds the exact object it held before the call, so an already-popped context is
    left alone.
  • Cleanup is wrapped so a failure to release cannot mask the original exception.
  • The except Exception in __enter__ widened to except BaseException in order to run cleanup,
    then re-raises non-Exception unchanged. Behaviour for Exception is identical to before.

Additional Notes

Split out of #19989 at review request: the change is in shared wrapping infrastructure and wants
its own review rather than riding along in an AppSec PR.

🤖 Generated with Claude Code

…[APPSEC-69960]

A wrapping context that raises is skipped by the machinery that would have
popped its per-call ContextVar storage, so the storage chains one dict per
call through _STORAGE_PREV and is never released. On a long-lived worker
thread it grows without bound, and on two of the three paths the universal
storage is among the retained dicts, so it keeps hold of __frame__.

Three paths, measured on 3.13:

  __enter__   concrete leaks, chain 1 -> 8 over 8 calls; universal is fine,
              because the injected exception handler still reaches _exit()
  __return__  both leak, chain 1 -> 5 over 5 calls, universal keeps __frame__
  __exit__    same as __return__

On __enter__ the cause is that `entered` is appended to only after a
successful call, so a context that raises is absent from the list __exit__
later iterates. On __return__ the universal context sets _SKIP_EXIT_KEY and
re-raises without reaching super().__return__, and the resulting __exit__ is
suppressed on purpose, so nothing pops anything.

Release the storage on all three paths, comparing storage identity against a
snapshot taken before the call. That identity check is what makes it safe:
popping unconditionally would discard an outer re-entrant call's storage.

Existing semantics are unchanged. An Exception from __enter__ is still
swallowed and logged so a broken context cannot break the call, and a
BaseException still propagates to the caller.

Latent today, since no shipped context raises by design; it needs a bug in a
context's __enter__/__return__/__exit__ to trigger, which an Exception-derived
failure does even though it is swallowed. It becomes reachable by design in
APPSEC-69877, where BlockingException is raised from __enter__ to stop a
blocked outgoing request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codeowners resolved as

Resolved from the full PR diff against main using the target branch CODEOWNERS file.
CODEOWNERS team requests not listed below are not required by the current file set.

No remaining files require a CODEOWNERS review.

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Sep 2, 2026

Copy link
Copy Markdown

Dependency direction analysis

⚠️ Existing dependency direction violations

There are 230 dependency direction violations that already exist on the base branch and have not been changed by this PR.

Show existing violations (showing 5 of 230 highest severity)
ddtrace.internal.tracemethods -×-> ddtrace.trace  (internal-core -> product:tracing, score=134)
ddtrace.llmobs._integrations.vertexai -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=132)
ddtrace.llmobs._integrations.bedrock -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=132)
ddtrace.internal.ci_visibility.recorder -×-> ddtrace.trace  (product:ci_visibility -> product:tracing, score=132)
ddtrace.llmobs._integrations.bedrock_agents -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=132)

To see all violations, download the layers-base.json and layers-pr.json artifacts from this CI job and run:

uv run --script scripts/import-analysis/layers.py compare layers-base.json layers-pr.json

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Sep 2, 2026

Copy link
Copy Markdown

Circular import analysis

⚠️ Existing circular imports

There are 3 circular imports that already exist on the base branch and have not been changed by this PR.

ddtrace.errortracking._handled_exceptions.bytecode_injector -> ddtrace.errortracking._handled_exceptions.callbacks -> ddtrace.errortracking._handled_exceptions.collector -> ddtrace.errortracking._handled_exceptions.bytecode_reporting -> ddtrace.errortracking._handled_exceptions.bytecode_injector
ddtrace.llmobs -> ddtrace.llmobs._evaluators -> ddtrace.llmobs._evaluators.format -> ddtrace.llmobs._experiment -> ddtrace.llmobs
ddtrace.appsec._asm_request_context -> ddtrace.appsec._iast._iast_request_context_base -> ddtrace.appsec._iast._iast_env -> ddtrace.appsec._iast.reporter -> ddtrace.appsec._exploit_prevention.stack_traces -> ddtrace.appsec._asm_request_context

@datadog-datadog-prod-us1

datadog-datadog-prod-us1 Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 404121a | Docs | View more details | Give us feedback!

@pr-commenter

pr-commenter Bot commented Sep 2, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-09-03 16:49:09

Comparing candidate commit 404121a in PR branch alberto.vara/APPSEC-69960-wrapping-context-storage-leak with baseline commit 390562c in branch main.

📊 Benchmarking dashboard

Found 0 performance improvements and 6 performance regressions! Performance is the same for 575 metrics, 10 unstable metrics, 2 known flaky benchmarks, 16 flaky benchmarks without significant changes.

Explanation

This is an A/B test comparing a candidate commit's performance against that of a baseline commit. Performance changes are noted in the tables below as:

  • 🟩 = significantly better candidate vs. baseline
  • 🟥 = significantly worse candidate vs. baseline

We compute a confidence interval (CI) over the relative difference of means between metrics from the candidate and baseline commits, considering the baseline as the reference.

If the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD), the change is considered significant.

Feel free to reach out to #apm-benchmarking-platform on Slack if you have any questions.

More details about the CI and significant changes

You can imagine this CI as a range of values that is likely to contain the true difference of means between the candidate and baseline commits.

CIs of the difference of means are often centered around 0%, because often changes are not that big:

---------------------------------(------|---^--------)-------------------------------->
                              -0.6%    0%  0.3%     +1.2%
                                 |          |        |
         lower bound of the CI --'          |        |
sample mean (center of the CI) -------------'        |
         upper bound of the CI ----------------------'

As described above, a change is considered significant if the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD).

For instance, for an execution time metric, this confidence interval indicates a significantly worse performance:

----------------------------------------|---------|---(---------^---------)---------->
                                       0%        1%  1.3%      2.2%      3.1%
                                                  |   |         |         |
       significant impact threshold --------------'   |         |         |
                      lower bound of CI --------------'         |         |
       sample mean (center of the CI) --------------------------'         |
                      upper bound of CI ----------------------------------'

scenario:httppropagationinject-ids_only

  • 🟥 execution_time [+2.982µs; +3.132µs] or [+17.505%; +18.389%]

scenario:iastaspects-swapcase_noaspect

  • 🟥 execution_time [+16.582µs; +24.005µs] or [+8.034%; +11.631%]

scenario:iastaspectsospath-ospathbasename_aspect

  • 🟥 execution_time [+110.884µs; +118.542µs] or [+26.451%; +28.277%]

scenario:otelspan-start

  • 🟥 execution_time [+2.445ms; +3.470ms] or [+7.903%; +11.219%]

scenario:telemetryaddmetric-1-count-metric-1-times

  • 🟥 execution_time [+621.232ns; +666.774ns] or [+22.955%; +24.637%]

scenario:tracer-small

  • 🟥 execution_time [+33.849µs; +36.235µs] or [+9.943%; +10.644%]

Unstable benchmarks

These benchmarks have a confidence interval too wide to call a change; treat them as noise rather than signal.

scenario:coreapiscenario-context_with_data_listeners

  • unstable execution_time [-683.918ns; +806.831ns] or [-6.131%; +7.233%]

scenario:coreapiscenario-core_dispatch_1_listener

  • unstable execution_time [-32.804ns; +33.532ns] or [-5.350%; +5.469%]

scenario:coreapiscenario-core_dispatch_50_listeners

  • unstable execution_time [-1481.447ns; +1859.941ns] or [-8.627%; +10.831%]

scenario:coreapiscenario-core_dispatch_exception_listeners

  • unstable execution_time [-1075.748ns; +1476.069ns] or [-8.116%; +11.136%]

scenario:coreapiscenario-core_dispatch_listeners

  • unstable execution_time [-322.452ns; +340.630ns] or [-8.617%; +9.103%]

scenario:coreapiscenario-core_dispatch_no_args_listeners

  • unstable execution_time [-227.978ns; +292.308ns] or [-7.722%; +9.901%]

scenario:coreapiscenario-core_dispatch_with_results_1_listener

  • unstable execution_time [-69.504ns; +76.765ns] or [-6.141%; +6.783%]

scenario:coreapiscenario-core_dispatch_with_results_50_listeners

  • unstable execution_time [-4114.251ns; +3827.373ns] or [-10.092%; +9.388%]

scenario:coreapiscenario-core_dispatch_with_results_listeners

  • unstable execution_time [-785.745ns; +745.253ns] or [-9.740%; +9.238%]

scenario:packagesupdateimporteddependencies-import_many_stdlib_cached

  • unstable execution_time [-55726.362ns; +54500.165ns] or [-9.640%; +9.428%]

Known flaky benchmarks

These benchmarks are marked as flaky and will not trigger a failure. Modify FLAKY_BENCHMARKS_REGEX to control which benchmarks are marked as flaky.

scenario:iastaspects-casefold_noaspect

  • 🟥 execution_time [+33.714µs; +43.274µs] or [+13.023%; +16.716%]

scenario:span-start

  • 🟥 execution_time [+1.908ms; +2.068ms] or [+13.523%; +14.653%]

Known flaky benchmarks without significant changes:

  • scenario:errortrackingflasksqli-baseline
  • scenario:flasksimple-iast-get
  • scenario:iastaspects-casefold_aspect
  • scenario:iastaspects-index_aspect
  • scenario:iastaspects-ljust_noaspect
  • scenario:iastaspects-lower_aspect
  • scenario:iastaspects-replace_aspect
  • scenario:iastaspects-swapcase_aspect
  • scenario:iastaspects-title_noaspect
  • scenario:iastaspects-translate_aspect
  • scenario:iastaspects-translate_noaspect
  • scenario:iastaspects-upper_noaspect
  • scenario:packagespackageforrootmodulemapping-cache_off
  • scenario:packagespackageforrootmodulemapping-cache_on
  • scenario:sethttpmeta-all-enabled
  • scenario:telemetryaddmetric-record-100-metrics

Below 3.11 the wrapped function enters through a real `with` statement, and
Python does not call __exit__ when __enter__ raises. So on those versions the
universal context's own storage, and that of the contexts that did enter, are
also left behind when a context raises out of __enter__ - not just the
storage of the context that raised.

From 3.11 the injected exception handler reaches _exit(), which already
released them, which is why this only showed up on py3.9 in CI:

  assert _storage_chain_length(universal._storage.get()) == 0
  AssertionError: assert 5 == 0

Release both from the raising path when the version needs it. Gating on the
version keeps 3.11+ untouched, where popping here would be wrong: _exit()
still runs, and a double pop would discard an outer re-entrant call's storage.

tests/internal/test_wrapping.py now passes on 3.9 through 3.14, 59 tests each.
Setting the gate to False reproduces the CI failure on 3.9, so the existing
test does cover this path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@avara1986
avara1986 marked this pull request as ready for review September 2, 2026 12:37
@avara1986
avara1986 requested review from a team as code owners September 2, 2026 12:37
@avara1986 avara1986 changed the title fix(internal): release WrappingContext storage when a context raises [APPSEC-69960] fix(internal): release WrappingContext storage when a context raises Sep 2, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6557190ecb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread ddtrace/internal/wrapping/context.py Outdated
Comment thread ddtrace/internal/wrapping/context.py
…EC-69960]

Codex review caught that releasing the storage inside __return__ is wrong, and
it is: _exit (bytecode path) and on_py_unwind (monitoring path) read
_SKIP_EXIT_KEY back off that same storage. Popping it first makes them read the
enclosing call's storage instead, so with a re-entrant wrapped call they exit
the outer call's contexts and pop its storage while its body is still running.
Non-re-entrant it is harmless, since __exit__ bails on a missing __contexts__,
but the re-entrant case is real corruption.

Revert __return__ and __exit__ to their original shape and keep only the
__enter__ fix, which is what APPSEC-69877 needs and what is verifiable here.
The remaining leaks are documented in place and tracked in APPSEC-69961:

- __return__ / __exit__ raising still leak, and fixing that means releasing
  where the flag is consumed rather than before, with different handling below
  3.11 where the with statement's __exit__ already cleans up.
- The 3.15 monitoring path has the same shape as the pre-3.11 with path, since
  on_py_unwind returns early on the flag. Not fixed here because 3.15 is
  outside requires-python and absent from CI, so it cannot be verified.

The two test parameters for those paths are now xfail(strict=True) against that
ticket, so they flip to a failure as soon as the leak is fixed.

This also removes the only cost the PR added to the happy path: __return__ and
__exit__ no longer build a {id: storage} snapshot on every call.

Matrix: tests/internal/test_wrapping.py 57 passed, 2 xfailed on 3.9 through
3.14. tests/wrapping/ 276 passed on 3.9 and 322 on 3.14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@emmettbutler emmettbutler left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a crazy find, I wonder if it will solve a lot of strange memory leaks we've observed.

@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot merged commit 6eff716 into main Sep 3, 2026
1535 of 1537 checks passed
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot deleted the alberto.vara/APPSEC-69960-wrapping-context-storage-leak branch September 3, 2026 19:54
avara1986 added a commit that referenced this pull request Sep 4, 2026
…EC-69877]

Review feedback. try_wrap_context's module hook returned early whenever
(module_name, name) was already in the registry, treating the key as proof
that the current target is wrapped. It is not: when http.client or
urllib.request is reloaded, or dropped from sys.modules and imported again,
ModuleWatchdog fires the hook for freshly defined functions while the
registry still holds a context bound to the previous ones. The new
HTTPConnection and OpenerDirector methods then ran without SSRF and API10
instrumentation until a full unpatch/patch cycle.

This was a regression against the wrapt path it replaced, which handled the
case for free: wrap_object runs on every hook fire, and apply_patch wraps
whatever the attribute holds at that point.

Compare the installed context's __wrapped__ against the freshly resolved
target instead, and rebind when they differ, releasing the stale context
first. A failure to release is logged and does not stop the rebind.

Also adds the regression test for the blocking-path storage leak that review
raised against this PR. The framework fix landed separately in #20016
(APPSEC-69960), but this is the code that raises BlockingException from
__enter__, so the assertion belongs here: three blocked requests must leave
both the concrete and the universal ContextVar unset, or each block chains
another storage dict onto the worker thread.

Both are mutation-checked: removing the rebind, or removing the storage
release in _UniversalWrappingContext.__enter__, fails the corresponding test.

appsec::appsec on py3.13: 708 passed, 1 xfailed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants