Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion pyglove/core/utils/error_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,26 @@ def stacktrace(self) -> str:

@classmethod
def _compute_tag(cls, error: BaseException):
"""Returns a dotted tag of exception class names from the cause chain.

Walks up the exception cause chain (``.cause`` falling back to
``__cause__``), collecting each exception class name, guarding against
cycles.

Args:
error: The exception whose cause chain is walked.

Returns:
The exception class names joined by '.'.
"""
error_types = []
while error is not None:
# Guard against cyclic cause chains (e.g. ``e.__cause__ is e`` or
# ``a.cause -> b.cause -> a``). Without this, the loop never terminates and
# ``error_types`` grows without bound, exhausting memory (OOM). Tracking the
# identities of visited exceptions bounds the walk to distinct exceptions.
seen = set()
while error is not None and id(error) not in seen:
seen.add(id(error))
error_types.append(error.__class__.__name__)
error = getattr(error, 'cause', error.__cause__) # pyrefly: ignore[bad-assignment]
return '.'.join(error_types)
Expand Down
39 changes: 39 additions & 0 deletions pyglove/core/utils/error_utils_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,44 @@ def test_catch_errors_bad_inputs(self):
pass


class _CyclicError(Exception):
"""Exception exposing a settable ``cause`` attribute for chain-walk tests."""

def __init__(self, message: str):
super().__init__(message)
self.cause = None


class ComputeTagTest(unittest.TestCase):
"""Regression tests for ErrorInfo._compute_tag cause-chain traversal."""

def test_linear_chain(self):
# Acyclic chains are fully traversed (backward-compatible behavior).
a = _CyclicError('a')
b = _CyclicError('b')
a.cause = b
b.cause = None
self.assertEqual(
error_utils.ErrorInfo._compute_tag(a),
'_CyclicError._CyclicError',
)

def test_self_cycle_terminates(self):
# Previously looped forever, growing a list until OOM.
e = _CyclicError('boom')
e.cause = e
self.assertEqual(error_utils.ErrorInfo._compute_tag(e), '_CyclicError')

def test_two_node_cycle_terminates(self):
a = _CyclicError('a')
b = _CyclicError('b')
a.cause = b
b.cause = a
self.assertEqual(
error_utils.ErrorInfo._compute_tag(a),
'_CyclicError._CyclicError',
)


if __name__ == '__main__':
unittest.main()
Loading