Skip to content

Commit

Permalink
Avoid creating ref cycles (#408)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
hauntsaninja authored Jan 15, 2024
1 parent bab0b3f commit fe8e3bc
Show file tree
Hide file tree
Showing 3 changed files with 45 additions and 9 deletions.
4 changes: 4 additions & 0 deletions docs/versionhistory.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ Version history
This library adheres to
`Semantic Versioning 2.0 <https://semver.org/#semantic-versioning-200>`_.

**UNRELEASED**

- Avoid creating reference cycles when type checking unions

**4.1.5** (2023-09-11)

- Fixed ``Callable`` erroneously rejecting a callable that has the requested amount of
Expand Down
21 changes: 12 additions & 9 deletions src/typeguard/_checkers.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,16 +395,19 @@ def check_union(
memo: TypeCheckMemo,
) -> None:
errors: dict[str, TypeCheckError] = {}
for type_ in args:
try:
check_type_internal(value, type_, memo)
return
except TypeCheckError as exc:
errors[get_type_name(type_)] = exc
try:
for type_ in args:
try:
check_type_internal(value, type_, memo)
return
except TypeCheckError as exc:
errors[get_type_name(type_)] = exc

formatted_errors = indent(
"\n".join(f"{key}: {error}" for key, error in errors.items()), " "
)
formatted_errors = indent(
"\n".join(f"{key}: {error}" for key, error in errors.items()), " "
)
finally:
del errors # avoid creating ref cycle
raise TypeCheckError(f"did not match any element in the union:\n{formatted_errors}")


Expand Down
29 changes: 29 additions & 0 deletions tests/test_checkers.py
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,35 @@ def test_union_fail(self, annotation, value):
f" int: is not an instance of int"
)

@pytest.mark.skipif(
sys.implementation.name != "cpython",
reason="Test relies on CPython's reference counting behavior",
)
def test_union_reference_leak(self):
leaked = True

class Leak:
def __del__(self):
nonlocal leaked
leaked = False

def inner1():
leak = Leak() # noqa: F841
check_type(b"asdf", Union[str, bytes])

inner1()
assert not leaked

leaked = True

def inner2():
leak = Leak() # noqa: F841
with pytest.raises(TypeCheckError, match="any element in the union:"):
check_type(1, Union[str, bytes])

inner2()
assert not leaked


class TestTypevar:
def test_bound(self):
Expand Down

0 comments on commit fe8e3bc

Please sign in to comment.