Skip to content

refactor(internal): narrow gevent module cloning to the threading family#18779

Draft
mabdinur wants to merge 4 commits into
mainfrom
munir/narrow-module-cloning
Draft

refactor(internal): narrow gevent module cloning to the threading family#18779
mabdinur wants to merge 4 commits into
mainfrom
munir/narrow-module-cloning

Conversation

@mabdinur

@mabdinur mabdinur commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Description

Narrows ddtrace's module-cloning mechanism (cleanup_loaded_modules in ddtrace/bootstrap/cloning.py) from a broad "unload almost everything from sys.modules" sweep down to just the threading-family modules ddtrace genuinely needs unpatched copies of.

Background

gevent monkey-patches stdlib modules in place. Application code that imports them therefore gets the greenlet-friendly versions automatically — the cloning sweep was never needed for application correctness. Its only purpose was to give ddtrace unpatched copies of modules it used on real threads.

Two things made the broad sweep obsolete:

  1. Background work moved to native PeriodicThreads (C/pthreads), so ddtrace no longer relies on Python threading for its own threads.
  2. This PR makes agent socket I/O self-contained (below), so it no longer needs module-level unpatched copies either.

What remained was a blunt sweep that unloaded nearly everything and required an ever-growing KEEP_MODULES allowlist (asyncio, dataclasses, pathlib, typing, protobuf, copyreg, reprlib, ...) — each entry a module that broke when cloned. That allowlist churn is also the source of a class of gevent-interaction bugs.

Changes

1. Self-contained agent socket I/O. gevent only ever replaces two socket attributes (socket.socket, socket.create_connection — verified empirically). Capture those in ddtrace/internal/_unpatched.py at import time, exactly like the existing unpatched_Popen/unpatched_open. Captured attribute references survive gevent's in-place patching.

  • _unpatched.py: add unpatched_socket, unpatched_create_connection.
  • uds.py: build the UDS socket via unpatched_socket.
  • http.py: pin self._create_connection to unpatched_create_connection.
  • cloning.py: drop the force-import of ddtrace.internal.http/uds (no longer needed for reference capture).

