Catch Me If You Can - Part II
It has been quite some time since I wrote the Catch Me If You
Can post about
the inability of LLVM-backed compilers to catch non-call hardware exceptions. In
other words, hardware exceptions raised by code written in the same block as the
try...except
exception handler.
Since then, more LLVM-backed compilers have been added to Delphi, and with those, more reasons to change common coding practices... but still, very few developers know about the issue.
The original article covered only try...except
exception handlers, and
exploring what exactly happens with implicit and explicit try...finally
handlers was left as an exercise to the readers and to some future, now long
overdue, post.
And Now: All Hell Breaks Loose
When you read about non-call hardware exceptions not being caught by an immediate
try...except
block, the implications might not look too serious. After all,
while plenty of code can raise exceptions, try...except
handlers are not that
frequently used in places, as most exceptions will be left to bubble up at the
next code level.
This is good, you may think, as such exception handlers will be able to catch exceptions raised within other procedures and methods and you might not have that many places where you would need to wrap your code within another procedure or method call, or add additional checks that would prevent hardware exceptions happening in the first place.
However, the same reason why hardware exceptions can escape try...except
blocks applies to try...finally
blocks, and when that happens, the
consequences are way, way more serious.
Explicit try...finally
blocks
Let's take another look at the example from the previous article:
type
TFoo = class(TObject)
public
procedure Foo; virtual;
end;
procedure TFoo.Foo;
begin
end;
procedure Escape;
var
Foo: TFoo;
begin
Foo := nil;
try
// calling a virtual method on nil reference raises an Access Violation exception
Foo.Foo;
except
Log.d('CAUGHT');
end;
end;
The issue with the above Escape
procedure is that the exception will bubble up, and
will not be caught by the exception handler, so any code within that handler
will not be executed. You may think that the worst that can happen is that
instead of showing some nice error message to the user that some operation
failed and why, your application will show the default message box brought up by the
application-level exception handler.
Now, let's modify that code and add a try...finally
instead.
procedure Escape;
var
Foo: TFoo;
Bar: TObject;
begin
Foo := nil;
Bar := TObject.Create;
try
// calling a virtual method on nil reference raises an Access Violation exception
Foo.Foo;
finally
Bar.Free; // this line is never called
end;
end;
The consequences of the above code will be that the code in the finally
section will
not run, causing the memory leak. While in this example the memory leak is
rather small, even small leaks can accumulate and cause severe memory fragmentation
in long-running processes. And of course, in real life, that leaked memory could
have been much larger.
This is bad, and you would definitely need to examine and fix all code where a
hardware exception might escape a try...finally
block. Depending on the size of
your codebase, this could mean days and weeks of work.
Implicit try...finally
blocks
If you think that things cannot get any worse, think again. Besides explicit
try...finally
blocks, Delphi also inserts implicit, hidden, try...finally
blocks to perform appropriate cleanup for any managed type. And just because
you don't see them, that does not mean they don't exist, and they will also be
totally broken if the non-call hardware exception occurs within the code they
are meant to protect.
This means that every string, every dynamic array, every anonymous method, every interface, will leak in such a scenario.
procedure Escape;
var
Foo: TFoo;
Bar: TObject;
Baz: IInterface;
begin
Foo := nil;
Baz := TInterfacedObject.Create;
Bar := TObject.Create;
try
// calling a virtual method on nil reference raises an Access Violation exception
Foo.Foo;
finally
Bar.Free; // this line is never called
end;
end; // cleanup code in the epilogue is never called
In the above example, not only will the Bar
object instance leak, but the Baz
instance will leak, too.
procedure Escape;
var
Foo: TFoo;
Baz: IInterface;
begin
Foo := nil;
Baz := TInterfacedObject.Create;
// calling a virtual method on nil reference raises an Access Violation exception
Foo.Foo;
end; // cleanup code in the epilogue is never called
In the above code, we only have implicit cleanup, which will not happen. If we
replaced an interface reference with the most commonly used type—a string—all those
strings would leak if the hardware exception happened directly within the
Escape
procedure code.
So, not only can you not simply focus on locating and fixing the problematic code
within various try...
blocks, but you need to pay attention to implicit
cleanup code that may be less obvious. And this also makes wrapping code within
another procedure or method harder, as you may need to cut the code up into multiple
methods instead of a single one.
On the Bright Side...
For the vast majority of applications, the most common hardware exceptions will be access violations and floating point exceptions. And with floating point exceptions being masked, you don't have to worry much about those. The rest of the hardware exceptions will commonly come from some lower-level code or API that will already be wrapped inside some procedure call, and as such, will not cause you any trouble.
So, most of the code you need to change will be to make sure that you don't
access nil
instances where you can expect one, and rely on exception handling
as part of the normal code workflow in such a situation.
This leaves unexpected access violation errors stemming from some serious issues in your code, which will require code inspection and fixing the problem anyway. While it would still be better if you could properly catch those and have a more controlled application shutdown and better error reporting, in real life, unless you have an extremely buggy application, you will rarely see an escaping hardware exception making your application go haywire.
That is another reason why many developers are not aware of this issue. It is simply because they have never encountered such a situation in real life.
If you are writing code for LLVM-backed compilers, you should pay more attention
to this problem, and make sure that code that could cause hardware exceptions
either avoids causing one in the first place (accessing nil
), or that you wrap
such code within a separate procedure or method which will not be inlined and
will not have any local implicit or explicit exception handling blocks.
But chances are that most of your code will not require any special adjustments.
You can read more about the underlying LLVM issue at https://github.com/llvm/llvm-project/issues/1641
Thanks! I'm thankful that I spend my life in Windows, but even though you say most code will not require special adjustments it still spooks the heck out of me to ever use their LLVM-backed compilers based on this.
ReplyDeleteWe have been spoiled by Delphi compilers and Windows SEH. Other platforms and languages work on different premises. That alone is not such a problem as changing the coding practice.
DeleteI also updated the post with link to underlying LLVM issue, because the issue exists in LLVM and it is not Delphi specific.
Once again you have underscored why NOT to use LLVM backed compilers. It is hard to believe that a flaw this fundamental has STILL not yet be been addressed either by the LLVM compiler or Delphi's automatic management of a known flaw in the compiler.
ReplyDeleteI don't consider myself to be "spoiled" by the delphi compiler doing what it claims to do, I feel betrayed by the choice to force flawed technology upon developers.
If the issue in LLVM could be easily fixed, that would have been done already.
DeleteThe technology is not flawed per-se. LLVM originated from supporting languages with slightly different requirements, and it also works fine for Delphi, but it also requires slight change in coding practice in some areas.
If LLVM is so bad, then giants like Apple and Google wouldn't be using it.
It is not the "flaw" in the technology that is as much of a problem, but the required changes for the existing code. And LLVM was and still is the best solutions for compiler backend that allowed Embarcadero to extend Delphi across different platforms.