Skip to content

fix(container): stop every container being a reference cycle - #381

Merged
lesnik512 merged 1 commit into
mainfrom
fix-container-reference-cycles
Jul 27, 2026
Merged

fix(container): stop every container being a reference cycle#381
lesnik512 merged 1 commit into
mainfrom
fix-container-reference-cycles

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Implements planning/changes/2026-07-27.03-container-reference-cycles.md,
from the audit in planning/audits/2026-07-27-container-reference-cycles-report.md.

The defect

Container.__init__ seeded _scope_map with scope: self. A container referenced
its own dict and the dict referenced the container, so no container could ever be freed
by reference counting
— every one waited for a generational GC pass. A request-scoped
application produced cyclic garbage at exactly its request rate.

find_container short-circuits on its own scope before consulting the map, so the
self-entry was never actually read. Seeding the map from the parent instead removes the
cycle and changes nothing about lookup.

# before
{**parent._scope_map, scope: self} if parent else {scope: self}
# after
{**parent._scope_map, parent.scope: parent} if parent else {}

Effect

before after
100 closed REQUEST children 792 objects reclaimable only by GC 0
100 closed roots 2300 0
100 full APP→SESSION→REQUEST→ACTION→STEP chains 5500 0
build-and-drop a child, GC enabled ~849 ns ~542 ns (−36%)

Isolated construction is unchanged within noise (~500 vs ~506 ns, GC off, children
retained) — an earlier draft of this commit claimed ~2% from the smaller dict; review
refuted it as noise and it has been corrected. The win is the collector no longer having
to reclaim what refcounting now frees.

find_container is unchanged on both its own-scope and ancestor paths.

Verification

Failing test first (assert 792 == 0), and it still goes red against main after being
hardened to also assert the children genuinely became garbage — so it cannot pass by them
never being collectable.

Review independently proved behavioural equivalence exhaustively: all 31 strictly-increasing
scope chains plus 15 chains over a non-contiguous custom IntEnum, probing every scope at
every level — identical results, including both error paths and skipped-scope chains.

just test-ci 450 passed at 100% coverage · just lint-ci clean · just docs-build clean.

architecture/containers.md, architecture/scopes.md and docs/providers/advanced-api.md
updated in the same PR, per the repo's promotion rule.

Compatibility

scope_map is a deprecated public property whose contents change (it no longer contains the
container itself). Review confirmed zero references to it across all 12 sibling
modern-di-* integration repos. Worth a line in the next release notes.

🤖 Generated with Claude Code

Container.__init__ seeded _scope_map with `scope: self`, so each container
referenced itself and none could be freed by refcounting -- 8 objects per
container waiting for a generational GC pass, at the rate a request-scoped app
builds children. Seed the map from the parent instead: it now holds ancestors
only, which is all find_container ever needed, since it short-circuits on its
own scope before consulting the map.

Roots, children and full 5-deep chains all go from cyclic garbage to nothing for
the collector: 100 closed REQUEST children drop from 792 objects reclaimable
only by gc to 0, and 100 closed roots from 2300 to 0.

Isolated construction is unchanged within noise (~500 vs ~506 ns with GC off and
children retained). The win is the realistic path -- build a child and drop it,
GC enabled -- at ~542 ns vs ~849 ns, roughly 36% faster, because the collector no
longer has to reclaim what refcounting now frees. find_container is unchanged on
both its own-scope and ancestor paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@github-actions github-actions 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.

Benchmark

