Restore leak attribution on modern CPython: hybrid traversal, scenario suite, CI revamp - #7
Merged
Merged
Conversation
… revamp Exporter (objex/exporter.py): - named edge handlers for coroutine (.cr_frame/.cr_code/.cr_await), async_generator, traceback (.tb_frame/.tb_next), cell (.cell_contents), slice, super, weakref.ref (.__callback__ only; referent is a weak edge), BaseException (.args/.__traceback__/.__cause__/.__context__/.__notes__), generator .gi_yieldfrom, function .__kwdefaults__ - generic gc.get_referents (tp_traverse) fallback labeled '<gc>' for every type outside _GC_COMPLETE_TYPES: covers C iterators, asyncio.Task, ContextVar, functools.partial, lru_cache caches, and future CPython types. Perf: bare dump 0.855s -> 0.97s (+13%), within budget - fix: on CPython 3.11+ running stacks materialize frame objects lazily (after the gc.get_objects() snapshot), so live frames had zero outbound edges and thread/coroutine locals were unattributable; frames are now queued when first seen and traversed at the end of add_all Tests: - tests/scenarios/leak_cases.py + tests/test_scenarios.py: 7 subprocess leak scenarios (module_global, exception_traceback, closure_cell, suspended_coroutine, thread_frame, lru_cache, iterator_pin), each proving path-to-module/frame attribution through its tricky edge - tests/test_gc_coverage.py: gc.get_referents ground-truth audit; fails with a readable per-type miss list when a Python upgrade adds untraversed types - non-slow suite 629s -> ~35s: raw-dump Reader opens repointed at the indexed analysis DB (unindexed attributed-size build is pathological; TODO.md note), same for the forked dump in test_spawn_dump_and_wait_dump; deleted vacuous test_dump_graph_survives_getfullargspec_failures (exporter no longer calls getfullargspec) CLI (objex/__main__.py): - 'q' subcommand: one-shot JSON queries wrapping web.dispatch_request (summary, root-summary, path-to-module, go, ...) plus a read-only 'sql' escape hatch (sqlite mode=ro URI); exit 0 on 200, 1 otherwise CI (.github/workflows/python-tests.yml): - pytest with markers honored (was unittest, which ran slow tests on every push); test + integration (slow) jobs, uv-managed 3.10-3.14 matrix, concurrency cancellation. Verified locally: non-slow green on 3.10-3.14, slow green on 3.12 (2m49s), 3.13 (3m04s), 3.14 (6m01s) Docs: docs/2026-08-06_leak_tool_landscape.md (tool landscape + positioning table), README when-to-use + agent/scripting sections, pyproject dev group restored, 3.14 classifier, scenario marker
mahmoud
force-pushed
the
modern-cpython-coverage
branch
from
August 6, 2026 21:05
7206ca2 to
eda0014
Compare
kurtbrose
approved these changes
Aug 6, 2026
Owner
|
let me know if you want me to merge :-) |
Owner
|
eh tests all pass we can just merge |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
objex's core promise is leak attribution: "what is still holding these objects, and from which module/frame root." That promise was quietly broken on exactly the leak classes where you need it most. The exporter discovers edges by scraping
__dict__/__slots__and a handful of named handlers, so C-implemented types had zero outbound edges: coroutines, async generators, tracebacks, cells, every C iterator,slice,super, weakrefs,ContextVar,GenericAlias,asyncio.Task. Exceptions andfunctools.partialonly showed their__dict__. The result: objects pinned by a suspended coroutine, a stored exception's frame chain, or anlru_cacheshowed up as spurious "roots," andfind_path_to_module/find_path_to_framesilently returned nothing. These gaps are structural, not version drift — verified identical on 3.10 and 3.12.Worse, modern CPython added a second silent hole: since 3.11, running stacks materialize frame objects lazily, after the exporter's
gc.get_objects()snapshot. Live thread and coroutine frames gotpyframerows but were never traversed — zero.locals[...]edges, so thread-held and coroutine-held leaks were entirely unattributable on 3.11+.None of this was caught because nothing tested the actual product claim (a path to a root through a tricky edge), and CI ran
unittest, which ignores pytest markers — so every push ran the slow in-process dumps and nothing else meaningful.What
Exporter — hybrid traversal. Named handlers (with human-legible labels, objex's UX advantage) for the high-value types: coroutine
.cr_frame/.cr_code/.cr_await, async generator, traceback.tb_frame/.tb_next, cell.cell_contents,slice,super,weakref.ref(.__callback__only — the referent is deliberately a weak edge, matching gc semantics),BaseException(.args/.__traceback__/.__cause__/.__context__/.__notes__), generator.gi_yieldfrom, function.__kwdefaults__. Then a genericgc.get_referents(tp_traverse) fallback labeled<gc>for everything not already covered: this is ground truth from the C level, and it future-proofs against types nobody enumerated (iterators, Task, ContextVar,lru_cacheinternals, whatever 3.15 adds). Exact builtin containers are exempted so the hot path stays hot: bare-interpreter dump goes 0.855s → 0.97s (+13%, budget was 25%).Exporter — lazy-frame fix. Frames encountered anywhere (
sys._current_frames(),f_back,cr_frame, ...) are queued and traversed at the end ofadd_all, restoring locals attribution on 3.11+.Tests that defend the promise.
tests/test_scenarios.pybuilds 7 leaks in bare subprocesses — module global, exception-pinned frame chain, closure cell, suspended coroutine task, parked thread frame,lru_cache, iterator-pinned dict — and asserts a payload instance is attributable: a non-empty path to a module/frame root passing through the scenario's anchor edge (.__traceback__/.tb_frame,.locals['payloads'],<gc>).tests/test_gc_coverage.pyis the Python-upgrade regression guard: everytp_traverseedge among seen objects must exist in thereferencetable, and failures print a readable per-type miss list.CI that runs what it claims. pytest with markers honored: a fast
testjob (non-slow + scenarios) and anintegrationjob (-m slow, the in-process full-dump smokes), uv-managed 3.10–3.14 matrix, concurrency cancellation. The non-slow suite dropped 629s → ~35s: almost all of the old runtime wasReaderbeing opened on a raw unindexed collection DB, which triggers the one-time attributed-size build without indices (pathological; noted in TODO.md rather than "fixed," since users are directed throughmake-analysis-db). The same pathology turned out to hang the forked-dump slow test for >55 min under pytest; it now routes throughmake_analysis_dblike users do.Agent/scripting interface. The explorer is a human REPL; scripts and agents need one-shot JSON. New
qsubcommand wraps the existingdispatch_requestseam (no duplicated query logic):python -m objex q analysis.db path-to-module id=4211, plus a read-onlysqlescape hatch (sqlitemode=ro, enforced by test).Positioning.
docs/2026-08-06_leak_tool_landscape.mdrecords where objex sits among leak tools (memray/tracemalloc answer where is it allocated; objex answers who still holds it) — no maintained tool combines fork-and-dump capture, a portable SQLite artifact, offline SQL analysis, and labeled path-to-root attribution. README gains when-to-use and agent-usage sections.Verification