diff --git a/pyglove/core/utils/error_utils.py b/pyglove/core/utils/error_utils.py index 639eb3c..8538268 100644 --- a/pyglove/core/utils/error_utils.py +++ b/pyglove/core/utils/error_utils.py @@ -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) diff --git a/pyglove/core/utils/error_utils_test.py b/pyglove/core/utils/error_utils_test.py index d56d3a6..92959c1 100644 --- a/pyglove/core/utils/error_utils_test.py +++ b/pyglove/core/utils/error_utils_test.py @@ -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()