Skip to content

Commit fe8e3bc

Browse files
authored
Avoid creating ref cycles (#408)
By storing previously raised exceptions inside a local, this code created ref cycles that kept all locals in all calling stack frames alive. This is because exceptions hold references to their tracebacks, which hold references to the relevant frames, which holds a reference to the local errors dict that holds references to the exceptions. See https://peps.python.org/pep-0344/#open-issue-garbage-collection and https://peps.python.org/pep-3110/#rationale This breaks the cycle by deleting the local when we raise, so frames are destroyed by the normal reference counting mechanism. This fixes some resource exhaustion issues I encountered at work.
1 parent bab0b3f commit fe8e3bc

File tree

3 files changed

+45
-9
lines changed

3 files changed

+45
-9
lines changed

docs/versionhistory.rst

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ Version history
44
This library adheres to
55
`Semantic Versioning 2.0 <https://semver.org/#semantic-versioning-200>`_.
66

7+
**UNRELEASED**
8+
9+
- Avoid creating reference cycles when type checking unions
10+
711
**4.1.5** (2023-09-11)
812

913
- Fixed ``Callable`` erroneously rejecting a callable that has the requested amount of

src/typeguard/_checkers.py

+12-9
Original file line numberDiff line numberDiff line change
@@ -395,16 +395,19 @@ def check_union(
395395
memo: TypeCheckMemo,
396396
) -> None:
397397
errors: dict[str, TypeCheckError] = {}
398-
for type_ in args:
399-
try:
400-
check_type_internal(value, type_, memo)
401-
return
402-
except TypeCheckError as exc:
403-
errors[get_type_name(type_)] = exc
398+
try:
399+
for type_ in args:
400+
try:
401+
check_type_internal(value, type_, memo)
402+
return
403+
except TypeCheckError as exc:
404+
errors[get_type_name(type_)] = exc
404405

405-
formatted_errors = indent(
406-
"\n".join(f"{key}: {error}" for key, error in errors.items()), " "
407-
)
406+
formatted_errors = indent(
407+
"\n".join(f"{key}: {error}" for key, error in errors.items()), " "
408+
)
409+
finally:
410+
del errors # avoid creating ref cycle
408411
raise TypeCheckError(f"did not match any element in the union:\n{formatted_errors}")
409412

410413

tests/test_checkers.py

+29
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,35 @@ def test_union_fail(self, annotation, value):
768768
f" int: is not an instance of int"
769769
)
770770

771+
@pytest.mark.skipif(
772+
sys.implementation.name != "cpython",
773+
reason="Test relies on CPython's reference counting behavior",
774+
)
775+
def test_union_reference_leak(self):
776+
leaked = True
777+
778+
class Leak:
779+
def __del__(self):
780+
nonlocal leaked
781+
leaked = False
782+
783+
def inner1():
784+
leak = Leak() # noqa: F841
785+
check_type(b"asdf", Union[str, bytes])
786+
787+
inner1()
788+
assert not leaked
789+
790+
leaked = True
791+
792+
def inner2():
793+
leak = Leak() # noqa: F841
794+
with pytest.raises(TypeCheckError, match="any element in the union:"):
795+
check_type(1, Union[str, bytes])
796+
797+
inner2()
798+
assert not leaked
799+
771800

772801
class TestTypevar:
773802
def test_bound(self):

0 commit comments

Comments
 (0)