Skip to content

Commit 0d615fd

Browse files
committed
ExceptionAlgo : Fix translatePythonException() reference counting bug
The `PyErr_Fetch()` documentation says "you own a reference to each object retrieved", which means we need to decrement the reference count before returning, as we have no interest in sharing ownership. We were doing this via the `boost::python::handle<>` objects in `formatInternal()`, but since d0731a3 we haven't been calling `formatInternal()` for exceptions bound via `IECorePython::ExceptionClass`. That meant we were leaking such Python exceptions such that they would never be destroyed. The solution, and the moral of the story, is to always hold `PyObject *` via a sensible RAII class like `handle` or `object`, and to always do that _as early as possible_. You might be forgiven for thinking that leaking a little exception object isn't that big a deal. But Python exceptions have a `__traceback__` attribute that contains the entire stack at the point the exception was raised, and _that_ contains all the local variables from all of those stack frames. So the leak can actually include a completely arbitrary amount of stuff.
1 parent c106737 commit 0d615fd

File tree

3 files changed

+72
-22
lines changed

3 files changed

+72
-22
lines changed

Changes

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
10.5.x.x (relative to 10.5.4.1)
22
========
33

4+
Fixes
5+
-----
46

7+
- ExceptionAlgo : Fixed memory leak in `translatePythonException()`, which was failing to manage the reference count for exceptions bound by `IECorePython::ExceptionClass`.
58

69
10.5.4.1 (relative to 10.5.4.0)
710
========

src/IECorePython/ExceptionAlgo.cpp

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -46,30 +46,23 @@ namespace
4646
{
4747

4848
std::string formatInternal(
49-
PyObject *exceptionPyObject, PyObject *valuePyObject, PyObject *tracebackPyObject,
49+
const handle<> &exceptionHandle, const handle<> &valueHandle, const handle<> &tracebackHandle,
5050
bool withStacktrace, int *lineNumber = nullptr
5151
)
5252
{
53-
if( !exceptionPyObject )
54-
{
55-
throw IECore::Exception( "No Python exception set" );
56-
}
57-
58-
PyErr_NormalizeException( &exceptionPyObject, &valuePyObject, &tracebackPyObject );
53+
object exception( exceptionHandle );
5954

60-
object exception( ( handle<>( exceptionPyObject ) ) );
61-
62-
// valuePyObject and tracebackPyObject may be null.
55+
// `valueHandle` and `tracebackHandle` may be null.
6356
object value;
64-
if( valuePyObject )
57+
if( valueHandle )
6558
{
66-
value = object( handle<>( valuePyObject ) );
59+
value = object( valueHandle );
6760
}
6861

6962
object traceback;
70-
if( tracebackPyObject )
63+
if( tracebackHandle )
7164
{
72-
traceback = object( handle<>( tracebackPyObject ) );
65+
traceback = object( tracebackHandle );
7366
}
7467

7568
if( lineNumber )
@@ -102,6 +95,20 @@ std::string formatInternal(
10295
return s;
10396
}
10497

98+
std::tuple<handle<>, handle<>, handle<>> currentPythonException()
99+
{
100+
PyObject *exceptionPyObject, *valuePyObject, *tracebackPyObject;
101+
PyErr_Fetch( &exceptionPyObject, &valuePyObject, &tracebackPyObject );
102+
if( !exceptionPyObject )
103+
{
104+
throw IECore::Exception( "No Python exception set" );
105+
}
106+
107+
PyErr_NormalizeException( &exceptionPyObject, &valuePyObject, &tracebackPyObject );
108+
109+
return std::make_tuple( handle<>( exceptionPyObject ), handle<>( allow_null( valuePyObject ) ), handle<>( allow_null( tracebackPyObject ) ) );
110+
}
111+
105112
} // namespace
106113

107114
namespace IECorePython
@@ -112,28 +119,26 @@ namespace ExceptionAlgo
112119

113120
std::string formatPythonException( bool withStacktrace, int *lineNumber )
114121
{
115-
PyObject *exceptionPyObject, *valuePyObject, *tracebackPyObject;
116-
PyErr_Fetch( &exceptionPyObject, &valuePyObject, &tracebackPyObject );
117-
return formatInternal( exceptionPyObject, valuePyObject, tracebackPyObject, withStacktrace, lineNumber );
122+
auto [exception, value, traceback] = currentPythonException();
123+
return formatInternal( exception, value, traceback, withStacktrace, lineNumber );
118124
}
119125

120126
void translatePythonException( bool withStacktrace )
121127
{
122-
PyObject *exceptionPyObject, *valuePyObject, *tracebackPyObject;
123-
PyErr_Fetch( &exceptionPyObject, &valuePyObject, &tracebackPyObject );
128+
auto [exception, value, traceback] = currentPythonException();
124129

125130
// If the python exception is one bound via IECorePython::ExceptionClass,
126131
// then we can extract and throw the C++ exception held internally.
127-
if( PyObject_HasAttrString( valuePyObject, "__exceptionPointer" ) )
132+
if( PyObject_HasAttrString( value.get(), "__exceptionPointer" ) )
128133
{
129-
object exceptionPointerMethod( handle<>( PyObject_GetAttrString( valuePyObject, "__exceptionPointer" ) ) );
134+
object exceptionPointerMethod( handle<>( PyObject_GetAttrString( value.get(), "__exceptionPointer" ) ) );
130135
object exceptionPointerObject = exceptionPointerMethod();
131136
std::exception_ptr exceptionPointer = boost::python::extract<std::exception_ptr>( exceptionPointerObject )();
132137
std::rethrow_exception( exceptionPointer );
133138
}
134139

135140
// Otherwise, we just throw a generic exception describing the python error.
136-
throw IECore::Exception( formatInternal( exceptionPyObject, valuePyObject, tracebackPyObject, withStacktrace ) );
141+
throw IECore::Exception( formatInternal( exception, value, traceback, withStacktrace ) );
137142
}
138143

139144
} // namespace ExceptionAlgo

