Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
5 changes: 5 additions & 0 deletions newsfragments/47.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Add ``unwrap_and_destroy`` method to remove references to
the wrapped exception or value to prevent issues where
values not being garbage collected when they are no longer
needed, or worse problems with exceptions leaving a
reference cycle.
56 changes: 54 additions & 2 deletions src/outcome/_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,23 @@ def unwrap(self) -> ValueT:
x = fn(*args)
x = outcome.capture(fn, *args).unwrap()

Note: this leaves a reference to the contained value or exception
alive which may result in values not being garbage collected or
exceptions leaving a reference cycle. If this is an issue it's
recommended to call the ``unwrap_and_destroy()`` method
Comment on lines +142 to +144
Copy link
Copy Markdown
Contributor

@A5rocks A5rocks Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should focus on the root cause of any issues, like maybe (wording very much a first draft):

Suggested change
alive which may result in values not being garbage collected or
exceptions leaving a reference cycle. If this is an issue it's
recommended to call the ``unwrap_and_destroy()`` method
alive which may result in contents having a strong reference
from anything that has a strong reference to the outcome. This
will keep the contents alive longer than you may expect.
As an example of something with this extra strong reference,
consider::
def use_an_outcome(some_outcome):
print("I got", some_outcome.unwrap())
del some_outcome
# note: the item within `some_outcome` will still be alive here!
print("later")
operator.call(use_an_outcome, <construct an outcome>)
This is because some functions keep arguments alive. Another
thing that could cause an extra strong reference is having some
variable containing the outcome.

(and also maybe make this note block a warning?)


"""

@abc.abstractmethod
def unwrap_and_destroy(self) -> ValueT:
"""Return or raise the contained value or exception, remove the
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"""Return or raise the contained value or exception, remove the
"""Return or raise the contained value or exception, removing the

reference to the contained value or exception.

These two lines of code are equivalent::

x = fn(*args)
x = outcome.capture(fn, *args).unwrap_and_destroy()

"""

@abc.abstractmethod
Expand Down Expand Up @@ -174,12 +191,21 @@ class Value(Outcome[ValueT], Generic[ValueT]):
"""The contained value."""

def __repr__(self) -> str:
return f'Value({self.value!r})'
try:
return f'Value({self.value!r})'
except AttributeError:
return 'Value(<AlreadyDestroyed>)'

def unwrap(self) -> ValueT:
self._set_unwrapped()
return self.value

def unwrap_and_destroy(self) -> ValueT:
self._set_unwrapped()
v = self.value
object.__delattr__(self, "value")
return v

def send(self, gen: Generator[ResultT, ValueT, object]) -> ResultT:
self._set_unwrapped()
return gen.send(self.value)
Expand All @@ -202,7 +228,10 @@ class Error(Outcome[NoReturn]):
"""The contained exception object."""

def __repr__(self) -> str:
return f'Error({self.error!r})'
try:
return f'Error({self.error!r})'
except AttributeError:
return 'Error(<AlreadyDestroyed>)'

def unwrap(self) -> NoReturn:
self._set_unwrapped()
Expand All @@ -226,6 +255,29 @@ def unwrap(self) -> NoReturn:
# __traceback__ from indirectly referencing 'captured_error'.
del captured_error, self

def unwrap_and_destroy(self) -> NoReturn:
self._set_unwrapped()
# Tracebacks show the 'raise' line below out of context, so let's give
# this variable a name that makes sense out of context.
captured_error = self.error
object.__delattr__(self, "error")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really necessary to do here? Or could we move this into the finally block with the other stuff.

try:
raise captured_error
finally:
# We want to avoid creating a reference cycle here. Python does
# collect cycles just fine, so it wouldn't be the end of the world
# if we did create a cycle, but the cyclic garbage collector adds
# latency to Python programs, and the more cycles you create, the
# more often it runs, so it's nicer to avoid creating them in the
# first place. For more details see:
#
# https://github.com/python-trio/trio/issues/1770
#
# In particuar, by deleting this local variables from the 'unwrap'
# methods frame, we avoid the 'captured_error' object's
# __traceback__ from indirectly referencing 'captured_error'.
del captured_error, self
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probably isn't necessary anymore:

Suggested change
del captured_error, self
del captured_error

and I remember hearing that CPython does some magic with names pointing to the exception being thrown, so this line might be completely unnecessary? But maybe I'm misremembering.

Copy link
Copy Markdown

@x42005e1f x42005e1f Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CPython does some magic with names pointing to the exception being thrown

Only for except blocks (names after as):

>>> exc = ...
>>> "exc" in locals()
True
>>> try:
...     raise RuntimeError
... except BaseException as exc:
...     "exc" in locals()
True
>>> "exc" in locals()
False


def send(self, gen: Generator[ResultT, NoReturn, object]) -> ResultT:
self._set_unwrapped()
return gen.throw(self.error)
Expand Down
14 changes: 13 additions & 1 deletion tests/test_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ def test_Outcome():
with pytest.raises(AlreadyUsedError):
v.unwrap()

v = Value(1)
v = Value(2)
assert v.unwrap_and_destroy() == 2
assert repr(v) == "Value(<AlreadyDestroyed>)"
with pytest.raises(AlreadyUsedError):
v.unwrap_and_destroy()

exc = RuntimeError("oops")
e = Error(exc)
Expand All @@ -33,12 +37,20 @@ def test_Outcome():
with pytest.raises(TypeError):
Error(RuntimeError)

e2 = Error(exc)
with pytest.raises(RuntimeError):
e2.unwrap_and_destroy()
with pytest.raises(AlreadyUsedError):
e2.unwrap_and_destroy()
assert repr(e2) == "Error(<AlreadyDestroyed>)"

def expect_1():
assert (yield) == 1
yield "ok"

it = iter(expect_1())
next(it)
v = Value(1)
assert v.send(it) == "ok"
with pytest.raises(AlreadyUsedError):
v.send(it)
Expand Down
Loading