2. Narrow the sweep. Remove the broad "unload all except KEEP_MODULES" loop and the KEEP_MODULES allowlist entirely. Keep only the targeted UNLOAD_MODULES set (threading, _thread, time, concurrent.futures, reprlib) that ddtrace still needs unpatched copies of (chiefly the profiler's real-thread tracking). Everything else stays shared and gevent-patched in place.

Testing

All run locally on Python 3.13 (Linux):

  • tests/internal/test_auto.py — the "must pass ALWAYS" canary; updated to assert the new invariants (ddtrace keeps unpatched threading; agent I/O works after socket is patched). 5 passed.
  • tests/contrib/gevent13 passed.
  • tests/commands/test_runner.py (ddtracerun) — 102 passed. The obsolete MODULES_TO_CHECK=["asyncio"] leak-checks (which asserted asyncio is not retained) are replaced with event-loop registration tests, since narrowing intentionally keeps asyncio loaded.
  • tests/profiling/collector/test_threading.py + profiling suite — 335 passed, including the gevent monkey-patch simulation tests.

Risks

Medium. This changes a load-bearing startup mechanism for gevent users. Mitigations: native threads and the keystone remove the original reasons for the broad sweep; the profiler's gevent-simulation tests and the gevent contrib suite pass; the canary test is updated to the new contract. CI's full gevent/gunicorn/profiling matrix is the real validation gate.

Relationship to #18776

The asyncio event-loop fix (#18776) added asyncio/_asyncio to KEEP_MODULES. This PR removes KEEP_MODULES altogether — asyncio is simply never unloaded — so it supersedes that fix. If #18776 merges first, this branch drops the now-redundant allowlist entry on rebase.

ddtrace's agent HTTP/UDS connections needed unpatched, real-thread sockets
under gevent. This was achieved indirectly: cleanup_loaded_modules() force-
imported ddtrace.internal.http and ddtrace.internal.uds before the global
sys.modules sweep so they captured pre-gevent module copies.

Capture the only socket primitives gevent replaces (socket.socket and
socket.create_connection) directly in _unpatched at import time, the same way
unpatched_Popen/open already work. These attribute references survive gevent's
in-place monkey-patching, so uds and http no longer depend on the sweep having
run. Remove the force-import accordingly.

This removes one coupling between agent I/O and the module-cloning mechanism, a
step toward narrowing that mechanism.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mabdinur mabdinur added the changelog/no-changelog A changelog entry is not required for this PR. label Jun 26, 2026
@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Jun 26, 2026

Copy link
Copy Markdown

Codeowners resolved as

tests/contrib/asyncio/test_lazyimport.py                                @DataDog/apm-core-python @DataDog/apm-idm-python

@datadog-prod-us1-3

datadog-prod-us1-3 Bot commented Jun 26, 2026

Copy link
Copy Markdown

Pipelines  Tests

Fix all issues with BitsAI

⚠️ Warnings

🚦 30 Pipeline jobs failed

DataDog/apm-reliability/dd-trace-py | build linux serverless: [amd64, cp315-cp315, v113741238-d2b8243-manylinux2014_x86_64, 1]   View in Datadog   GitLab

DataDog/apm-reliability/dd-trace-py | build linux serverless: [arm64, cp315-cp315, v113741357-d2b8243-manylinux2014_aarch64, 1]   View in Datadog   GitLab

DataDog/apm-reliability/dd-trace-py | build linux serverless: [arm64, cp315-cp315, v113741589-d2b8243-musllinux_1_2_aarch64, 1]   View in Datadog   GitLab

View all 30 failed jobs.

ℹ️ Info

No other issues found (see more)

🧪 All tests passed
❄️ No new flaky tests detected

🔄 Datadog auto-retried 2 jobs - 1 passed on retry View in Datadog

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 90296fa | Docs | Datadog PR Page | Give us feedback!

…ules

Remove the broad "unload every non-KEEP module from sys.modules" sweep and the
ever-growing KEEP_MODULES allowlist. gevent monkey-patches stdlib modules in
place, so application code that imports them already gets the greenlet-friendly
versions; the sweep only ever existed to give ddtrace unpatched copies. With
native periodic threads and agent I/O now holding unpatched socket primitives
(captured in _unpatched), the only modules ddtrace still needs its own unpatched
copies of are the threading family, which UNLOAD_MODULES already covers.

This eliminates the KEEP_MODULES churn (asyncio, dataclasses, pathlib, typing,
protobuf, ...) that accreted to work around the broad sweep, and with it a class
of gevent-interaction bugs.

Tests: test_auto, contrib/gevent, ddtracerun, and profiling (incl. gevent
monkey-patch simulations) all pass locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mabdinur mabdinur changed the title refactor(internal): make agent socket I/O independent of module cloning refactor(internal): narrow gevent module cloning to the threading family Jun 26, 2026
@pr-commenter

pr-commenter Bot commented Jun 26, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-06-29 21:00:42

Comparing candidate commit 90296fa in PR branch munir/narrow-module-cloning with baseline commit be31d09 in branch main.

Found 0 performance improvements and 6 performance regressions! Performance is the same for 612 metrics, 10 unstable metrics.

scenario:iast_aspects-re_expand_aspect

  • 🟥 execution_time [+287.241µs; +331.879µs] or [+8.410%; +9.717%]

scenario:iastaspects-strip_aspect

  • 🟥 execution_time [+78.274µs; +83.472µs] or [+24.630%; +26.265%]

scenario:iastaspects-translate_aspect

  • 🟥 execution_time [+71.743µs; +78.860µs] or [+14.140%; +15.543%]

scenario:iastaspectsospath-ospathbasename_aspect

  • 🟥 execution_time [+94.487µs; +100.649µs] or [+21.172%; +22.553%]

scenario:span-start

  • 🟥 execution_time [+1.224ms; +1.410ms] or [+7.902%; +9.102%]

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

  • 🟥 execution_time [+270.685ns; +303.546ns] or [+13.116%; +14.709%]

mabdinur and others added 2 commits June 26, 2026 16:45
…ading

CI surfaced a RecursionError in ssl.SSLContext.options under gunicorn+gevent:
leaving ssl shared lets gevent patch it in place, producing a self-referential
MRO. The old broad sweep unloaded ssl (and the rest of gevent's patched set)
implicitly. Narrow UNLOAD_MODULES to exactly gevent's in-place patched modules
(socket, ssl, select, selectors, queue, signal, subprocess, time, threading,
_thread) plus concurrent.futures/reprlib, instead of only the threading family.
os is left shared (boot-loaded, fork-hooks only).

Validated: ssl.create_default_context() works after gevent.monkey.patch_all()
under forced module cloning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e cloning

Narrowed cloning never unloads asyncio, so the _asyncio C extension stays in
sync with asyncio.events and event loop registration keeps working. Replace the
obsolete skipped "asyncio not imported" test (the old workaround) with a bare
subprocess check under forced cloning.

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

Copy link
Copy Markdown
Contributor

Because we mostly use the internal native periodic threads, the threading module is no longer an issue and we probably don't need to clone that anymore. So it seems like we're quite close to removing the module cloning mechanism entirely. In #18792 I'm trying to resolve the socket issue, which is one of the other missing "isolation-from-stdlib" pieces that we need to get rid of module cloning.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog/no-changelog A changelog entry is not required for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants