Skip to content

fix!: copy the context= dict so set_context does not leak between containers (#568) - #588

Merged
lesnik512 merged 2 commits into
mainfrom
fix/copy-context-dict
Oct 5, 2026
Merged

lesnik512 merged 2 commits into
mainfrom
fix/copy-context-dict

Conversation

@lesnik512

@lesnik512 lesnik512 commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

Closes #568.

Summary

Container stored the context= dict it was given by reference. set_context() on one container wrote into the caller's dict, and every other container built from that dict saw the value. A module-level dict reused per request leaked values set on one request into the next.

The container now copies the dict on construction: copy.copy(context) if context is not None else {}. This applies to the root constructor and to build_child_container, which goes through it.

  • Failing tests first, in tests/providers/test_context_provider.py: two siblings built from one dict, two roots built from one dict, and a caller who mutates the dict after construction.
  • Migration entry in docs/migration/to-4.x.md: the container copies context=, so later changes to the caller's dict are not seen.
  • One sentence in docs/providers/context.md under "Context propagation", and the Container.__init__ docstring.

Design decisions

  • copy.copy(context). It keeps the type of a dict subclass, so a context mapping that overrides __contains__/__getitem__ keeps working, as perf(context): drop the typing.cast and dead None check from find_context #408 intended. A new test pins that. .copy() was about 40 ns cheaper but returned a plain dict for subclasses that don't override copy().
  • No copy for None. That path still builds one fresh empty dict. An empty dict is copied too, since an empty subclass instance can still answer lookups through its hooks.
  • An empty dict passed by the caller was already replaced by a fresh {} before this change, so mutating it was never seen. Only non-empty dicts were shared. The new behavior is now the same for both.

Benchmarks

Measured A/B, interleaved three times on the same machine (Apple M4, Python 3.14.7), swapping only modern_di/container.py.

Guard tier, pytest benchmarks/test_guard_lifecycle.py -k "g6_ or g7_" --benchmark-only, median in ns:

Run G6 before G6 after G7 (batch of 100) before G7 after
1 712.5 702.7 231,500 231,625
2 696.0 679.8 227,792 225,500
3 683.5 696.7 226,250 229,083

Neither G6 nor G7 passes context=, and both are within run-to-run noise.

Since neither guard scenario passes a context, a timeit script also builds a REQUEST child with context=None, context={} and context={Req: r} (median of 7 x best-of-5 x 20,000 calls, ns):

Case before (3 runs) after (3 runs)
context=None 633 / 644 / 652 629 / 650 / 650
context={} 632 / 643 / 648 642 / 645 / 647
context={Req: r} 617 / 627 / 632 677 / 670 / 659

These numbers are from the first .copy() version. With copy.copy a REQUEST child build measured 693 ns with context=None, 748 ns with {} and 756 ns with one entry (best of 7 x 100k, Python 3.14.7), so passing a context costs about 55 to 65 ns per child build. The None path does not change.

Test plan

  • New tests fail on main and pass with the fix, including a dict subclass with lookup hooks
  • just lint and just lint-ci clean
  • just test-ci passes with 100% line coverage
  • mkdocs build --strict passes
  • G6/G7 guard benchmarks and the context timeit run before and after

@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: db34177 Previous: 6423f9e Ratio
benchmarks/test_guard_by_type.py::test_g16_resolve_by_type 4585155.741746889 iter/sec (stddev: 6.786017370957142e-9) 3274737.9022109755 iter/sec (stddev: 1.4217577773135276e-8) 0.71
benchmarks/test_guard_by_type.py::test_g17_resolve_by_type_large_registry 4516149.456888419 iter/sec (stddev: 9.240038428499602e-9) 3209832.873684291 iter/sec (stddev: 1.5683069225975477e-8) 0.71
benchmarks/test_guard_cold.py::test_g8_cold_first_resolve 21132.409848689826 iter/sec (stddev: 0.00004727617921921452) 19611.099725853812 iter/sec (stddev: 0.00005857815790537035) 0.93
benchmarks/test_guard_cold.py::test_g8b_cold_first_resolve_cached 17163.83391336117 iter/sec (stddev: 0.00010991613612756768) 15732.595502787824 iter/sec (stddev: 0.00012705574219610356) 0.92
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[1] 543.4727461596158 iter/sec (stddev: 0.00005948863051109168) 332.75401405544864 iter/sec (stddev: 0.00003890731245642262) 0.61
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[2] 510.59734780275403 iter/sec (stddev: 0.00003952466801763249) 319.89014821996403 iter/sec (stddev: 0.00003372610351095769) 0.63
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[4] 475.579773584964 iter/sec (stddev: 0.00003529612353172363) 305.9350934183702 iter/sec (stddev: 0.00005272482593616402) 0.64
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[1] 2185.7093405295705 iter/sec (stddev: 0.00014595635980819063) 1787.4294199952565 iter/sec (stddev: 0.00017580003174505487) 0.82
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[2] 1774.131320246873 iter/sec (stddev: 0.00013931077311760042) 1387.3157627359249 iter/sec (stddev: 0.00017813732854738486) 0.78
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[4] 1238.9897178773442 iter/sec (stddev: 0.00015889751301613193) 989.0759854327717 iter/sec (stddev: 0.00019897698313127887) 0.80
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[1] 2214.3608312070387 iter/sec (stddev: 0.0001598867600318388) 1802.894989622912 iter/sec (stddev: 0.0001847804894103928) 0.81
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[2] 1482.904520595758 iter/sec (stddev: 0.00016108581557040038) 1247.2541051280616 iter/sec (stddev: 0.00019948674222235787) 0.84
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[4] 830.7429671488252 iter/sec (stddev: 0.0012092173088061502) 731.8253863910646 iter/sec (stddev: 0.0011328908553737416) 0.88
benchmarks/test_guard_lifecycle.py::test_g6_build_child_container 1020497.0402327698 iter/sec (stddev: 5.5672361447937614e-8) 941613.4900180469 iter/sec (stddev: 4.266590188435129e-8) 0.92
benchmarks/test_guard_lifecycle.py::test_g6b_build_child_container_auto_scope 938375.8492223457 iter/sec (stddev: 6.856677656411148e-8) 869030.7634753458 iter/sec (stddev: 2.845671569705943e-8) 0.93
benchmarks/test_guard_lifecycle.py::test_g7_request_lifecycle_batch 2802.901114785747 iter/sec (stddev: 0.000012599416610919652) 2470.939972520431 iter/sec (stddev: 0.00001000404296553261) 0.88
benchmarks/test_guard_lifecycle.py::test_g7c_event_loop_floor_control 72322.81955225223 iter/sec (stddev: 0.0000012472855301892135) 58353.07323744343 iter/sec (stddev: 0.000001811221833940914) 0.81
benchmarks/test_guard_lifecycle.py::test_g13_teardown_at_scale 50284.93389326924 iter/sec (stddev: 0.000002377002540889153) 43242.379573048835 iter/sec (stddev: 0.0000018484039762398666) 0.86
benchmarks/test_guard_lifecycle.py::test_g13b_teardown_at_scale_async_no_finalizers 666.7917977644279 iter/sec (stddev: 0.00010969663361120983) 550.541644279654 iter/sec (stddev: 0.000026569551231199374) 0.83
benchmarks/test_guard_resolve.py::test_g1_transient_resolve 2350941.9078510846 iter/sec (stddev: 2.8433012835774994e-8) 1990456.259876522 iter/sec (stddev: 2.9664739513844002e-8) 0.85
benchmarks/test_guard_resolve.py::test_g2_cached_resolve 4325863.677005932 iter/sec (stddev: 6.336510048830488e-9) 2870051.446517434 iter/sec (stddev: 1.4456877265379857e-8) 0.66
benchmarks/test_guard_resolve.py::test_g3_deep_chain 830299.522667458 iter/sec (stddev: 3.8441486472379246e-8) 677405.3937315469 iter/sec (stddev: 4.2086711658707184e-8) 0.82
benchmarks/test_guard_resolve.py::test_g4_wide_resolve 501916.9714958407 iter/sec (stddev: 4.599708037309397e-7) 454446.1683852499 iter/sec (stddev: 4.399292297608773e-7) 0.91
benchmarks/test_guard_resolve.py::test_g5_cross_scope 2204998.1243200134 iter/sec (stddev: 1.5208466602792766e-8) 1869216.0030977393 iter/sec (stddev: 2.4948572190745596e-8) 0.85
benchmarks/test_guard_resolve.py::test_g9_context_resolve 1641555.3161823992 iter/sec (stddev: 1.4475577005261042e-7) 1290165.0556396716 iter/sec (stddev: 1.289606559846295e-7) 0.79
benchmarks/test_guard_resolve.py::test_g12_override_active_resolve 841980.43733616 iter/sec (stddev: 2.9326963731542013e-8) 699147.3513732532 iter/sec (stddev: 4.0350410131016355e-8) 0.83
benchmarks/test_guard_resolve.py::test_g18_alias_hop 4308720.328492626 iter/sec (stddev: 9.739660749252664e-9) 2863128.595613249 iter/sec (stddev: 1.2733684196756908e-8) 0.66
benchmarks/test_guard_validate.py::test_g10_validate_deep_chain 28243.914824784297 iter/sec (stddev: 0.000018827157187382493) 26535.096694100852 iter/sec (stddev: 0.000024788071504037524) 0.94
benchmarks/test_guard_validate.py::test_g11_validate_wide 17110.919757914096 iter/sec (stddev: 0.000023325516062714315) 15730.931271704567 iter/sec (stddev: 0.000026224409536436096) 0.92

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

@lesnik512
lesnik512 force-pushed the fix/copy-context-dict branch from 592ebcf to db34177 Compare October 5, 2026 08:45
@lesnik512
lesnik512 merged commit b217d41 into main Oct 5, 2026
9 checks passed
@lesnik512
lesnik512 deleted the fix/copy-context-dict branch October 5, 2026 08:46
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.

context= dict is stored by reference and leaks between containers

1 participant