Details
Benchmark suite Current: d801df9 Previous: 5c46fd6 Ratio
benchmarks/test_guard_by_type.py::test_g16_resolve_by_type 1760820.5883846807 iter/sec (stddev: 2.4302388820705835e-7) 1776495.4185880087 iter/sec (stddev: 2.3876365374584567e-7) 1.01
benchmarks/test_guard_by_type.py::test_g17_resolve_by_type_large_registry 2274613.2972390437 iter/sec (stddev: 7.567030681185612e-8) 2098131.28134814 iter/sec (stddev: 6.460542283799097e-8) 0.92
benchmarks/test_guard_cold.py::test_g8_cold_first_resolve 25408.775385586385 iter/sec (stddev: 0.000018878966671758158) 20929.397330882104 iter/sec (stddev: 0.0001677672758980295) 0.82
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[1] 307.4134482685561 iter/sec (stddev: 0.00008420068191491997) 328.0705037882775 iter/sec (stddev: 0.000040796757969098145) 1.07
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[2] 292.4734495572977 iter/sec (stddev: 0.00018271240315432876) 302.8779404736962 iter/sec (stddev: 0.00034165334178571624) 1.04
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[4] 273.9324232466593 iter/sec (stddev: 0.00005276029262558306) 294.9869747771677 iter/sec (stddev: 0.00009002602027863251) 1.08
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[1] 2400.2347909678847 iter/sec (stddev: 0.00003090186276453794) 1381.0449054790115 iter/sec (stddev: 0.0015364944007164193) 0.58
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[2] 1647.311601881019 iter/sec (stddev: 0.00035612313304824806) 1290.3441418823516 iter/sec (stddev: 0.0005583511344704258) 0.78
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[4] 1166.5179259096137 iter/sec (stddev: 0.00003558330315955287) 902.1140343245934 iter/sec (stddev: 0.0005996675252015803) 0.77
benchmarks/test_guard_lifecycle.py::test_g6_build_child_container 647390.6462416914 iter/sec (stddev: 5.967936616653367e-7) 443139.369097251 iter/sec (stddev: 0.00005757834177573751) 0.68
benchmarks/test_guard_lifecycle.py::test_g6b_build_child_container_auto_scope 612331.0479834188 iter/sec (stddev: 4.850519942443344e-7) 456930.9781118482 iter/sec (stddev: 0.000010129679439609713) 0.75
benchmarks/test_guard_lifecycle.py::test_g7_request_lifecycle_batch 2099.4228287461365 iter/sec (stddev: 0.000014957869088860663) 1819.095311946131 iter/sec (stddev: 0.0001015238786932972) 0.87
benchmarks/test_guard_lifecycle.py::test_g7c_event_loop_floor_control 62715.662043159944 iter/sec (stddev: 0.0000020873382448667114) 61873.678279768945 iter/sec (stddev: 0.0000013801366212444619) 0.99
benchmarks/test_guard_lifecycle.py::test_g13_teardown_at_scale 44916.24409610219 iter/sec (stddev: 0.000002189407624844477) 41005.764669941214 iter/sec (stddev: 0.000022216300746542194) 0.91
benchmarks/test_guard_resolve.py::test_g1_transient_resolve 1182762.6686876388 iter/sec (stddev: 4.3793201176020497e-7) 1206666.3981995212 iter/sec (stddev: 3.819041100265692e-7) 1.02
benchmarks/test_guard_resolve.py::test_g2_cached_resolve 2721994.7083659554 iter/sec (stddev: 8.286984773784885e-8) 2552729.6470805504 iter/sec (stddev: 5.545615761001395e-8) 0.94
benchmarks/test_guard_resolve.py::test_g3_deep_chain 482928.6591642835 iter/sec (stddev: 7.32631684464223e-7) 496370.8506889218 iter/sec (stddev: 4.860804335165113e-7) 1.03
benchmarks/test_guard_resolve.py::test_g4_wide_resolve 301262.15260199294 iter/sec (stddev: 0.0000010893785029986201) 311827.4353613602 iter/sec (stddev: 6.527226551052641e-7) 1.04
benchmarks/test_guard_resolve.py::test_g5_cross_scope 1050092.3859710589 iter/sec (stddev: 5.868860957945767e-7) 1091669.224633086 iter/sec (stddev: 4.3090727658030746e-7) 1.04
benchmarks/test_guard_resolve.py::test_g9_context_resolve 557035.205086495 iter/sec (stddev: 6.216056332878339e-7) 557629.7402789904 iter/sec (stddev: 5.74188028526361e-7) 1.00
benchmarks/test_guard_resolve.py::test_g12_override_active_resolve 373799.5538810236 iter/sec (stddev: 6.372616601376392e-7) 359178.5990524778 iter/sec (stddev: 4.317070602455115e-7) 0.96
benchmarks/test_guard_validate.py::test_g10_validate_deep_chain 28661.203650122272 iter/sec (stddev: 0.0000037631919399003493) 27164.02425589421 iter/sec (stddev: 0.00006644609094396787) 0.95
benchmarks/test_guard_validate.py::test_g11_validate_wide 17309.681423076243 iter/sec (stddev: 0.000005259469942229982) 18257.453056036305 iter/sec (stddev: 0.000011197171312353043) 1.05

This comment was automatically generated by workflow using github-action-benchmark.

@lesnik512
lesnik512 merged commit 64b7cec into main Jul 27, 2026
9 checks passed
@lesnik512
lesnik512 deleted the fix-container-reference-cycles branch July 27, 2026 18:57
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.

1 participant