test/IECore/MessageHandler.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
##########################################################################
3434

3535
from __future__ import with_statement
36+
import gc
3637
import unittest
3738
import threading
3839
import time
@@ -224,5 +225,46 @@ def testLifetime( self ) :
224225

225226
self.assertEqual( w(), None )
226227

228+
def testExceptionHandling( self ) :
229+
230+
# This is more a test of ExceptionAlgo than it is MessageHandler, but
231+
# this is the most convenient place to put the test right now.
232+
233+
class ThrowingMessageHandler( IECore.MessageHandler ):
234+
235+
def __init__( self ) :
236+
237+
IECore.MessageHandler.__init__( self )
238+
239+
def handle( self, level, context, msg ):
240+
241+
if context == "python" :
242+
raise Exception( "Test" )
243+
else :
244+
assert( context == "c++" )
245+
# This will raise a C++ exception that gets translated to a
246+
# Python exception, and then gets translated back to a C++
247+
# exception by the MessageHandlerWrapper, before finally
248+
# being converted back to another Python exception by the
249+
# binding for `IECore.msg()`.
250+
IECore.StringAlgo.substitute( "##", { "frame" : IECore.BoolData( False ) } )
251+
252+
for exceptionType in [ "python", "c++" ] :
253+
254+
with self.subTest( exceptionType = exceptionType ) :
255+
256+
while gc.collect() :
257+
pass
258+
259+
expected = "Test" if exceptionType == "python" else "Unexpected data type"
260+
with self.assertRaisesRegex( Exception, expected ) :
261+
with ThrowingMessageHandler() :
262+
IECore.msg( IECore.Msg.Level.Error, exceptionType, "message" )
263+
264+
# There should be no Exception objects in the garbage pool. If
265+
# there were, that would indicate a reference counting error in
266+
# `ExceptionAlgo::translatePythonException()`.
267+
self.assertEqual( [ o for o in gc.get_objects() if isinstance( o, Exception ) ], [] )
268+
227269
if __name__ == "__main__":
228270
unittest.main()

0 commit comments

Comments
 (0)